[
  {
    "path": ".editorconfig",
    "content": "# editorconfig.org\nroot = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\nindent_size = 2\nindent_style = space\ninsert_final_newline = true\nmax_line_length = 250\ntrim_trailing_whitespace = true\n\n[*.{js,json}]\nindent_size = 4\nindent_style = tab\n"
  },
  {
    "path": ".gitattributes",
    "content": "# .gitattributes snippet to force users to use same line endings for project.\n# \n# Handle line endings automatically for files detected as text\n# and leave all files detected as binary untouched.\n* text=auto\n\n#\n# The above will handle all files NOT found below\n# https://help.github.com/articles/dealing-with-line-endings/\n# https://github.com/Danimoth/gitattributes/blob/master/Web.gitattributes\n\n\n\n# These files are text and should be normalized (Convert crlf => lf)\n*.php text\n*.css text\n*.scss text\n*.js text\n*.json text\n*.htm text\n*.html text\n*.xml text\n*.txt text\n*.ini text\n*.inc text\n*.pl text\n*.rb text\n*.py text\n*.scm text\n*.sql text\n.htaccess text\n*.sh text\nDockerfile* text\n*.yml text\n*.yaml text\n*.md text\n*.markdown text\n\n# These files are binary and should be left untouched\n# (binary is a macro for -text -diff)\n*.png binary\n*.jpg binary\n*.jpeg binary\n*.gif binary\n*.ico binary\n*.mov binary\n*.mp4 binary\n*.mp3 binary\n*.flv binary\n*.fla binary\n*.swf binary\n*.gz binary\n*.zip binary\n*.7z binary\n*.ttf binary\n*.pyc binary"
  },
  {
    "path": ".github/CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participation in our\ncommunity a harassment-free experience for everyone, regardless of age, body\nsize, visible or invisible disability, ethnicity, sex characteristics, gender\nidentity and expression, level of experience, education, socio-economic status,\nnationality, personal appearance, race, caste, color, religion, or sexual\nidentity and orientation.\n\nWe pledge to act and interact in ways that contribute to an open, welcoming,\ndiverse, inclusive, and healthy community.\n\n## Our Standards\n\nExamples of behavior that contributes to a positive environment for our\ncommunity include:\n\n- Demonstrating empathy and kindness toward other people\n- Being respectful of differing opinions, viewpoints, and experiences\n- Giving and gracefully accepting constructive feedback\n- Accepting responsibility and apologizing to those affected by our mistakes,\n  and learning from the experience\n- Focusing on what is best not just for us as individuals, but for the overall\n  community\n\nExamples of unacceptable behavior include:\n\n- The use of sexualized language or imagery, and sexual attention or advances of\n  any kind\n- Trolling, insulting or derogatory comments, and personal or political attacks\n- Public or private harassment\n- Publishing others' private information, such as a physical or email address,\n  without their explicit permission\n- Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Enforcement Responsibilities\n\nCommunity leaders are responsible for clarifying and enforcing our standards of\nacceptable behavior and will take appropriate and fair corrective action in\nresponse to any behavior that they deem inappropriate, threatening, offensive,\nor harmful.\n\nCommunity leaders have the right and responsibility to remove, edit, or reject\ncomments, commits, code, wiki edits, issues, and other contributions that are\nnot aligned to this Code of Conduct, and will communicate reasons for moderation\ndecisions when appropriate.\n\n## Scope\n\nThis Code of Conduct applies within all community spaces, and also applies when\nan individual is officially representing the community in public spaces.\nExamples of representing our community include using an official email address,\nposting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported to the community leaders responsible for enforcement:\nContact [Rejas](https://forum.magicmirror.builders/user/rejas),\n[Karsten](https://forum.magicmirror.builders/user/karsten13),\n[Sam](https://forum.magicmirror.builders/user/sdetweil) or\n[Kristjan](https://forum.magicmirror.builders/user/kristjanesperanto)\nvia private message in the forum.\n\nAll complaints will be reviewed and investigated promptly and fairly.\n\nAll community leaders are obligated to respect the privacy and security of the\nreporter of any incident.\n\n## Enforcement Guidelines\n\nCommunity leaders will follow these Community Impact Guidelines in determining\nthe consequences for any action they deem in violation of this Code of Conduct:\n\n### 1. Correction\n\n**Community Impact**: Use of inappropriate language or other behavior deemed\nunprofessional or unwelcome in the community.\n\n**Consequence**: A private, written warning from community leaders, providing\nclarity around the nature of the violation and an explanation of why the\nbehavior was inappropriate. A public apology may be requested.\n\n### 2. Warning\n\n**Community Impact**: A violation through a single incident or series of\nactions.\n\n**Consequence**: A warning with consequences for continued behavior. No\ninteraction with the people involved, including unsolicited interaction with\nthose enforcing the Code of Conduct, for a specified period of time. This\nincludes avoiding interactions in community spaces as well as external channels\nlike social media. Violating these terms may lead to a temporary or permanent\nban.\n\n### 3. Temporary Ban\n\n**Community Impact**: A serious violation of community standards, including\nsustained inappropriate behavior.\n\n**Consequence**: A temporary ban from any sort of interaction or public\ncommunication with the community for a specified period of time. No public or\nprivate interaction with the people involved, including unsolicited interaction\nwith those enforcing the Code of Conduct, is allowed during this period.\nViolating these terms may lead to a permanent ban.\n\n### 4. Permanent Ban\n\n**Community Impact**: Demonstrating a pattern of violation of community\nstandards, including sustained inappropriate behavior, harassment of an\nindividual, or aggression toward or disparagement of classes of individuals.\n\n**Consequence**: A permanent ban from any sort of public interaction within the\ncommunity.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage],\nversion 2.1, available at\n[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].\n\nCommunity Impact Guidelines were inspired by\n[Mozilla's code of conduct enforcement ladder][Mozilla CoC].\n\nFor answers to common questions about this code of conduct, see the FAQ at\n[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at\n[https://www.contributor-covenant.org/translations][translations].\n\n[homepage]: https://www.contributor-covenant.org\n[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html\n[Mozilla CoC]: https://github.com/mozilla/diversity\n[FAQ]: https://www.contributor-covenant.org/faq\n[translations]: https://www.contributor-covenant.org/translations\n"
  },
  {
    "path": ".github/CONTRIBUTING.md",
    "content": "# Contribution Policy for MagicMirror²\n\nThanks for contributing to MagicMirror²!\n\nWe hold our code to standard, and these standards are documented below.\n\n## Linters\n\nWe use [prettier](https://prettier.io/) for automatic formatting a lot all our files. The configuration is in our `prettier.config.mjs` file.\n\nTo run prettier, use `node --run lint:prettier`.\n\n### JavaScript: Run ESLint\n\nWe use [ESLint](https://eslint.org) to lint our JavaScript files. The configuration is in our `eslint.config.mjs` file.\n\nTo run ESLint, use `node --run lint:js`.\n\n### CSS: Run StyleLint\n\nWe use [StyleLint](https://stylelint.io) to lint our CSS. The configuration is in our `stylelint.config.mjs` file.\n\nTo run StyleLint, use `node --run lint:css`.\n\n### Markdown: Run markdownlint\n\nWe use [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2) to lint our markdown files. The configuration is in our `.markdownlint.json` file.\n\nTo run markdownlint, use `node --run lint:markdown`.\n\n## Testing\n\nWe use [Vitest](https://vitest.dev) for JavaScript testing.\n\nTo run all tests, use `node --run test`.\n\nThe `package.json` scripts expose finer-grained test commands:\n\n- `test:unit` – run unit tests only\n- `test:e2e` – execute browser-driven end-to-end tests\n- `test:electron` – launch the Electron-based regression suite\n- `test:coverage` – collect coverage while running every suite\n- `test:watch` – keep Vitest in watch mode for fast local feedback\n- `test:ui` – open the Vitest UI dashboard (needs OS file-watch support enabled)\n- `test:calendar` – run the legacy calendar debug helper\n- `test:css`, `test:markdown`, `test:prettier`, `test:spelling`, `test:js` – lint-only scripts that enforce formatting, spelling, markdown style, and ESLint.\n\nYou can invoke any script with `node --run <script>` (or `npm run <script>`). Individual files can still be targeted directly, e.g. `npx vitest run tests/e2e/env_spec.js`.\n"
  },
  {
    "path": ".github/FUNDING.yaml",
    "content": "github: MichMich\ncustom: [\"https://magicmirror.builders/#donate\"]\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: 🐛 Report a problem\ndescription: Report an issue with MagicMirror² 🚨\ntitle: \"[Bug] {{ brief description }}\"\nlabels:\n  - bug\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thanks for reporting a bug! Please fill in the following template to help us reproduce the issue.\n        Please only submit reproducible issues. If you're not sure if it's a real bug or if it's just you, please open a topic on the forum.\n  - type: textarea\n    id: environment\n    attributes:\n      label: Environment\n      description: |\n        Please tell us about how your MagicMirror² is set up.\n\n        Optimal would be the systeminformation from the logs, which looks like this:\n        ```bash\n        [2025-01-14 20:05:03.529] [INFO]  System information:\n        ### SYSTEM:    manufacturer: Raspberry Pi Foundation; model: Raspberry Pi 4 Model B Rev 1.5; virtual: false\n        ### OS:        platform: linux; distro: Debian GNU/Linux; release: 12; arch: arm64; kernel: 6.1.21-v8+\n        ### VERSIONS:  electron: 31.2.1; used node: 20.15.0; installed node: 22.4.1; npm: 10.8.1; pm2:\n        ### OTHER:     timeZone: Europe/Berlin; ELECTRON_ENABLE_GPU: undefined\n        ```\n\n        If you can't provide this information, please provide the following:\n        - MagicMirror² version: Can be found in the `package.json` file. Please use the latest version before reporting a bug.\n        - Node version: Run `node -v` to find out. Make sure it's version 20 or later (recommended is 22).\n        - npm version: Run `npm -v` to find out.\n        - Platform: Are you using a Raspberry Pi (2/3/4/5), Windows, Mac, Linux, Docker, or something else?\n      value: |\n        MagicMirror² version: \n        Node version: \n        npm version: \n        Platform:\n    validations:\n      required: true\n  - type: dropdown\n    id: start-option\n    attributes:\n      label: Which start option are you using?\n      description: |\n        Please keep in mind that some problems are specific to certain start options.\n      options:\n        - \"node --run start\"\n        - \"node --run start:wayland\"\n        - \"node --run start:windows\"\n        - \"node --run start:x11\"\n        - \"node --run server\"\n        - \"node clientonly --address ... --port ...\"\n    validations:\n      required: true\n  - type: dropdown\n    id: pm2\n    attributes:\n      label: Are you using PM2?\n      options:\n        - \"No\"\n        - \"Yes\"\n        - \"I don't know\"\n    validations:\n      required: true\n  - type: dropdown\n    id: module\n    attributes:\n      label: Module\n      description: |\n        If the issue is related to a specific module, please provide the name of the module.\n        Note: Please don't report issues with 3rd party modules here. Report them on the module's repository.\n      options:\n        - \"alert\"\n        - \"calendar\"\n        - \"clock\"\n        - \"compliments\"\n        - \"helloworld\"\n        - \"newsfeed\"\n        - \"updatenotification\"\n        - \"weather\"\n  - type: checkboxes\n    id: module-disabled\n    attributes:\n      label: Have you tried disabling other modules?\n      options:\n        - label: \"Yes\"\n        - label: \"No\"\n  - type: checkboxes\n    id: search\n    attributes:\n      label: Have you searched if someone else has already reported the issue on the forum or in the issues?\n      options:\n        - label: \"Yes\"\n          required: true\n  - type: textarea\n    id: description\n    attributes:\n      label: What did you do?\n      description: |\n        Please include a *minimal* reproduction case. List the step by step process to reproduce the issue.\n        You can use Markdown in this field.\n      value: |\n        <details>\n        <summary>Configuration</summary>\n\n        ```\n        <!-- Paste your configuration here. Don't forget to remove any sensitive information! -->\n        ```\n        </details>\n\n        ```js\n        <!-- Paste relevant code here -->\n        ```\n\n        Steps to reproduce the issue:\n    validations:\n      required: true\n  - type: textarea\n    id: expectation\n    attributes:\n      label: What did you expect to happen?\n      description: |\n        You can use Markdown in this field.\n    validations:\n      required: true\n  - type: textarea\n    id: lint-output\n    attributes:\n      label: What actually happened?\n      description: |\n        Please copy-paste relevant log output or error messages.\n        You can use Markdown in this field.\n    validations:\n      required: true\n\n  - type: textarea\n    id: comments\n    attributes:\n      label: Additional comments\n      description: |\n        Is there anything else that's important for the team to know?\n        Fill out all fields and provide as much information as possible.\n        Adding screenshots might help us understand your problem better.\n\n  - type: checkboxes\n    attributes:\n      label: Participation\n      options:\n        - label: \"I am willing to submit a pull request for this change.\"\n          required: false\n\n  - type: markdown\n    attributes:\n      value: Please **do not** open a pull request until this issue has been accepted by the team.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/change_request.yml",
    "content": "name: 🔀 Request a change\ndescription: Request a change that is not a bug fix, a feature request or a support request.\ntitle: \"[Change Request] {{ brief description }}\"\nlabels:\n  - enhancement\n  - core\nbody:\n  - type: markdown\n    attributes:\n      value: Thanks for requesting a change! Please fill in the following template to help us understand your request.\n  - type: textarea\n    attributes:\n      label: What problem do you want to solve with this change?\n      description: |\n        Please explain your use case in as much detail as possible.\n      placeholder: |\n        Currently...\n    validations:\n      required: true\n  - type: textarea\n    attributes:\n      label: What do you think is the correct solution?\n      description: |\n        Please explain how you'd like to change MagicMirror² to address the problem.\n      placeholder: |\n        I'd like MagicMirror² to...\n    validations:\n      required: true\n  - type: checkboxes\n    attributes:\n      label: Participation\n      options:\n        - label: I am willing to submit a pull request for this change.\n          required: false\n  - type: markdown\n    attributes:\n      value: Please **do not** open a pull request until this issue has been accepted by the team.\n  - type: textarea\n    attributes:\n      label: Additional comments\n      description: Is there anything else that's important for the team to know?\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: 📚 Documentation\n    url: https://github.com/MagicMirrorOrg/MagicMirror-Documentation/issues\n    about: This issue tracker is not for documentation issues. Please file documentation issues on the docs repo.\n  - name: 🤔 Support Question\n    url: https://forum.magicmirror.builders/\n    about: Problems installing or configuring your MagicMirror? Please post your question on the MagicMirror² Forum.\n  - name: 💬 Exchange of ideas\n    url: https://discord.gg/AmGBBwPph5\n    about: This issue tracker is not for general discussion. Please use the Discord channel.\n  - name: 📦 Issues with a 3rd-party module\n    url: https://kristjanesperanto.github.io/MagicMirror-3rd-Party-Modules/\n    about: This issue tracker is not for 3rd-party module issues. Please file 3rd-party module issues on the module's repo.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: 🚀 Feature Request\ndescription: Suggest a new feature for MagicMirror² 💡\ntitle: \"[Feature Request] {{ brief description }}\"\nbody:\n  - type: checkboxes\n    id: prerequisites\n    attributes:\n      label: Prerequisites\n      description: Please ensure you have completed all of the following.\n      options:\n        - label: I am running the latest version of MagicMirror², and know that this feature is not available now.\n          required: true\n        - label: I know my issue is not related to a third-party module.\n          required: true\n        - label: I have searched for [existing issues](https://github.com/MagicMirrorOrg/MagicMirror/issues) that already include this feature request, without success.\n          required: true\n\n  - type: textarea\n    id: description\n    attributes:\n      label: Describe the Feature Request\n      description: A clear and concise description of what the feature does.\n    validations:\n      required: true\n\n  - type: textarea\n    id: use-case\n    attributes:\n      label: Describe the Use Case\n      description: A clear and concise use case for what problem this feature would solve.\n    validations:\n      required: true\n\n  - type: textarea\n    id: proposed-solution\n    attributes:\n      label: Describe Preferred Solution\n      description: A clear and concise description of how you want this feature to be added to MagicMirror².\n\n  - type: textarea\n    id: alternatives-considered\n    attributes:\n      label: Describe Alternatives\n      description: A clear and concise description of any alternative solutions or features you have considered.\n\n  - type: textarea\n    id: related-code\n    attributes:\n      label: Related Code\n      description: If you are able to illustrate the feature request with an example, please provide a sample here.\n\n  - type: textarea\n    id: additional-information\n    attributes:\n      label: Additional Information\n      description: List any other information that is relevant to your issue. Related issues, suggestions on how to implement, Stack Overflow links, forum links, etc.\n\n  - type: checkboxes\n    attributes:\n      label: Participation\n      options:\n        - label: I am willing to submit a pull request for this change.\n          required: false\n\n  - type: markdown\n    attributes:\n      value: Please **do not** open a pull request until this issue has been accepted by the team.\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "content": "Hello and thank you for wanting to contribute to the MagicMirror² project!\n\n**Please make sure that you have followed these 3 rules before submitting your Pull Request:**\n\n> 1. Base your pull requests against the `develop` branch.\n> 2. Include these infos in the description:\n>\n> - Does the pull request solve a **related** issue?\n> - If so, can you reference the issue like this `Fixes #<issue_number>`?\n> - What does the pull request accomplish? Use a list if needed.\n> - If it includes major visual changes please add screenshots.\n>\n> 3. Please run `node --run lint:prettier` before submitting so that\n>    style issues are fixed.\n\n**Note**: Sometimes the development moves very fast. It is highly\nrecommended that you update your branch of `develop` before creating a\npull request to send us your changes. This makes everyone's lives\neasier (including yours) and helps us out on the development team.\n\nThanks again and have a nice day!\n"
  },
  {
    "path": ".github/dependabot.yaml",
    "content": "version: 2\nupdates:\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n    target-branch: \"develop\"\n    labels:\n      - \"dependencies\"\n\n  - package-ecosystem: \"npm\"\n    directory: \"/\"\n    schedule:\n      interval: \"monthly\"\n    target-branch: \"develop\"\n    labels:\n      - \"dependencies\"\n      - \"javascript\"\n"
  },
  {
    "path": ".github/workflows/automated-tests.yaml",
    "content": "# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node\n# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions\n\nname: \"Run Automated Tests\"\n\non:\n  push:\n    branches: [master, develop]\n  pull_request:\n    branches: [master, develop]\n\npermissions:\n  contents: read\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: true\n\njobs:\n  code-style-check:\n    runs-on: ubuntu-latest\n    timeout-minutes: 15\n    steps:\n      - name: \"Checkout code\"\n        uses: actions/checkout@v6\n      - name: \"Use Node.js\"\n        uses: actions/setup-node@v6\n        with:\n          node-version: lts/*\n          cache: \"npm\"\n      - name: \"Install dependencies\"\n        run: |\n          node --run install-mm:dev\n      - name: \"Run linter tests\"\n        run: |\n          node --run test:prettier\n          node --run test:js\n          node --run test:css\n          node --run test:markdown\n  test:\n    runs-on: ubuntu-24.04\n    timeout-minutes: 30\n    strategy:\n      matrix:\n        node-version: [22.21.1, 22.x, 24.x]\n    steps:\n      - name: Install electron dependencies and labwc\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y libnss3 libasound2t64 labwc\n      - name: \"Checkout code\"\n        uses: actions/checkout@v6\n      - name: \"Use Node.js ${{ matrix.node-version }}\"\n        uses: actions/setup-node@v6\n        with:\n          node-version: ${{ matrix.node-version }}\n          check-latest: true\n          cache: \"npm\"\n      - name: \"Install MagicMirror²\"\n        run: |\n          node --run install-mm:dev\n      - name: \"Install Playwright browsers\"\n        run: |\n          npx playwright install --with-deps chromium\n      - name: \"Prepare environment for tests\"\n        run: |\n          # Fix chrome-sandbox permissions:\n          sudo chown root:root ./node_modules/electron/dist/chrome-sandbox\n          sudo chmod 4755 ./node_modules/electron/dist/chrome-sandbox\n          # Start labwc\n          WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 WLR_RENDERER=pixman labwc &\n          touch css/custom.css\n      - name: \"Run tests\"\n        run: |\n          export WAYLAND_DISPLAY=wayland-0\n          node --run test\n"
  },
  {
    "path": ".github/workflows/dep-review.yaml",
    "content": "# This workflow scans your pull requests for dependency changes, and will raise an error if any vulnerabilities or invalid licenses are being introduced.\n# For more information see: https://github.com/actions/dependency-review-action\n\nname: \"Review Dependencies\"\n\non: [pull_request]\n\npermissions:\n  contents: read\n\njobs:\n  dependency-review:\n    runs-on: ubuntu-latest\n    steps:\n      - name: \"Checkout code\"\n        uses: actions/checkout@v6\n      - name: \"Dependency Review\"\n        uses: actions/dependency-review-action@v4\n"
  },
  {
    "path": ".github/workflows/electron-rebuild.yaml",
    "content": "name: \"Electron Rebuild Testing\"\n\non: [pull_request]\n\njobs:\n  rebuild:\n    name: Run electron-rebuild\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        node-version: [22.21.1, 22.x, 24.x]\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n      - name: \"Use Node.js ${{ matrix.node-version }}\"\n        uses: actions/setup-node@v6\n        with:\n          node-version: ${{ matrix.node-version }}\n          check-latest: true\n      - name: Install MagicMirror\n        run: node --run install-mm\n      - name: Install @electron/rebuild\n        run: npm install @electron/rebuild\n      - name: Install test library (serialport) to be rebuilt\n        run: npm install serialport\n      - name: Run electron-rebuild\n        run: npx electron-rebuild\n        continue-on-error: false\n"
  },
  {
    "path": ".github/workflows/enforce-pullrequest-rules.yaml",
    "content": "# This workflow enforces on every pull request that the PR is not based against master,\n# taken from https://github.com/oppia/oppia-android/blob/develop/.github/workflows/static_checks.yml\n\nname: \"Enforce Pull-Request Rules\"\n\non:\n  pull_request:\n  push:\n    branches-ignore:\n      - develop\n      - master\n\njobs:\n  check:\n    runs-on: ubuntu-latest\n    if: github.event_name == 'pull_request'\n    timeout-minutes: 10\n    steps:\n      - name: \"Branch is not based on develop\"\n        if: ${{ github.base_ref != 'develop' && !contains(github.event.pull_request.labels.*.name, 'mastermerge') }}\n        run: |\n          echo \"Current base branch: $BASE_BRANCH\"\n          echo \"Note: PRs should only ever be merged into develop so please rebase your branch on develop and try again.\"\n          exit 1\n        env:\n          BASE_BRANCH: ${{ github.base_ref }}\n"
  },
  {
    "path": ".github/workflows/release-notes.yaml",
    "content": "# This workflow writes a draft release on GitHub named `unreleased` after every push on develop\n\nname: \"Create Release Notes\"\n\non:\n  push:\n    branches: [develop]\n\npermissions:\n  contents: write\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: true\n\njobs:\n  release-notes:\n    runs-on: ubuntu-latest\n    timeout-minutes: 15\n    steps:\n      - name: \"Checkout code\"\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: \"0\"\n      - name: \"Use Node.js\"\n        uses: actions/setup-node@v6\n        with:\n          node-version: lts/*\n          cache: \"npm\"\n      - name: \"Create Markdown content\"\n        run: |\n          export GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}\n          node js/releasenotes.js\n"
  },
  {
    "path": ".github/workflows/spellcheck.yaml",
    "content": "# This workflow will run a spellcheck on the codebase.\n# It runs a few days before each release. At 00:00 on day-of-month 27 in March, June, September, and December.\n\nname: Run Spellcheck\n\non:\n  schedule:\n    - cron: \"0 0 27 3,6,9,12 *\"\n\npermissions:\n  contents: read\n\njobs:\n  spellcheck:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v6\n        with:\n          ref: develop\n      - name: Set up Node.js\n        uses: actions/setup-node@v6\n        with:\n          node-version: lts/*\n          check-latest: true\n          cache: \"npm\"\n      - name: Install dependencies\n        run: |\n          node --run install-mm:dev\n      - name: Run Spellcheck\n        run: node --run test:spelling\n"
  },
  {
    "path": ".github/workflows/stale.yaml",
    "content": "name: \"Close stale issues and PRs\"\n\non:\n  workflow_dispatch: # needed for manually running this workflow\n  schedule:\n    - cron: \"30 1 * * 6\" # every Saturday at 1:30\n\npermissions:\n  issues: write\n\njobs:\n  stale:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/stale@v10\n        with:\n          stale-issue-message: \"This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.\"\n          days-before-issue-stale: 60\n          days-before-issue-close: 7\n          operations-per-run: 100\n          stale-issue-label: \"wontfix\"\n          exempt-issue-labels: \"pinned,security,under investigation,pr welcome,ready (coming with next release)\"\n"
  },
  {
    "path": ".gitignore",
    "content": "# Various Node ignoramuses.\nlogs\n*.log\nnpm-debug.log*\npids\n*.pid\n*.seed\nlib-cov\ncoverage\n.lock-wscript\nbuild/Release\nnode_modules\njspm_modules\n.npm\n.node_repl_history\n\n# Visual Studio Code ignoramuses.\n.vscode/\n\n# IDE Code ignoramuses.\n.idea/\n\n# Various Windows ignoramuses.\nThumbs.db\nehthumbs.db\nDesktop.ini\n$RECYCLE.BIN/\n*.cab\n*.msi\n*.msm\n*.msp\n*.lnk\n\n# Various OSX ignoramuses.\n.DS_Store\n.AppleDouble\n.LSOverride\nIcon\n._*\n.DocumentRevisions-V100\n.fseventsd\n.Spotlight-V100\n.TemporaryItems\n.Trashes\n.VolumeIcon.icns\n.AppleDB\n.AppleDesktop\nNetwork Trash Folder\nTemporary Items\n.apdisk\n\n# Various Linux ignoramuses.\n.fuse_hidden*\n.directory\n.Trash-*\n\n# Ignore all modules except the default modules.\n/modules/*\n!/modules/default\n\n# Ignore changes to the custom css files but keep the sample and main.\n/css/*\n!/css/custom.css.sample\n!/css/font-awesome.css\n!/css/main.css\n!/css/roboto.css\n\n# Ignore users config file but keep the sample.\nconfig\n!config/config.js.sample\n\n# Vim\n## swap\n[._]*.s[a-w][a-z]\n[._]s[a-w][a-z]\n\n## diff patch\n*.orig\n*.rej\n*.bak\n\n# Ignore positions file (#3518)\njs/positions.js\n\n# Ignore lock files other than package-lock.json\npnpm-lock.yaml\nyarn.lock\n\n# Vitest temporary test files\ntests/**/.tmp/\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "#!/bin/sh\n\nif command -v npx &> /dev/null; then\n  npx lint-staged\nfi\n"
  },
  {
    "path": ".markdownlint.json",
    "content": "{\n\t\"line_length\": false,\n\t\"no-duplicate-heading\": false,\n\t\"no-inline-html\": false,\n\t\"no-trailing-punctuation\": false\n}\n"
  },
  {
    "path": ".npmrc",
    "content": "engine-strict=true\naudit=false\nloglevel=\"error\"\n"
  },
  {
    "path": ".prettierignore",
    "content": "*.js\n*.mjs\n.husky/pre-commit\n.prettierignore\n/config\n/coverage\npackage-lock.json\n**.ics\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n❤️ **Donate:** Enjoying MagicMirror²? [Please consider a donation!](https://magicmirror.builders/#donate) With your help we can continue to improve the MagicMirror².\n\n## Obsolete\n\nThis file is no longer being updated. Release notes are now automatically generated via a GitHub action.\n\n## [2.33.0] - 2025-10-01\n\nThanks to: @Crazylegstoo, @dathbe, @m-idler, @plebcity, @khassel, @KristjanESPERANTO, @rejas and @sdetweil!\n\n> ⚠️ This release needs nodejs version `v22.18.0 or higher`\n\n### Added\n\n- Add configuration option for `User-Agent`, used by calendar & news module (#3255)\n- [linter] Add prettier plugin for nunjuck templates (#3887)\n- [core] Add clear log for occupied port at startup (#3890)\n\n### Changed\n\n- [clock] Add CSS to prevent line breaking of sunset/sunrise time display (#3816)\n- [core] Enhance system information logging format and include additional env and RAM details (#3839, #3843)\n- [refactor] Add new file `js/module_functions.js` to move code used in several modules to one place (#3837)\n- [refactor] Use global.root_path where possible and add tests for config:check (#3883, #3885, #3886, #3889)\n- [tests] refactor: simplify jest config file (#3844)\n- [tests] refactor: extract constants for weather electron tests (#3845)\n- [tests] refactor: add `setupDOMEnvironment` helper function to eliminate repetitive JSDOM setup code (#3860)\n- [tests] replace `console` with `Log` in calendar `debug.js` to avoid exception in eslint config (#3846)\n- [tests] speed up e2e tests, cleanup and stabilize weather e2e tests, update snapshot url (#3847, #3848, #3861)\n- [tests] refactor translation tests (#3866)\n  - Remove `sinon` dependency in favor of Jest native mocking\n  - Unify test helper functions across translation test suites\n  - Rename `setupDOMEnvironment` to `createTranslationTestEnvironment` for consistency\n  - Simplify DOM setup by removing unnecessary Promise/async patterns\n  - Avoid potential port conflicts by using port 3001 for translator unit tests\n  - Improve test reliability and maintainability\n- [tests] add alert module tests for different welcome_message configurations (#3867)\n- [lint-staged] use `prettier --write --ignore-unknown` in `lint-staged` to avoid errors on unsupported files (#3888)\n\n### Updated\n\n- [calendar] Update defaultSymbol name and also the link to the icon search site (#3879)\n- [core] Update dependencies including electron to v38 as well as github actions (#3831, #3849, #3857, #3858, #3872, #3876, #3882, #3891, #3896)\n- [weather] Update feels_like temperature calculation formula (#3869)\n- [weather] Update null value handling for weather type (#3892)\n- [layout] Update styles for weather and calendar (#3894)\n\n### Fixed\n\n- [calendar] Fixed broken unittest that only broke on the 1st of July and 1st of january (#3830)\n- [clock] Fixed missing icons when no other modules with icons is loaded (#3834)\n- [weather] Fixed handling of empty values in weathergov providers handling of precipitationAmount (#3859)\n- [calendar] Fix regression handling of limit days (#3840)\n- [calendar] Fixed regression of calendarfetcherutils.shouldEventBeExcluded (#3841)\n- [core] Fixed socket.io timeout when server is slow to send notification, notification lost at client (#3380)\n- [tests] refactor AnimateCSS tests after jsdom 27 upgrade (#3891)\n- [weather] Use `apparent_temperature` data from openmeteo's hourly weather for current feelsLikeTemp (#3868).\n- [weather] Updated envcanada Provider to use new database/URL schema for accessing weather data (#3878).\n\n## [2.32.0] - 2025-07-01\n\nThanks to: @bughaver, @bugsounet, @khassel, @KristjanESPERANTO, @plebcity, @rejas, @sdetweil.\n\n> ⚠️ This release needs nodejs version `v22.14.0 or higher`\n\n### Added\n\n- [config] Allow to change module order for final renderer (or dynamically with CSS): Feature `order` in config (#3762)\n- [clock] Added option 'disableNextEvent' to hide next sun event (#3769)\n- [clock] Implement short syntax for clock week (#3775)\n\n### Changed\n\n- [refactor] Simplify module loading process (#3766)\n- Use `node --run` instead of `npm run` (#3764) and adapt `start:dev` script (#3773)\n- [workflow] Run linter and spellcheck with LTS node version (#3767)\n- [workflow] Split \"Run test\" step into two steps for more clarity (#3767)\n- [linter] Review linter setup (#3783)\n  - Fix command to lint markdown in `CONTRIBUTING.md`\n  - Re-activate JSDoc linting and fix linting issues\n  - Refactor ESLint config to use `defineConfig` and `globalIgnores`\n  - Replace `eslint-plugin-import` with `eslint-plugin-import-x`\n  - Switch Stylelint config to flat format and simplify Stylelint scripts\n- [workflow] Replace Node.js version v23 with v24 (#3770)\n- [refactor] Replace deprecated constants `fs.F_OK` and `fs.R_OK` (#3789)\n- [refactor] Replace `ansis` with built-in function `util.styleText` (#3793)\n- [core] Integrate stuff from `vendor` and `fonts` folders into main `package.json`, simplifies install and maintaining dependencies (#3795, #3805)\n- [l10n] Complete translations (with the help of translation tools) (#3794)\n- [refactor] Refactored `calendarfetcherutils` in Calendar module to handle timezones better (#3806)\n  - Removed as many of the date conversions as possible\n  - Use `moment-timezone` when calculating recurring events, this will fix problems from the past with offsets and DST not being handled properly\n  - Added some tests to test the behavior of the refactored methods to make sure the correct event dates are returned\n- [linter] Enable ESLint rule `no-console` and replace `console` with `Log` in some files (#3810)\n- [tests] Review and refactor translation tests (#3792)\n\n### Fixed\n\n- [fix] Handle spellcheck issues (#3783)\n- [calendar] fix fullday event rrule until with timezone offset (#3781)\n- [feat] Add rule `no-undef` in config file validation to fix #3785 (#3786)\n- [fonts] Fix `roboto.css` to avoid error message `Unknown descriptor 'var(' in @font-face rule.` in firefox console (#3787)\n- [tests] Fix and refactor e2e test `Same keys` in `translations_spec.js` (#3809)\n- [tests] Fix e2e tests newsfeed and calendar to exit without open handles (#3817)\n\n### Updated\n\n- [core] Update dependencies including electron to v36 (#3774, #3788, #3811, #3804, #3815, #3823)\n- [core] Update package type to `commonjs`\n- [logger] Review factory code part: use `switch/case` instead of `if/else if` (#3812)\n\n## [2.31.0] - 2025-04-01\n\nThanks to: @Developer-Incoming, @eltociear, @geraki, @khassel, @KristjanESPERANTO, @MagMar94, @mixasgr, @n8many, @OWL4C, @rejas, @savvadam, @sdetweil.\n\n> ⚠️ This release needs nodejs version `v22.14.0 or higher`\n\n### Added\n\n- Add CSS support to the digital clock hour/minute/second through the use of the classes `clock-hour-digital`, `clock-minute-digital`, and `clock-second-digital`.\n- Add Arabic (#3719) and Esperanto translation (#3740)\n- Mark option `secondsColor` as deprecated in clock module.\n- Add Greek translation to Alerts module.\n- [newsfeed] Add specific ignoreOlderThan value (override) per feed (#3360)\n- [weather] Added option Humidity to hourly View\n- [weather] Added option to hide hourly entries that are Zero, hiding the entire column if empty.\n- [updatenotification] Added option to iterate over modules directory instead using modules defined in `config.js` (#3739)\n\n### Changed\n\n- [core] Starting clientonly now checks for needed env var `WAYLAND_DISPLAY` or `DISPLAY` and starts electron with needed parameters (if both are set Wayland is used) (#3677)\n- [core] Optimize systeminformation calls and output (#3689)\n- [core] Add issue templates for feature requests and bug reports (#3695)\n- [core] Adapt `start:x11:dev` script\n- [weather/yr] The Yr weather provider now enforces a minimum `updateInterval` of 600 000 ms (10 minutes) to comply with the terms of service. If a lower value is set, it will be automatically increased to this minimum.\n- [weather/weatherflow] Fixed icons and added hourly support as well as UV, precipitation, and location name support.\n- [workflow] Run `sudo apt-get update` before installing packages to avoid install errors\n- [workflow] Exclude issues with label `ready (coming with next release)` from stale job\n\n### Removed\n\n### Updated\n\n- [core] Update requirements and dependencies including electron to v35 and formatting (#3593, #3693, #3717)\n- [core] Update prettier, ESLint and simplify config\n- Update Greek translation\n\n### Fixed\n\n- [calendar] Fix clipping events being broadcast (#3678)\n- [tests] Fix Electron tests by running them under new github image ubuntu-24.04, replace xserver with labwc, running under xserver and labwc depending on env variable WAYLAND_DISPLAY is set (#3676)\n- [calendar] Fix arrayed symbols, #3267, again, add testcase, add testcase for #3678\n- [weather] Fix wrong weatherCondition name in openmeteo provider which lead to n/a icon (#3691)\n- [core] Fix wrong port in log message when starting server only (#3696)\n- [calendar] Fix NewYork event processed on system in Central timezone shows wrong time #3701\n- [weather/yr] The Yr weather provider is now able to recover from bad API responses instead of freezing (#3296)\n- [compliments] Fix evening events being shown during the day (#3727)\n- [weather] Fixed minor spacing issues when using UV Index in Hourly\n- [workflow] Fix command to run spellcheck\n\n## [2.30.0] - 2025-01-01\n\nThanks to: @xsorifc28, @HeikoGr, @bugsounet, @khassel, @KristjanESPERANTO, @rejas, @sdetweil.\n\n> ⚠️ This release needs nodejs version `v20` or `v22 or higher`, minimum version is `v20.18.1`\n\n### Added\n\n- [core] Add Wayland and Windows start options to `package.json` (#3594)\n- [docs] Add step for npm publishing in release process (#3595)\n- [core] Add GitHub workflow to run spellcheck a few days before each release (#3623)\n- [core] Add test flag to `index.html` to pass to module js for test mode detection (needed by #3630)\n- [core] Add export on animation names (#3644)\n- [compliments] Add support for refreshing remote compliments file, and test cases (#3630)\n- [linter] Re-add `eslint-plugin-import`now that it supports ESLint v9 (#3586)\n- [linter] Re-activate `eslint-plugin-package-json` to lint `package.json` (#3643)\n- [linter] Add linting for markdown files (#3646)\n- [linter] Add some handy ESLint rules (#3665)\n- [calendar] Add ability to display end date for full date events, where end is not same day (showEnd=true) (#3650)\n- [core] Add text to the config.js.sample file about the locale variable (#3654, #3655)\n- [core] Add fetch timeout for all node_helpers (thru undici, forces node 20.18.1 minimum) to help on slower systems. (#3660) (3661)\n\n### Changed\n\n- [core] Run code style checks in workflow only once (#3648)\n- [core] Fix animations export #3644 only on server side (#3649)\n- [core] Use project URL in fallback config (#3656)\n- [core] Fix Access Denied crash writing js/positions.js (on synology nas) #3651. new message, MM starts, but no modules showing (#3652)\n- [linter] Switch to 'npx' for lint-staged in pre-commit hook (#3658)\n\n### Removed\n\n- [tests] Remove `node-pty` and `drivelist` from rebuilded test (#3575)\n- [deps] Remove `@eslint/js` dependency. Already installed with `eslint` in deep (#3636)\n\n### Updated\n\n- [repo] Reactivate `stale.yaml` as GitHub action to mark issues as stale after 60 days and close them 7 days later (if no activity) (#3577, #3580, #3581)\n- [core] Update electron dependency to v32 (test electron rebuild) and all other dependencies too (#3657)\n- [tests] All test configs have been updated to allow full external access, allowing for easier debugging (especially when running as a container)\n- [core] Run and test with node 23 (#3588)\n- [workflow] delete exception `allow-ghsas: GHSA-8hc4-vh64-cxmj` in `dep-review.yaml` (#3659)\n\n### Fixed\n\n- [updatenotification] Fix pm2 using detection when pm2 script is inside or outside MagicMirror root folder (#3576) (#3605) (#3626) (#3628)\n- [core] Fix loading node_helper of modules: avoid black screen, display errors and continue loading with next module (#3578)\n- [weather] Change default value for weatherEndpoint of provider openweathermap to \"/onecall\" (#3574)\n- [tests] Fix electron tests with mock dates, the mock on server side was missing (#3597)\n- [tests] Fix testcases with hard coded Date.now (#3597)\n- [core] Fix missing `basePath` where `location.host` is used (#3613)\n- [compliments] croner library changed filenames used in latest version (#3624)\n- [linter] Fix ESLint ignore pattern which caused that default modules not to be linted (#3632)\n- [core] Fix module path in case of sub/sub folder is used and use path.resolve for resolve `moduleFolder` and `defaultModuleFolder` in app.js (#3653)\n- [calendar] Update to resolve issues #3098 #3144 #3351 #3422 #3443 #3467 #3537 related to timezone changes\n- [calendar] Fix #3267 (styles array), also fixes event with both exdate AND recurrence(and testcase)\n- [calendar] Fix showEndsOnlyWithDuration not working, #3598, applies ONLY to full day events\n- [calendar] Fix showEnd for Full Day events (#3602)\n- [tests] Suppress \"module is not defined\" in e2e tests (#3647)\n- [calendar] Fix #3267 (styles array, really this time!)\n- [core] Fix #3662 js/positions.js created incorrectly\n\n## [2.29.0] - 2024-10-01\n\nThanks to: @bugsounet, @dkallen78, @jargordon, @khassel, @KristjanESPERANTO, @MarcLandis, @rejas, @ryan-d-williams, @sdetweil, @skpanagiotis.\n\n> ⚠️ This release needs nodejs version `v20` or `v22`, minimum version is `v20.9.0`\n\n### Added\n\n- [compliments] Added support for cron type date/time format entries mm hh DD MM dow (minutes/hours/days/months and day of week) see <https://crontab.cronhub.io> for construction (#3481)\n- [core] Check config at every start of MagicMirror² (#3450)\n- [core] Add spelling check (cspell): `npm run test:spelling` and handle spelling issues (#3544)\n- [core] removed `config.paths.vendor` (could not work because `vendor` is hardcoded in `index.html`), renamed `config.paths.modules` to `config.foreignModulesDir`, added variable `MM_CUSTOMCSS_FILE` which - if set - overrides `config.customCss`, added variable `MM_MODULES_DIR` which - if set - overrides `config.foreignModulesDir`, added test for `MM_MODULES_DIR` (#3530)\n- [core] elements are now removed from `index.html` when loading script or stylesheet files fails\n- [core] Added `MODULE_DOM_UPDATED` notification each time the DOM is re-rendered via `updateDom` (#3534)\n- [tests] added minimal needed node version to tests (currently v20.9.0) to avoid releases with wrong node version info\n- [tests] Added `node-libgpiod` library to electron-rebuild tests (#3563)\n\n### Removed\n\n- [core] removed installer only files (#3492)\n- [core] removed raspberry object from systeminformation (#3505)\n- [linter] removed `eslint-plugin-import`, because it doesn't support ESLint v9. We will reenter it later when it does.\n- [tests] removed `onoff` library from electron-rebuild tests (#3563)\n\n### Updated\n\n- [weather] Updated `apiVersion` default from 2.5 to 3.0 (#3424)\n- [core] Updated dependencies including stylistic-eslint\n- [core] nail down `node-ical` version to `0.18.0` with exception `allow-ghsas: GHSA-8hc4-vh64-cxmj` in `dep-review.yaml` (which should removed after next `node-ical` update)\n- [core] Updated SocketIO catch all to new API\n- [core] Allow custom modules positions by scanning index.html for the defined regions, instead of hard coded (PR #3518 fixes issue #3504)\n- [core] Detail optimizations in `config_check.js`\n- [core] Updated minimal needed node version in `package.json` (currently v20.9.0) (#3559) and except for v21 (no security updates) (#3561)\n- [linter] Switch to ESLint v9 and flat config and replace `eslint-plugin-unicorn` by `@eslint/js`\n- [core] Fix discovering module positions twice after #3450\n\n### Fixed\n\n- [docs] Fixed `checks` badge in README.md\n- [weather] Fixed issue with the UK Met Office provider following a change in their API paths and header info.\n- [core] Add check for node_helper loading for multiple instances of same module (#3502)\n- [weather] Fixed issue for respecting unit config on broadcasted notifications\n- [tests] Fixes calendar test by moving it from e2e to electron with fixed date (#3532)\n- [calendar] fixed sliceMultiDayEvents getting wrong count and displaying incorrect entries, Europe/Berlin (#3542)\n- [tests] ignore `js/positions.js` when linting (this file is created at runtime)\n- [calendar] fixed sliceMultiDayEvents showing previous day without config enabled\n\n## [2.28.0] - 2024-07-01\n\nThanks to: @btoconnor, @bugsounet, @JasonStieber, @khassel, @kleinmantara and @WallysWellies.\n\n> ⚠️ This release needs nodejs version >= v20.9.0\n\n### Added\n\n- [calendar] Added config option \"showEndsOnlyWithDuration\" for default calendar\n- [compliments] Added `specialDayUnique` config option, defaults to `false` (#3465)\n- [weather] Provider weathergov: Use `precipitationLast3Hours` if `precipitationLastHour` is `null` (#3124)\n\n### Removed\n\n- [tests] delete node v18 support (#3462)\n\n### Updated\n\n- [core] Update dependencies including electron to v31\n- [core] use node >= v20 (#3462)\n- [core] Update `config.js.sample` to use openmeteo as weather provider which needs no api key\n- [tests] Use latest@version of node for `automated-tests.yaml` (#3483)\n- [updatenotification] Avoid using pm2 when running in docker container\n\n### Fixed\n\n- [core] Fixed crash possibility if `module: <name>` is not defined and on `position: <position>` mistake (#3445)\n- [weather] Fixed precipitationProbability in forecast for provider openmeteo (#3446)\n- [weather] Fixed type=daily for provider openmeteo having no data when running after 23:00 (#3449)\n- [weather] Fixed type=daily for provider openmeteo showing nightly icons in forecast when current time is \"nightly\" (#3458)\n- [weather] Fixed forecast and hourly weather for provider openmeteo to use real temperatures, not apparent temperatures (#3466)\n- [tests] Fixed e2e tests running in docker container which needs `address: \"0.0.0.0\"` (#3479)\n\n## [2.27.0] - 2024-04-01\n\nThanks to: @bugsounet, @crazyscot, @illimarkangur, @jkriegshauser, @khassel, @KristjanESPERANTO, @Paranoid93, @rejas, @sdetweil and @vppencilsharpener.\n\nThis release marks the first release without Michael Teeuw (@michmich). A very special thanks to him for creating MagicMirror and leading the project for so many years.\n\nFor more info, please read the following post: [A New Chapter for MagicMirror: The Community Takes the Lead](https://forum.magicmirror.builders/topic/18329/a-new-chapter-for-magicmirror-the-community-takes-the-lead).\n\n### Added\n\n- Output of system information to the console for troubleshooting (#3328 and #3337), ignore errors under aarch64 (#3349)\n- [linter] Add `eslint-plugin-package-json` to lint the `package.json` files (#3368)\n- [weather] `showHumidity` config is now a string describing where to show this element. Supported values: \"wind\", \"temp\", \"feelslike\", \"below\", \"none\". (#3330)\n- electron-rebuild test suite for electron and 3rd party modules compatibility (#3392)\n- Create MM² icon and attach it to electron process (#3407)\n\n### Updated\n\n- [updatenotification] Recode update_helper.js with pm2 library (#3332)\n- Removing lodash dependency by replacing merge by spread operator (#3339)\n- Use node prefix for build-in modules (#3340)\n- Rework logging colors (#3350)\n- Update pm2 to v5.3.1 with no allow-ghsas (#3364)\n- [core] Update husky and let lint-staged fix ESLint issues\n- [core] Update dependencies including electron to v29 (#3357) and node-ical\n- Update translations for estonian (#3371)\n- Update electron to v29 and update other dependencies\n- [calendar] fullDay events over several days now show the left days from the first day on and 'today' on the last day\n- [weather] Update layout of current weather indoor values\n\n### Fixed\n\n- [weather] Correct apiBase of weathergov weatherProvider to match documentation (#2926)\n- Worked around several issues in the RRULE library that were causing deleted calendar events to still show, some\n  initial and recurring events to not show, and some event times to be off an hour. (#3291)\n- Skip changelog requirement when running tests for dependency updates (#3320)\n- Display precipitation probability when it is 0% instead of blank/empty (#3345)\n- [newsfeed] Suppress unsightly animation cases when there are 0 or 1 active news items (#3336)\n- [newsfeed] Always compute the feed item URL using the same helper function (#3336)\n- Ignore all custom css files (#3359)\n- [newsfeed] Fix newsfeed stall issue introduced by #3336 (#3361)\n- Changed `log.debug` to `log.log` in `app.js` where logLevel is not set because config is not loaded at this time (#3353)\n- [calendar] deny fetch interval < 60000 and set 60000 in this case (prevent fetch loop failed) (#3382)\n- added message in case where config.js is missing the module.export line PR #3383\n- Fixed an issue where recurring events could extend past their recurrence end date (#3393)\n- Don't display any `npm WARN <....>` on install (#3399)\n- [core] Moved suncalc dependency to production from dev, as it is used by clock module\n- [compliments] Fix mirror not responding anymore when no compliments are to be shown (#3385)\n- [core] Fixed mastermerge workflow (#3415)\n\n### Deleted\n\n- Unneeded file headers (#3358)\n- Removed codecov.yaml\n\n## [2.26.0] - 2024-01-01\n\nThanks to: @bnitkin, @bugsounet, @dependabot, @jkriegshauser, @kaennchenstruggle, @KristjanESPERANTO and @Ybbet.\n\nSpecial thanks to @khassel, @rejas and @sdetweil for taking over most (if not all) of the work on this release as project collaborators. This version would not be there without their effort. Thank you guys! You are awesome!\n\nThis release also marks the latest release by Michael Teeuw. For more info, please read the following post: [A New Chapter for MagicMirror: The Community Takes the Lead](https://forum.magicmirror.builders/topic/18329/a-new-chapter-for-magicmirror-the-community-takes-the-lead).\n\n### Added\n\n- Added update notification updater (for 3rd party modules)\n- Added node 21 to the test matrix\n- Added transform object to calendar:customEvents\n- Added ESLint rules for jest (including jest/expect-expect and jest/no-done-callback)\n\n### Removed\n\n- Removed Codecov workflow (not working anymore, other workflow required) (#3107)\n- Removed titleReplace from calendar, replaced + extended by customEvents (backward compatibility included) (#3249)\n- Removed failing unit test (#3254)\n- Removed some unused variables\n\n### Updated\n\n- Update electron to v27 and update other dependencies as well as github actions\n- Update newsfeed: Use `html-to-text` instead of regex for transform description\n- Review ESLint config (#3269)\n- Update dependencies\n- Clock module: optionally display current moon phase in addition to rise/set times\n- electron is now per default started without gpu, if needed it must be enabled with new env var `ELECTRON_ENABLE_GPU=1` on startup (#3226)\n- Replace prettier by stylistic in ESLint config to lint JavaScript (and disable some rules for `config/config.js*` files)\n- Update node-ical to v0.17.1 and fix tests\n\n### Fixed\n\n- Avoid fade out/in on updateDom when many calendars are used\n- Fix the option eventClass on customEvents.\n- Fix yr API version in locationforecast and sunrise call (#3227)\n- Fix cloneObject() function to respect RegExp (#3237)\n- Fix newsfeed module for feeds using \"a10:updated\" tag (#3238)\n- Fix issue template (#3167)\n- Fix #3256 filter out bad results from rrule.between\n- Fix calendar events sometimes not respecting deleted events (#3250)\n- Fix electron loadURL locally on Windows when address \"0.0.0.0\" (#2550)\n- Fix updatenotification (update_helper.js): catch error if response is not an JSON format (check PM2)\n- Fix missing typeof in calendar module\n- Fix style issues after prettier update\n- Fix calendar test (#3291) by moving \"Exdate check\" from e2e to electron to run on a Thursday\n- Fix calendar config params `fetchInterval` and `excludedEvents` were never used from single calendar config (#3297)\n- Fix MM_PORT variable not used in electron and allow full path for MM_CONFIG_FILE variable (#3302)\n\n## [2.25.0] - 2023-10-01\n\nThanks to: @bugsounet, @dgoth, @dependabot, @kenzal, @Knapoc, @KristjanESPERANTO, @martingron, @NolanKingdon, @Paranoid93, @TeddyStarinvest and @Ybbet.\n\nSpecial thanks to @khassel, @rejas and @sdetweil for taking over most (if not all) of the work on this release as project collaborators. This version would not be there without their effort. Thank you guys! You are awesome!\n\n> ⚠️ This release needs nodejs version >= `v18`, older releases have reached end of life and will not work!\n\n### Added\n\n- Added UV Index support to OpenWeatherMap\n- Added 'hideDuplicates' flag to the calendar module\n- Added `allowOverrideNotification` to weather module to enable sending current weather objects with the `CURRENT_WEATHER_OVERRIDE` notification to supplement/replace the current weather displayed\n- Added optional AnimateCSS animate for `hide()`, `show()`, `updateDom()`\n- Added AnimateIn and animateOut in module config definition\n- Apply AnimateIn rules on the first start\n- Added automatic client page reload when server was restarted by setting `reloadAfterServerRestart: true` in `config.js`, per default `false` (#3105)\n- Added eventClass option for customEvents on the default calendar\n- Added AnimateCSS integration in tests suite (#3206)\n- Added npm dependabot [Reserved to developer] (#3210)\n- Added improved logging for calendar (#3110)\n\n### Removed\n\n- **Breaking Change**: Removed `digest` authentication method from calendar module (which was already broken since release `2.15.0`)\n\n### Updated\n\n- Update roboto fonts to version v5\n- Update issue template\n- Update dev/dependencies incl. electron to v26\n- Replace pretty-quick by lint-staged (<https://github.com/azz/pretty-quick/issues/164>)\n- Update engine node >=18. v16 reached its end of life. (#3170)\n- Update typescript definition for modules\n- Cleaned up nunjuck templates\n- Replace `node-fetch` with internal fetch (#2649) and remove `digest-fetch`\n- Update the French translation according to the English file.\n- Update dependabot incl. vendor/fonts (monthly check)\n- Renew `package-lock.json` for release\n\n### Fixed\n\n- Fix engine check on npm install (#3135)\n- Fix undefined formatTime method in clock module (#3143)\n- Fix clientonly startup fails after async added (#3151)\n- Fix electron width/height when using xrandr under bullseye\n- Fix time issue with certain recurring events in calendar module\n- Fix ipWhiteList test (#3179)\n- Fix newsfeed: Convert HTML entities, codes and tag in description (#3191)\n- Respect width/height (no fullscreen) if set in electronOptions (together with `fullscreen: false`) in `config.js` (#3174)\n- Fix: AnimateCSS merge hide() and show() animated css class when we do multiple call\n- Fix `Uncaught SyntaxError: Identifier 'getCorsUrl' has already been declared (at utils.js:1:1)` when using `clock` and `weather` module (#3204)\n- Fix overriding `config.js` when running tests (#3201)\n- Fix issue in weathergov provider with probability of precipitation not showing up on hourly or daily forecast\n- Fix yr weather provider after changes in yr API (#3189)\n\n## [2.24.0] - 2023-07-01\n\nThanks to: @angeldeejay, @bugsounet, @buxxi, @CarJem, @dariom, @DaveChild, @dWoolridge, @eddiehung, @grenagit, @Hirschberger, @ismarslomic, @JakeBinney, @KristjanESPERANTO, @MagMar94, @naveensrinivasan, @nfogal, @oscarb, @OWL4C, @psieg, @rajniszp, @retroflex, @SkySails and @tomzt\n\nSpecial thanks to @khassel, @rejas and @sdetweil for taking over most (if not all) of the work on this release as project collaborators. This version would not be there without their effort. Thank you guys! You are awesome!\n\n### Added\n\n- Added UV Index to hourly and current Weather, with support for Openmeteo\n- Added tests for serveronly\n- Set Timezone `Europe/Berlin` in unit tests (needed for new formatTime tests)\n- [linter] Added no-param-reassign eslint rule and fix warnings\n- [updatenotification] Added `sendUpdatesNotifications` feature. Broadcast update with `UPDATES` notification to other modules\n- [updatenotification] Allow force scanning with `SCAN_UPDATES` notification from other modules\n- Added per-calendar fetchInterval\n\n### Removed\n\n- Removed unneeded (and unwanted) '.' after the year in calendar repeatingCountTitle (#2896, second attempt ...)\n\n### Updated\n\n- [weather] Added support for precipitation probability with openmeteo weather-provider\n- Update electron to v25.2 and other dependencies\n- Use node v20 in github workflow (replacing v14)\n- Refactor formatTime into common util function for default modules\n- Refactor some calendar methods into own class and added tests for them\n- Split install and run commands in github actions\n- Changed `fetchInterval` of calendar in `config.js.sample` to 7 days so we not to request example calendar too frequently\n- Changed default calendar fetchInterval to one hour\n- Changed calendar url in sample config\n\n### Fixed\n\n- Fix envcanada hourly forecast time (#3080)\n- Fix electron not running under windows after async changes (#3083)\n- Fix style issues after eslint-plugin-jsdoc update\n- Fix don't filter out ongoing full day events (#3095)\n- Fix date not shown when clock in analog mode (#3100)\n- Fix envcanada today percentage-of-precipitation (#3106)\n- Fix updatenotification where no branch is checked out but e.g. a version tag (#3130)\n\n## [2.23.0] - 2023-04-04\n\nThanks to: @angeldeejay, @buxxi, @CarJem, @dariom, @DaveChild, @dWoolridge, @grenagit, @Hirschberger, @KristjanESPERANTO, @MagMar94, @naveensrinivasan, @nfogal, @psieg, @rajniszp, @retroflex, @SkySails and @tomzt.\n\nSpecial thanks to @khassel, @rejas and @sdetweil for taking over most (if not all) of the work on this release as project collaborators. This version would not be there without their effort. Thank you guys! You are awesome!\n\n### Added\n\n- Added increments for hourly forecasts in weather module (#2996)\n- Added tests for hourly weather forecast\n- Added possibility to ignore MagicMirror repo in updatenotification module\n- Added Pirate Weather as new weather-provider (#3005)\n- Added possibility to use your own templates in Alert module\n- Added error message if `<modulename>.js` file is missing in module folder to get a hint in the logs (#2403)\n- Added possibility to use environment variables in `config.js` (#1756)\n- Added option `pastDaysCount` to default calendar module to control of how many days past events should be displayed\n- Added thai language to alert module\n- Added option `sendNotifications` in clock module (#3056)\n- Added tests for some weather utils\n\n### Removed\n\n- Removed darksky weather-provider\n- Removed unneeded (and unwanted) '.' after the year in calendar repeatingCountTitle (#2896)\n\n### Updated\n\n- Use develop as target branch for dependabot\n- Update issue template, contributing doc and sample config\n- The weather modules clearly separates precipitation amount and probability (risk of rain/snow)\n  - This requires all providers that only supports probability to change the config from `showPrecipitationAmount` to `showPrecipitationProbability`.\n- Update tests for weather and calendar module\n- Changed updatenotification module for MagicMirror repo only: Send only notifications for `master` if there is a tag on a newer commit\n- Update dates in Calendar widgets every minute\n- Cleanup jest coverage for patches\n- Update `stylelint` dependencies, switch to `stylelint-config-standard` and handle `stylelint` issues, update `main.css` matching new rules\n- Update Eslint config, add new rule and handle issue\n- Convert lots of callbacks to async/await\n- Revise require imports (#3071 and #3072)\n- Use `config.js-old` instead of file with timestamp suffix when backing up config with a `config.template` in use (#3104)\n\n### Fixed\n\n- Fix wrong day labels in envcanada forecast (#2987)\n- Fix for missing default class name prefix for customEvents in calendar\n- Fix electron flashing white screen on startup (#1919)\n- Fix weathergov provider hourly forecast (#3008)\n- Fix message display with HTML code into alert module (#2828)\n- Fix typo in french translation\n- Yr wind direction is no longer inverted\n- Fix async node_helper stopping electron start (#2487)\n- The wind direction arrow now points in the direction the wind is flowing, not into the wind (#3019)\n- Fix precipitation css styles and rounding value\n- Fix wrong vertical alignment of calendar title column when wrapEvents is true (#3053)\n- Fix empty news feed stopping the reload forever\n- Fix e2e tests (failed after async changes) by running calendar and newsfeed tests last\n- Lint: Use template literals instead of string concatenation\n- Fix default alert module to render HTML for title and message\n- Fix Open-Meteo wind speed units\n\n## [2.22.0] - 2023-01-01\n\nThanks to: @angeldeejay, @buxxi, @dariom, @dWoolridge, @KristjanESPERANTO, @MagMar94, @naveensrinivasan, @retroflex, @SkySails and @Tom.\n\nSpecial thanks to @khassel, @rejas and @sdetweil for taking over most (if not all) of the work on this release as project collaborators. This version would not be there without their effort. Thank you!\n\n### Added\n\n- Added new calendar options for colored entries and improved styling (#3033)\n- Added test for remoteFile option in compliments module\n- Added hourlyWeather functionality to Weather.gov weather-provider\n- Added css class names \"today\" and \"tomorrow\" for default calendar\n- Added Collaboration.md\n- Added new github action for dependency review (#2862)\n- Added a WeatherProvider for Open-Meteo\n- Added Yr as a weather-provider\n- Added config options \"ignoreXOriginHeader\" and \"ignoreContentSecurityPolicy\"\n- Added thai language\n- Added workflow rule to make sure PRs are based against develop\n\n### Removed\n\n- Removed usage of internal fetch function of node until it is more stable\n- Removed weatherEndpoint definition from weathergov.js (not used)\n\n### Updated\n\n- Cleaned up test directory (#2937) and jest config (#2959)\n- Wait for all modules to start before declaring the system ready (#2487)\n- Updated e2e tests (moved `done()` in helper functions) and use es6 syntax in all tests\n- Updated da translation\n- Rework weather module\n  - Make sure smhi provider api only gets a maximum of 6 digits coordinates (#2955)\n  - Use fetch instead of XMLHttpRequest in weather-provider (#2935)\n  - Reworked how weather-providers handle units (#2849)\n  - Use unix() method for parsing times, fix suntimes on the way (#2950)\n  - Refactor conversion functions into utils class (#2958)\n- The `cors`-method in `server.js` now supports sending and receiving HTTP headers\n- Replace `&hellip;` by `…`\n- Cleanup compliments module\n- Updated dependencies including electron to v22 (#2903)\n\n### Fixed\n\n- Correctly show apparent temperature in SMHI weather-provider\n- Ensure updatenotification module isn't shown when local is _ahead_ of remote\n- Handle node_helper errors during startup (#2944)\n- Possibility to change FontAwesome class in calendar, so icons like `fab fa-facebook-square` works.\n- Fix cors problems with newsfeed articles (as far as possible), allow disabling cors per feed with option `useCorsProxy: false` (#2840)\n- Tests not waiting for the application to start and stop before starting the next test\n- Fix electron tests failing sometimes in github workflow\n- Fixed gap in clock module when displayed on the left side with displayType=digital\n- Fixed playwright issue by upgrading to v1.29.1 (#2969)\n\n## [2.21.0] - 2022-10-01\n\nSpecial thanks to: @BKeyport, @buxxi, @davide125, @khassel, @kolbyjack, @krukle, @MikeBishop, @rejas, @sdetweil, @SkySails and @veeck\n\n### Added\n\n- Added possibility to fetch calendars through socket notifications.\n- New scripts `install-mm` (and `install-mm:dev`) for simplifying mm installation (now: `npm run install-mm`) and adding params `--no-audit --no-fund --no-update-notifier` for less noise.\n- New `showTimeToday` option in calendar module shows time for current-day events even if `timeFormat` is `\"relative\"`.\n- Added hourly forecasts, apparent temperature & custom location name to SMHI weather-provider.\n- Added new electron tests for calendar and moved some compliments tests from `e2e` to `electron` because of date mocking, removed mock stuff from compliments module.\n\n### Removed\n\n- Removed old and deprecated weather modules `currentweather` and `weatherforecast`.\n- Removed `DAYAFTERTOMORROW` from English.\n\n### Updated\n\n- Updated dependencies.\n- Updated jsdoc.\n- Updated font tree to use variables consistently.\n- Removed deprecated Docker Repository from issue template.\n\n### Fixed\n\n- Broadcast all calendar events while still honoring global and per-calendar maximumEntries.\n- Respect rss ttl provided by newsfeed (#2883).\n- Fix multi day calendar events always presented as \"(1/X)\" instead of the amount of days the event has progressed.\n- Fix weatherbit provider to use type config value instead of endpoint.\n- Fix calendar events which DO NOT specify rrule byday adjusted incorrectly (#2885).\n- Fix e2e tests not failing on errors (#2911).\n\n## [2.20.0] - 2022-07-02\n\nSpecial thanks to the following contributors: @eouia, @khassel, @kolbyjack, @KristjanESPERANTO, @nathannaveen, @naveensrinivasan, @rejas, @rohitdharavath and @sdetweil.\n\n### Added\n\n- Added a new config option `httpHeaders` used by helmet (see <https://helmetjs.github.io/>). You can now set own httpHeaders which will override the defaults in `js/defaults.js` which is useful e.g. if you want to embed MagicMirror into another website (solves #2847).\n- Show endDate for calendar events when dateHeader is enabled and showEnd is set to true (#2192).\n- Added the notification emitting from the weather module on information updated.\n- Use recommended file extension for YAML files (#2864).\n\n### Updated\n\n- Use latest node 18 when running tests on github actions.\n- Updated `electron` to v19 and other dependencies.\n- Use internal fetch function of node instead external `node-fetch` library if used node version >= `v18`.\n- Include duplicate events in broadcasts.\n\n### Fixed\n\n- Fix problems with non latin fonds caused by updating to fontsource (fixes #2835).\n\n## [2.19.0] - 2022-04-01\n\nSpecial thanks to the following contributors: @10bias, @CFenner, @JHWelch, @k1rd3rf, @khassel, @kolbyjack, @krekos, @KristjanESPERANTO, @Nerfzooka, @oraclesean, @oscarb, @philnagel, @rejas, @sdetweil, @shin10, @SiderealArt and @Tom-Hirschberger.\n\n### Added\n\n- Added a config option under the weather module, `absoluteDates`, providing an option to format weather forecast date output with either absolute or relative dates.\n- Added test for new weather forecast `absoluteDates` property.\n- The modules get a class hidden added/removed if they get hidden/shown which will also toggle pointer-events.\n- Added new config option `showTitleAsUrl` to newsfeed module. If set, the displayed title is a link to the article which is useful when running in a browser and you want to read this article.\n- Added internal cors proxy to get weather-providers working without public proxies (fixes #2714). The new url `http(s)://address:port/cors?url=https://whatever-to-proxy` can be used in other modules too.\n- Added a WeatherProvider for Weatherflow.\n- Added new env var `ELECTRON_DISABLE_GPU` which disable gpu under electron if set (fixes #2831).\n- Added missing Czech translations.\n\n### Updated\n\n- Deprecated roboto fonts package `roboto-fontface-bower` replaced with `fontsource`.\n- Updated `electron` to v17, `helmet` to v5 (use defaults of v4) and other dependencies\n- Updated Font Awesome css class to new default style (fixes #2768)\n- Replaced deprecated modules `currentweather` and `weatherforecast` with dummy modules only displaying that they have to be replaced.\n- Include all calendar events from the configured date range when broadcasting.\n- Updated Danish and German translation.\n- Updated `node-ical` to v0.15 and added `luxon` as dependency for not breaking the \"no-optional\" install (see #2718 and #2824).\n\n### Fixed\n\n- Improved and speedup e2e tests, artificial wait after mm start removed.\n- Improved husky setup not blocking `git commit` if `husky` or `npm` is not installed.\n- Using a consistent spelling of MagicMirror².\n- Fix minor console output issue for loading translations (#2814).\n- Don't adjust startDate for full day events if endDate is in the past.\n- Fix windspeed conversion error in openweathermap provider. (#2812)\n- Fix conflicting parameter turning off showEnd for full day events. (#2629)\n- Fix regression, calendar.maximumEntries not used to filter calendar level entries (#2868)\n\n## [2.18.0] - 2022-01-01\n\nSpecial thanks to the following contributors: @AmpioRosso, @eouia, @fewieden, @jupadin, @khassel, @kolbyjack, @KristjanESPERANTO, @MariusVaice, @rejas, @rico24 and @sdetweil.\n\n### Added\n\n- Added test for calendar recurring event with checks the correct date displayed (related to #2752).\n\n### Updated\n\n- ESLint version supports now ECMAScript 2018.\n- Cleaned up `updatenotification` module and switched to nunjuck template.\n- Moved calendar tests from category `electron` to `e2e`.\n- Updated missed translations for Korean language (ko.json).\n- Updated missed translations for Dutch language (nl.json).\n- Cleaned up `alert` module and switched to nunjuck template.\n- Moved weather tests from category `electron` to `e2e`.\n- Updated github actions.\n- Replace spectron with playwright, update dependencies including electron update to v16.\n- Added lithuanian language to translations.js.\n- Show info message if newsfeed is empty (fixes #2731).\n- Added dangerouslyDisableAutoEscaping config option for newsfeed templates (fixes #2712).\n- Added missing shebang to `installers/mm.sh`.\n- Node versions in templates and github workflows.\n- Updated translations for Traditional Chinese (Taiwan) (zh-tw.json).\n\n### Fixed\n\n- Fixed wrong file `kr.json` to `ko.json`. Use language code 'ko' instead of 'kr' for Korean language.\n- [weather] Fixed `feels_like` data from openweathermap's current weather being ignored (#2678).\n- Fixed chaotic newsfeed display after network connection loss thanks to @jalibu (#2638).\n- Fixed incorrect time zone correction of recurring full day events (#2632 and #2634).\n- Fixed e2e tests by increasing testTimeout.\n- Revert node-ical update due to missing luxon package.\n- Fixed User-Agent-Header for newsfeed and calendar module (#2729).\n- Replace broken shields in Readme and use https for links.\n- Fixed electron tests with retry.\n- Fixed Calendar recurring cross timezone error (add/subtract a day, not just offset hours) (#2632).\n- Fixed Calendar showEnd and Full Date overlay (#2629).\n- Fixed regression on #2632, #2752.\n- Broadcast custom symbols in CALENDAR_EVENTS.\n\n## [2.17.1] - 2021-10-01\n\n### Fixed\n\n- Fixed error when accessing letsencrypt certificates\n- Fixed Calendar module enhancement: displaying full events without time (#2424)\n\n## [2.17.0] - 2021-10-01\n\nSpecial thanks to the following contributors: @apiontek, @eouia, @jupadin, @khassel and @rejas.\n\n### Added\n\n- Added showTime parameter to clock module for enabling/disabling time display in analog clock.\n- Added custom electron switches from user config (`config.electronSwitches`).\n- Added unit tests for updatenotification module.\n\n### Updated\n\n- Bump electron to v13 (and spectron to v15) and update other dependencies in package.json.\n- Refactor test configs, use default test config for all tests.\n- Updated github templates.\n- Actually test all js and css files when lint script is run.\n- Updated jsdocs and print warnings during testing too.\n- Updated weathergov provider to try fetching not just current, but also forecast, when API URLs available.\n- [clock] Refactored clock layout.\n- Refactored methods from weather-providers into weatherobject (isDaytime, updateSunTime).\n- Use of `logger.js` in jest tests.\n- Run prettier over all relevant files.\n- Move tests needing electron in new category `electron`, use `server only` mode in `e2e` tests.\n- Updated dependencies in package.json.\n\n### Fixed\n\n- Fix undefined error with ignoreToday option in weather module (#2620).\n- Fix time zone correction in calendar module when the date hour is equal to the time zone correction value (#2632).\n- Fix black cursor on startup when using electron.\n- Fix update notification not working for own repository (#2644).\n\n## [2.16.0] - 2021-07-01\n\nSpecial thanks to the following contributors: @210954, @B1gG, @codac, @Crazylegstoo, @daniel, @earlman, @ezeholz, @FrancoisRmn, @jupadin, @khassel, @KristjanESPERANTO, @njwilliams, @oemel09, @r3wald, @rejas, @rico24, Faizan Ahmed.\n\n### Added\n\n- Added French translations for \"MODULE_CONFIG_ERROR\" and \"PRECIP\".\n- Added German translation for \"PRECIP\".\n- Added Dutch translation for \"WEEK\", \"PRECIP\", \"MODULE_CONFIG_CHANGED\" and \"MODULE_CONFIG_ERROR\".\n- Added first test for Alert module.\n- Added support for `dateFormat` when not using `timeFormat: \"absolute\"`.\n- Added custom-properties for colors and fonts for improved styling experience, see `custom.css.sample` file.\n- Added custom-properties for gaps around body and between modules.\n- Added test case for recurring calendar events.\n- Added new Environment Canada provider for default WEATHER module (weather data for Canadian locations only).\n- Added list view for newsfeed module.\n- Added dev dependency jest, switching from mocha to jest.\n\n### Updated\n\n- Bump node-ical to v0.13.0 (now last runtime dependency using deprecated `request` package is removed).\n- Use codecov in informational mode.\n- Refactor code into es6 where possible (e.g. var -> let/const).\n- Use node v16 in github workflow (replacing node v10).\n- Moved some files into better suited directories.\n- Updated dependencies in package.json, require node >= v12, remove `rrule-alt` and `rrule`.\n- Updated dependencies in package.json and migrate husky to v6, fix husky setup in prod environment.\n- Cleaned up error handling in newsfeed and calendar modules for real.\n- Updated default WEATHER module such that a provider can optionally set a custom unit-of-measure for precipitation (`weatherObject.precipitationUnits`).\n- Updated documentation.\n- Updated jest tests: Reset changes on js/logger.js, mock logger.js in global_vars tests.\n- Updated dependencies in package.json.\n\n### Removed\n\n- Switching from mocha to jest so removed following dev dependencies: chai, chai-as-promised, mocha, mocha-each, mocha-logger.\n\n### Fixed\n\n- Fix calendar start function logging inconsistency.\n- Fix updatenotification start function logging inconsistency.\n- Checks and applies the showDescription setting for the newsfeed module again.\n- Fix issue with openweathermap not showing current or forecast info when using onecall API.\n- Fix tests in weather module and add one for decimalPoint in forecast.\n- Fix decimalSymbol in the forecast part of the new weather module (#2530).\n- Fix wrong treatment of `appendLocationNameToHeader` when using `ukmetofficedatahub`.\n- Fix alert not recognizing multiple alerts (#2522).\n- Fix fetch option httpsAgent to agent in calendar module (#466).\n- Fix module updatenotification which did not work for repos with many refs (#1907).\n- Fix config check failing when encountering let syntax (\"Parsing error: Unexpected token config\").\n- Fix calendar debug check.\n- Really run prettier over all files.\n- Fix logger.js after jest changes, use --forceExit running jest.\n- Workaround for dev_console test using getWindowCount.\n\n## [2.15.0] - 2021-04-01\n\nSpecial thanks to the following contributors: @EdgardosReis, @MystaraTheGreat, @TheDuffman85, @ashishtank, @buxxi, @codac, @fewieden, @khassel, @klaernie, @qu1que, @rejas, @sdetweil & @thomasrockhu.\n\nℹ️ **Note:** This update uses new dependencies. Please update using the following command: `git pull && npm install`.\n\n### Added\n\n- Added Galician language.\n- Added GitHub workflows for automated testing and changelog enforcement.\n- Added CodeCov badge to Readme.\n- Added CURRENTWEATHER_TYPE notification to currentweather and weather module, use it in compliments module.\n- Added `start:dev` command to the npm scripts for starting electron with devTools open.\n- Added logging when using deprecated modules weatherforecast or currentweather.\n- Added Portuguese translations for \"MODULE_CONFIG_CHANGED\" and \"PRECIP\".\n- Respect parameter ColoredSymbolOnly also for custom events.\n- Added a new parameter to hide time portion on relative times.\n- `module.show` has now the option for a callback on error.\n- Added locale to sample config file.\n- Added support for self-signed certificates for the default calendar module (#466).\n- Added hiddenOnStartup flag to module config (#2475).\n\n### Updated\n\n- Updated markdown files for github.\n- Cleaned up old code on server side.\n- Convert `-0` to `0` when displaying temperature.\n- Code cleanup for FEELS like and added {DEGREE} placeholder for FEELSLIKE for each language.\n- Converted newsfeed module to use templates.\n- Updated documentation and help screen about invalid config files.\n- Moving weather-provider specific code and configuration into each provider and making hourly part of the interface.\n- Bump electron to v11 and enable contextIsolation.\n- Don't update the DOM when a module is not displayed.\n- Cleaned up jsdoc and tests.\n- Exposed logger as node module for easier access for 3rd party modules.\n- Replaced deprecated `request` package with `node-fetch` and `digest-fetch`.\n- Refactored calendar fetcher.\n- Cleaned up newsfeed module.\n- Cleaned up translations and translator code.\n\n### Removed\n\n- Removed danger.js library.\n- Removed `ical` which was substituted by `node-ical` in release `v2.13.0`. Module developers must install this dependency themselves in the module folder if needed.\n- Removed valid-url library.\n\n### Fixed\n\n- Added default log levels to stop calendar log spamming.\n- Fix socket.io cors errors, see [breaking change since socket.io v3](https://socket.io/docs/v3/handling-cors/).\n- Fix Issue with weather forecast icons due to fixed day start and end time (#2221).\n- Fix empty directory for each module's main javascript file in the inspector.\n- Fix Issue with weather forecast icons unit tests with different timezones (#2221).\n- Fix issue with unencoded characters in translated strings when using nunjuck template (`Loading &hellip;` as an example).\n- Fix socket.io backward compatibility with socket v2 clients.\n- Fix 3rd party module language loading if language is English.\n- Fix e2e tests after spectron update.\n- Fix updatenotification creating zombie processes by setting a timeout for the git process.\n- Fix weather module openweathermap not loading if lat and lon set without onecall.\n- Fix calendar daylight savings offset calculation if recurring start date before 2007.\n- Fix calendar time/date adjustment when time with GMT offset is different day (#2488).\n- Fix calendar daylight savings offset calculation if recurring FULL DAY start date before 2007 (#2483).\n- Fix newsreaders template, for wrong test for nowrap in 2 places (should be if not).\n\n## [2.14.0] - 2021-01-01\n\nSpecial thanks to the following contributors: @Alvinger, @AndyPoms, @ashishtank, @bluemanos, @flopp999, @jakemulley, @jakobsarwary1, @marvai-vgtu, @mirontoli, @rejas, @sdetweil, @Snille & @Sub028.\n\nℹ️ **Note:** This update uses new dependencies. Please update using the following command: `git pull && npm install`.\n\n### Added\n\n- Added new log level \"debug\" to the logger.\n- Added new parameter \"useKmh\" to weather module for displaying wind speed as kmh.\n- Added Chuvash translation.\n- Added Weatherbit as a provider to Weather module.\n- Added SMHI as a provider to Weather module.\n- Added Hindi & Gujarati translation.\n- Added optional support for DEGREE position in Feels like translation.\n- Added support for variables in nunjucks templates for translate filter.\n- Added Chuvash translation.\n- Added new option \"limitDays\" - limit the number of discreet days displayed.\n- Added new option \"customEvents\" - use custom symbol/color based on keyword in event title.\n\n### Updated\n\n- Merging .gitignore in the config-folder with the .gitignore in the root-folder.\n- Weather module - forecast now show TODAY and TOMORROW instead of weekday, to make it easier to understand.\n- Updated dependencies to latest versions.\n- Updated dependencies eslint, feedme, simple-git and socket.io to latest versions.\n- Updated lithuanian translation.\n- Updated config sample.\n- Highlight required version mismatch.\n- No select Text for TouchScreen use.\n- Corrected logic for timeFormat \"relative\" and \"absolute\".\n- Added missing function call in module.show()\n- Translator variables can have falsy values (e.g. empty string)\n- Fix issue with weather module with DEGREE label in FEELS like\n\n### Deleted\n\n- Removed Travis CI integration.\n\n### Fixed\n\n- JSON Parse translation files with comments crashing UI. (#2149)\n- Calendar parsing where RRULE bug returns wrong date, add Windows timezone name support. (#2145, #2151)\n- Wrong node-ical version installed (package.json) requested version. (#2153)\n- Fix calendar fetcher subsequent timing. (#2160)\n- Rename Greek translation to correct ISO 639-1 alpha-2 code (gr > el). (#2155)\n- Add a space after icons of sunrise and sunset. (#2169)\n- Fix calendar when no DTEND record found in event, startDate overlay when endDate set. (#2177)\n- Fix windspeed conversion error in ukmetoffice weather-provider. (#2189)\n- Fix console.debug not having timestamps. (#2199)\n- Fix calendar full day event east of UTC start time. (#2200)\n- Fix non-fullday recurring rule processing. (#2216)\n- Catch errors when parsing calendar data with ical. (#2022)\n- Fix Default Alert Module does not hide black overlay when alert is dismissed manually. (#2228)\n- Weather module - Always displays night icons when local is other than English. (#2221)\n- Updated node-ical 0.12.4, fix invalid RRULE format in cal entries\n- Fix package.json for optional electron dependency (2378)\n- Updated node-ical version again, 0.12.5, change RRULE fix (#2371, #2379)\n- Remove undefined objects from modules array (#2382)\n- Updated node-ical version again, 0.12.7, change RRULE fix (#2371, #2379), node-ical now throws error (which we catch)\n- Updated simple-git version to 2.31 unhandled promise rejection (#2383)\n\n## [2.13.0] - 2020-10-01\n\nSpecial thanks to the following contributors: @bryanzzhu, @bugsounet, @chamakura, @cjbrunner, @easyas314, @larryare, @oemel09, @rejas, @sdetweil & @sthuber90.\n\nℹ️ **Note:** This update uses new dependencies. Please update using the following command: `git pull && npm install`.\n\n### Added\n\n- `--dry-run` Added option in fetch call within updatenotification node_helper. This is to prevent\n  MagicMirror² from consuming any fetch result. Causes conflict with MMPM when attempting to check\n  for updates to MagicMirror² and/or MagicMirror² modules.\n- Test coverage with Istanbul, run it with `npm run test:coverage`.\n- Added lithuanian language.\n- Added support in weatherforecast for OpenWeather onecall API.\n- Added config option to calendar-icons for recurring- and fullday-events.\n- Added current, hourly (max 48), and daily (max 7) weather forecasts to weather module via OpenWeatherMap One Call API.\n- Added eslint-plugin for jsdoc comments.\n- Added new configDeepMerge option for module developers.\n\n### Updated\n\n- Change incorrect weather.js default properties.\n- Cleaned up newsfeed module.\n- Cleaned up jsdoc comments.\n- Cleaned up clock tests.\n- Move lodash into devDependencies, update other dependencies.\n- Switch from ical to node-ical library.\n\n### Fixed\n\n- Fix backward compatibility issues for Safari < 11.\n- Fix the use of \"maxNumberOfDays\" in the module \"weatherforecast depending on the endpoint (forecast/daily or forecast)\". [#2018](https://github.com/MagicMirrorOrg/MagicMirror/issues/2018)\n- Fix calendar display. Account for current timezone. [#2068](https://github.com/MagicMirrorOrg/MagicMirror/issues/2068)\n- Fix logLevel being set before loading config.\n- Fix incorrect namespace links in svg clockfaces. [#2072](https://github.com/MagicMirrorOrg/MagicMirror/issues/2072)\n- Fix weather/providers/weathergov for API guidelines. [#2045](https://github.com/MagicMirrorOrg/MagicMirror/issues/2045)\n- Fix \"undefined\" in weather modules header. [#1985](https://github.com/MagicMirrorOrg/MagicMirror/issues/1985)\n- Fix #2110, #2111, #2118: Recurring full day events should not use timezone adjustment. Just compare month/day.\n\n## [2.12.0] - 2020-07-01\n\nSpecial thanks to the following contributors: @AndreKoepke, @andrezibaia, @bryanzzhu, @chamakura, @DarthBrento, @Ekristoffe, @khassel, @Legion2, @ndom91, @radokristof, @rejas, @XBCreepinJesus & @ZoneMR.\n\nℹ️ **Note:** This update uses new dependencies. Please update using the following command: `git pull && npm install`.\n\n### Added\n\n- Added option to config the level of logging.\n- Added prettier for an even cleaner codebase.\n- Hide Sunrise/Sunset in Weather module.\n- Hide Sunrise/Sunset in Current Weather module.\n- Added Met Office DataHub (UK) provider.\n\n### Updated\n\n- Cleaned up alert module code.\n- Cleaned up check_config code.\n- Replaced grunt-based linters with their non-grunt equivalents.\n- Switch to most of the eslint:recommended rules and fix warnings.\n- Replaced insecure links with https ones.\n- Cleaned up all \"no-undef\" warnings from eslint.\n- Added location title wrapping for calendar module.\n- Updated the BG translation.\n\n### Deleted\n\n- Removed truetype (ttf) fonts.\n\n### Fixed\n\n- The broken modules due to Socket.io change from last release. [#1973](https://github.com/MagicMirrorOrg/MagicMirror/issues/1973)\n- Add backward compatibility for old module code in socketclient.js. [#1973](https://github.com/MagicMirrorOrg/MagicMirror/issues/1973)\n- Support multiple instances of calendar module with different config. [#1109](https://github.com/MagicMirrorOrg/MagicMirror/issues/1109)\n- Fix the use of \"maxNumberOfDays\" in the module \"weatherforecast\". [#2018](https://github.com/MagicMirrorOrg/MagicMirror/issues/2018)\n- Throw error when check_config fails. [#1928](https://github.com/MagicMirrorOrg/MagicMirror/issues/1928)\n- Bug fix related to 'maxEntries' not displaying Calendar events. [#2050](https://github.com/MagicMirrorOrg/MagicMirror/issues/2050)\n- Updated ical library to the latest version. [#1926](https://github.com/MagicMirrorOrg/MagicMirror/issues/1926)\n- Fix config check after merge of prettier [#2109](https://github.com/MagicMirrorOrg/MagicMirror/issues/2109)\n\n## [2.11.0] - 2020-04-01\n\n🚨 READ THIS BEFORE UPDATING 🚨\n\nIn the past years the project has grown a lot. This came with a huge downside: poor maintainability. If I let the project continue the way it was, it would eventually crash and burn. More important: I would completely lose the drive and interest to continue the project. Because of this the decision was made to simplify the core by removing all side features like automatic installers and support for exotic platforms. This release (2.11.0) is the first real release that will reflect (parts) of these changes. As a result of this, some things might break. So before you continue make sure to backup your installation. Your config, your modules or better yet: your full MagicMirror² folder. In other words: update at your own risk.\n\nFor more information regarding this major change, please check issue [#1860](https://github.com/MagicMirrorOrg/MagicMirror/issues/1860).\n\n### Deleted\n\n- Remove installers.\n- Remove externalized scripts.\n- Remove jshint dependency, instead eslint checks your config file now\n\n### Added\n\n- Brazilian translation for \"FEELS\".\n- Ukrainian translation.\n- Finnish translation for \"PRECIP\", \"UPDATE_INFO_MULTIPLE\" and \"UPDATE_INFO_SINGLE\".\n- Added the ability to hide the temp label and weather icon in the `currentweather` module to allow showing only information such as wind and sunset/rise.\n- The `clock` module now optionally displays sun and moon data, including rise/set times, remaining daylight, and percent of moon illumination.\n- Added Hebrew translation.\n- Add HTTPS support and update config.js.sample\n- Run tests on long term support and latest stable version of nodejs\n- Added the ability to configure a list of modules that shouldn't be update checked.\n- Run linters on git commits\n- Added date functionality to compliments: display birthday wishes or celebrate an anniversary\n- Add HTTPS support for clientonly-mode.\n\n### Fixed\n\n- Force declaration of public ip address in config file (ISSUE #1852)\n- Fixes `run-start.sh`: If running in docker-container, don't check the environment, just start electron (ISSUE #1859)\n- Fix calendar time offset for recurring events crossing Daylight Savings Time (ISSUE #1798)\n- Fix regression in currentweather module causing 'undefined' to show up when config.hideTemp is false\n- Fix FEELS translation for Croatian\n- Fixed weather tests [#1840](https://github.com/MagicMirrorOrg/MagicMirror/issues/1840)\n- Fixed Socket.io can't be used with Reverse Proxy in serveronly mode [#1934](https://github.com/MagicMirrorOrg/MagicMirror/issues/1934)\n- Fix update checking skipping 3rd party modules the first time\n\n### Changed\n\n- Remove documentation from core repository and link to new dedicated docs site: [docs.magicmirror.builders](https://docs.magicmirror.builders).\n- Updated config.js.sample: Corrected some grammar on `config.js.sample` comment section.\n- Removed `run-start.sh` script and update start commands:\n  - To start using electron, use `npm run start`.\n  - To start in server only mode, use `npm run server`.\n- Remove redundant logging from modules.\n- Timestamp in log output now also contains the date\n- Turkish translation.\n- Option to configure the size of the currentweather module.\n- Changed \"Gevoelstemperatuur\" to \"Voelt als\" shorter text.\n\n## [2.10.1] - 2020-01-10\n\n### Changed\n\n- Updated README.md: Added links to the official documentation website and remove links to broken installer.\n\n## [2.10.0] - 2020-01-01\n\nSpecial thanks to @sdetweil for all his great contributions!\n\nℹ️ **Note:** This update uses new dependencies. Please update using the following command: `git pull && npm install`.\n\n### Added\n\n- Timestamps in log output.\n- Padding in dateheader mode of the calendar module.\n- New upgrade script to help users consume regular updates installers/upgrade-script.sh.\n- New script to help setup pm2, without install installers/fixuppm2.sh.\n\n### Updated\n\n- Updated lower bound of `lodash` and `helmet` dependencies for security patches.\n- Updated compliments.js to handle newline in text, as text fields to not interpolate contents.\n- Updated raspberry.sh installer script to handle new platform issues, split node/npm, pm2, and screen saver changes.\n- Improve handling for armv6l devices, where electron support has gone away, add optional serveronly config option.\n- Improved run-start.sh to handle for serveronly mode, by choice, or when electron not available.\n- Only check for xwindows running if not on macOS.\n\n### Fixed\n\n- Fixed issue in weatherforecast module where predicted amount of rain was not using the decimal symbol specified in config.js.\n- Module header now updates correctly, if a module need to dynamically show/hide its header based on a condition.\n- Fix handling of config.js for serverOnly mode commented out.\n- Fixed issue in calendar module where the debug script didn't work correctly with authentication.\n- Fixed issue that some full day events were not correctly recognized as such.\n- Display full day events lasting multiple days as happening today instead of some days ago if they are still ongoing.\n\n## [2.9.0] - 2019-10-01\n\nℹ️ **Note:** This update uses new dependencies. Please update using the following command: `git pull && npm install`. If you are having issues running Electron, make sure your [Raspbian is up to date](https://www.raspberrypi.org/documentation/raspbian/updating.md).\n\n### Added\n\n- Spanish translation for \"PRECIP\".\n- Adding a Malay (Malaysian) translation for MagicMirror².\n- Add test check URLs of vendors 200 and 404 HTTP CODE.\n- Add tests for new weather module and helper to stub ajax requests.\n\n### Updated\n\n- Updatenotification module: Display update notification for a limited (configurable) time.\n- Enabled e2e/vendor_spec.js tests.\n- The css/custom.css will be renamed after the next release. We've added into `run-start.sh` an instruction by GIT to ignore with `--skip-worktree` and `rm --cached`. [#1540](https://github.com/MagicMirrorOrg/MagicMirror/issues/1540)\n- Disable sending of notification CLOCK_SECOND when displaySeconds is false.\n\n### Fixed\n\n- Updatenotification module: Properly handle race conditions, prevent crash.\n- Send `NEWS_FEED` notification also for the first news messages which are shown.\n- Fixed issue where weather module would not refresh data after a network or API outage. [#1722](https://github.com/MagicMirrorOrg/MagicMirror/issues/1722)\n- Fixed weatherforecast module not displaying rain amount on fallback endpoint.\n- Notifications CLOCK_SECOND & CLOCK_MINUTE being from startup instead of matched against the clock and avoid drifting.\n\n## [2.8.0] - 2019-07-01\n\nℹ️ **Note:** This update uses new dependencies. Please update using the following command: `git pull && npm install`. If you are having issues running Electron, make sure your [Raspbian is up to date](https://www.raspberrypi.org/documentation/raspbian/updating.md).\n\n### Added\n\n- Option to show event location in calendar\n- Finnish translation for \"Feels\" and \"Weeks\"\n- Russian translation for “Feels”\n- Calendar module: added `nextDaysRelative` config option\n- Add `broadcastPastEvents` config option for calendars to include events from the past `maximumNumberOfDays` in event broadcasts\n- Added feature to broadcast news feed items `NEWS_FEED` and updated news items `NEWS_FEED_UPDATED` in default [newsfeed](https://github.com/MagicMirrorOrg/MagicMirror/tree/develop/modules/default/newsfeed) module (when news is updated) with documented default and `config.js` options in [README.md](https://github.com/MagicMirrorOrg/MagicMirror/blob/develop/modules/default/newsfeed/README.md)\n- Added notifications to default `clock` module broadcasting `CLOCK_SECOND` and `CLOCK_MINUTE` for the respective time elapsed.\n- Added UK Met Office Datapoint feed as a provider in the default weather module.\n- Added new provider class\n- Added suncalc.js dependency to calculate sun times (not provided in UK Met Office feed)\n- Added \"tempUnits\" and \"windUnits\" to allow, for example, temp in metric (i.e. celsius) and wind in imperial (i.e. mph). These will override \"units\" if specified, otherwise the \"units\" value will be used.\n- Use Feels Like temp from feed if present\n- Optionally display probability of precipitation (PoP) in current weather (UK Met Office data)\n- Automatically try to fix eslint errors by passing `--fix` option to it\n- Added sunrise and sunset times to weathergov weather-provider [#1705](https://github.com/MagicMirrorOrg/MagicMirror/issues/1705)\n- Added \"useLocationAsHeader\" to display \"location\" in `config.js` as header when location name is not returned\n- Added to `newsfeed.js`: in order to design the news article better with css, three more class-names were introduced: newsfeed-desc, newsfeed-desc, newsfeed-desc\n\n### Updated\n\n- English translation for \"Feels\" to \"Feels like\"\n- Fixed the example calendar url in `config.js.sample`\n- Updated `ical.js` to solve various calendar issues.\n- Updated weather city list url [#1676](https://github.com/MagicMirrorOrg/MagicMirror/issues/1676)\n- Only update clock once per minute when seconds aren't shown\n- Updated weather-provider documentation.\n\n### Fixed\n\n- Fixed uncaught exception, race condition on module update\n- Fixed issue [#1696](https://github.com/MagicMirrorOrg/MagicMirror/issues/1696), some ical files start date to not parse to date type\n- Allowance HTML5 autoplay-policy (policy is changed from Chrome 66 updates)\n- Handle SIGTERM messages\n- Fixes sliceMultiDayEvents so it respects maximumNumberOfDays\n- Minor types in default NewsFeed [README.md](https://github.com/MagicMirrorOrg/MagicMirror/blob/develop/modules/default/newsfeed/README.md)\n- Fix typos and small syntax errors, cleanup dependencies, remove multiple-empty-lines, add semi-rule\n- Fixed issues with calendar not displaying one-time changes to repeating events\n- Updated the fetchedLocationName variable in currentweather.js so that city shows up in the header\n\n### Updated installer\n\n- give non-pi2+ users (pi0, odroid, jetson nano, mac, windows, ...) option to continue install\n- use current username vs hardcoded 'pi' to support non-pi install\n- check for npm installed. node install doesn't do npm anymore\n- check for mac as part of PM2 install, add install option string\n- Updated pm2 config with current username instead of hard coded 'pi'\n- check for screen saver config, \"/etc/xdg/lxsession\", bypass if not setup\n\n## [2.7.1] - 2019-04-02\n\nFixed `package.json` version number.\n\n## [2.7.0] - 2019-04-01\n\nℹ️ **Note:** This update uses new dependencies. Please update using the following command: `git pull && npm install`. If you are having issues running Electron, make sure your [Raspbian is up to date](https://www.raspberrypi.org/documentation/raspbian/updating.md).\n\n### Added\n\n- Italian translation for \"Feels\"\n- Basic Klingon (tlhIngan Hol) translations\n- Disabled the screensaver on raspbian with installation script\n- Added option to truncate the number of vertical lines a calendar item can span if `wrapEvents` is enabled.\n- Danish translation for \"Feels\" and \"Weeks\"\n- Added option to split multiple day events in calendar to separate numbered events\n- Slovakian translation\n- Alerts now can contain Font Awesome icons\n- Notifications display time can be set in request\n- Newsfeed: added support for `ARTICLE_INFO_REQUEST` notification\n- Add `name` config option for calendars to be sent along with event broadcasts\n\n### Updated\n\n- Bumped the Electron dependency to v3.0.13 to support the most recent Raspbian. [#1500](https://github.com/MagicMirrorOrg/MagicMirror/issues/1500)\n- Updated modernizr code in alert module, fixed a small typo there too\n- More verbose error message on console if the config is malformed\n- Updated installer script to install Node.js version 10.x\n\n### Fixed\n\n- Fixed temperature displays in currentweather and weatherforecast modules [#1503](https://github.com/MagicMirrorOrg/MagicMirror/issues/1503), [#1511](https://github.com/MagicMirrorOrg/MagicMirror/issues/1511).\n- Fixed unhandled error on bad git data in updatenotification module [#1285](https://github.com/MagicMirrorOrg/MagicMirror/issues/1285).\n- Weather forecast now works with openweathermap in new weather module. Daily data are displayed, see issue [#1504](https://github.com/MagicMirrorOrg/MagicMirror/issues/1504).\n- Fixed analogue clock border display issue where non-black backgrounds used (previous fix for issue 611)\n- Fixed compatibility issues caused when modules request different versions of Font Awesome, see issue [#1522](https://github.com/MagicMirrorOrg/MagicMirror/issues/1522). MagicMirror² now uses [Font Awesome 5 with v4 shims included for backwards compatibility](https://fontawesome.com/how-to-use/on-the-web/setup/upgrading-from-version-4#shims).\n- Installation script problems with raspbian\n- Calendar: only show repeating count if the event is actually repeating [#1534](https://github.com/MagicMirrorOrg/MagicMirror/pull/1534)\n- Calendar: Fix exdate handling when multiple values are specified (comma separated)\n- Calendar: Fix relative date handling for fulldate events, calculate difference always from start of day [#1572](https://github.com/MagicMirrorOrg/MagicMirror/issues/1572)\n- Fix null dereference in moduleNeedsUpdate when the module isn't visible\n- Calendar: Fixed event end times by setting default calendarEndTime to \"LT\" (Local time format). [#1479]\n- Calendar: Fixed missing calendar fetchers after server process restarts [#1589](https://github.com/MagicMirrorOrg/MagicMirror/issues/1589)\n- Notification: fixed background color (was white text on white background)\n- Use getHeader instead of data.header when creating the DOM so overwriting the function also propagates into it\n- Fix documentation of `useKMPHwind` option in currentweather\n\n### New weather module\n\n- Fixed weather forecast table display [#1499](https://github.com/MagicMirrorOrg/MagicMirror/issues/1499).\n- Dimmed loading indicator for weather forecast.\n- Implemented config option `decimalSymbol` [#1499](https://github.com/MagicMirrorOrg/MagicMirror/issues/1499).\n- Aligned indoor values in current weather vertical [#1499](https://github.com/MagicMirrorOrg/MagicMirror/issues/1499).\n- Added humidity support to nunjuck unit filter.\n- Do not display degree symbol for temperature in Kelvin [#1503](https://github.com/MagicMirrorOrg/MagicMirror/issues/1503).\n- Weather forecast now works with openweathermap for both, `/forecast` and `/forecast/daily`, in new weather module. If you use the `/forecast`-weatherEndpoint, the hourly data are converted to daily data, see issues [#1504](https://github.com/MagicMirrorOrg/MagicMirror/issues/1504), [#1513](https://github.com/MagicMirrorOrg/MagicMirror/issues/1513).\n- Added fade, fadePoint and maxNumberOfDays properties to the forecast mode [#1516](https://github.com/MagicMirrorOrg/MagicMirror/issues/1516)\n- Fixed Loading string and decimalSymbol string replace [#1538](https://github.com/MagicMirrorOrg/MagicMirror/issues/1538)\n- Show Snow amounts in new weather module [#1545](https://github.com/MagicMirrorOrg/MagicMirror/issues/1545)\n- Added weather.gov as a new weather-provider for US locations\n\n## [2.6.0] - 2019-01-01\n\nℹ️ **Note:** This update uses new dependencies. Please update using the following command: `git pull && npm install`. If you are having issues updating, make sure you are running the latest version of Node.\n\n### ✨ Experimental ✨\n\n- New default [module weather](modules/default/weather). This module will eventually replace the current `currentweather` and `weatherforecast` modules. The new module is still pretty experimental, but it's included so you can give it a try and help us improve this module. Please give us you feedback using [this forum post](https://forum.magicmirror.builders/topic/9335/default-weather-module-refactoring).\n\nA huge, huge, huge thanks to user @fewieden for all his hard work on the new `weather` module!\n\n### Added\n\n- Possibility to add classes to the cell of symbol, title and time of the events of calendar.\n- Font-awesome 5, still has 4 for backwards compatibility.\n- Missing `showEnd` in calendar documentation\n- Screenshot for the new feed module\n- Screenshot for the compliments module\n- Screenshot for the clock module\n- Screenshot for the current weather\n- Screenshot for the weather forecast module\n- Portuguese translation for \"Feels\"\n- Croatian translation\n- Fading for dateheaders timeFormat in Calendar [#1464](https://github.com/MagicMirrorOrg/MagicMirror/issues/1464)\n- Documentation for the existing `scale` option in the Weather Forecast module.\n\n### Fixed\n\n- Allow parsing recurring calendar events where the start date is before 1900\n- Fixed Polish translation for Single Update Info\n- Ignore entries with unparseable details in the calendar module\n- Bug showing FullDayEvents one day too long in calendar fixed\n- Bug in newsfeed when `removeStartTags` is used on the description [#1478](https://github.com/MagicMirrorOrg/MagicMirror/issues/1478)\n\n### Updated\n\n- The default calendar setting `showEnd` is changed to `false`.\n\n### Changed\n\n- The Weather Forecast module by default displays the &deg; symbol after every numeric value to be consistent with the Current Weather module.\n\n## [2.5.0] - 2018-10-01\n\n### Added\n\n- Romanian translation for \"Feels\"\n- Support multi-line compliments\n- Simplified Chinese translation for \"Feels\"\n- Polish translate for \"Feels\"\n- French translate for \"Feels\"\n- Translations for newsfeed module\n- Support for toggling news article in fullscreen\n- Hungarian translation for \"Feels\" and \"Week\"\n- Spanish translation for \"Feels\"\n- Add classes instead of inline style to the message from the module Alert\n- Support for events having a duration instead of an end\n- Support for showing end of events through config parameters showEnd and dateEndFormat\n\n### Fixed\n\n- Fixed gzip encoded calendar loading issue #1400.\n- Fixed mixup between german and spanish translation for newsfeed.\n- Fixed close dates to be absolute, if no configured in the config.js - module Calendar\n- Fixed the updatenotification module message about new commits in the repository, so they can be correctly localized in singular and plural form.\n- Fix for weatherforecast rainfall rounding [#1374](https://github.com/MagicMirrorOrg/MagicMirror/issues/1374)\n- Fix calendar parsing issue for Midori on Raspberry Pi Zero w, related to issue #694.\n- Fix weather city ID link in sample config\n- Fixed issue with clientonly not updating with IP address and port provided on command line.\n\n### Updated\n\n- Updated Simplified Chinese translation\n- Swedish translations\n- Hungarian translations for the updatenotification module\n- Updated Norsk bokmål translation\n- Updated Norsk nynorsk translation\n- Consider multi days event as full day events\n\n## [2.4.1] - 2018-07-04\n\n### Fixed\n\n- Fix weather parsing issue #1332.\n\n## [2.4.0] - 2018-07-01\n\n⚠️ **Warning:** This release includes an updated version of Electron. This requires a Raspberry Pi configuration change to allow the best performance and prevent the CPU from overheating. Please read the information on the [MagicMirror² Wiki](https://github.com/MagicMirrorOrg/MagicMirror/wiki/configuring-the-raspberry-pi#enable-the-open-gl-driver-to-decrease-electrons-cpu-usage).\n\nℹ️ **Note:** This update uses new dependencies. Please update using the following command: `git pull && npm install`\n\n### Added\n\n- Enabled translation of feelsLike for module currentweather\n- Added support for on-going calendar events\n- Added scroll up in fullscreen newsfeed article view\n- Changed fullscreen newsfeed width from 100% to 100vw (better results)\n- Added option to calendar module that colors only the symbol instead of the whole line\n- Added option for new display format in the calendar module with date headers with times/events below.\n- Ability to fetch compliments from a remote server\n- Add regex filtering to calendar module\n- Customize classes for table\n- Added option to newsfeed module to only log error parsing a news article if enabled\n- Add update translations for Português Brasileiro\n\n### Changed\n\n- Upgrade to Electron 2.0.0.\n- Remove yarn-or-npm which breaks production builds.\n- Invoke module suspend even if no dom content. [#1308](https://github.com/MagicMirrorOrg/MagicMirror/issues/1308)\n\n### Fixed\n\n- Fixed issue where wind chill could not be displayed in Fahrenheit. [#1247](https://github.com/MagicMirrorOrg/MagicMirror/issues/1247)\n- Fixed issues where a module crashes when it tries to dismiss a non existing alert. [#1240](https://github.com/MagicMirrorOrg/MagicMirror/issues/1240)\n- In default module currentWeather/currentWeather.js line 296, 300, self.config.animationSpeed can not be found because the notificationReceived function does not have \"self\" variable.\n- Fixed browser-side code to work on the Midori browser.\n- Fixed issue where heat index was reporting incorrect values in Celsius and Fahrenheit. [#1263](https://github.com/MagicMirrorOrg/MagicMirror/issues/1263)\n- Fixed weatherforecast to use dt_txt field instead of dt to handle timezones better\n- Newsfeed now remembers to show the description when `\"ARTICLE_LESS_DETAILS\"` is called if the user wants to always show the description. [#1282](https://github.com/MagicMirrorOrg/MagicMirror/issues/1282)\n- `clientonly/*.js` is now linted, and one linting error is fixed\n- Fix issue #1196 by changing underscore to hyphen in locale id, in align with moment.js.\n- Fixed issue where heat index and wind chill were reporting incorrect values in Kelvin. [#1263](https://github.com/MagicMirrorOrg/MagicMirror/issues/1263)\n\n### Updated\n\n- Updated Italian translation\n- Updated German translation\n- Updated Dutch translation\n\n## [2.3.1] - 2018-04-01\n\n### Fixed\n\n- Downgrade electron to 1.4.15 to solve the black screen issue.[#1243](https://github.com/MagicMirrorOrg/MagicMirror/issues/1243)\n\n## [2.3.0] - 2018-04-01\n\n### Added\n\n- Add new settings in compliments module: setting time intervals for morning and afternoon\n- Add system notification `MODULE_DOM_CREATED` for notifying each module when their Dom has been fully loaded.\n- Add types for module.\n- Implement Danger.js to notify contributors when CHANGELOG.md is missing in PR.\n- Allow scrolling in full page article view of default newsfeed module with gesture events from [MMM-Gestures](https://github.com/thobach/MMM-Gestures)\n- Changed 'compliments.js' - Updated DOM if remote compliments are loaded instead of waiting one updateInterval to show custom compliments\n- Automated unit tests utils, deprecated, translator, cloneObject(lockStrings)\n- Automated integration tests translations\n- Add advanced filtering to the excludedEvents configuration of the default calendar module\n- New currentweather module config option: `showFeelsLike`: Shows how it actually feels like. (wind chill or heat index)\n- New currentweather module config option: `useKMPHwind`: adds an option to see wind speed in Kmph instead of just m/s or Beaufort.\n- Add dc:date to parsing in newsfeed module, which allows parsing of more rss feeds.\n\n### Changed\n\n- Add link to GitHub repository which contains the respective Dockerfile.\n- Optimized automated unit tests cloneObject, cmpVersions\n- Updated notifications use now translation templates instead of normal strings.\n- Yarn can be used now as an installation tool\n- Changed Electron dependency to v1.7.13.\n\n### Fixed\n\n- News article in fullscreen (iframe) is now shown in front of modules.\n- Forecast respects maxNumberOfDays regardless of endpoint.\n- Fix exception on translation of objects.\n\n## [2.2.2] - 2018-01-02\n\n### Added\n\n- Add missing `package-lock.json`.\n\n### Changed\n\n- Changed Electron dependency to v1.7.10.\n\n## [2.2.1] - 2018-01-01\n\n### Fixed\n\n- Fixed linting errors.\n\n## [2.2.0] - 2018-01-01\n\n**Note:** This update uses new dependencies. Please update using the following command: `git pull && npm install`\n\n### Changed\n\n- Calendar week is now handled with a variable translation in order to move number language specific.\n- Reverted the Electron dependency back to 1.4.15 since newer version don't seem to work on the Raspberry Pi very well.\n\n### Added\n\n- Add option to use [Nunjucks](https://mozilla.github.io/nunjucks/) templates in modules. (See `helloworld` module as an example.)\n- Add Bulgarian translations for MagicMirror² and Alert module.\n- Add graceful shutdown of modules by calling `stop` function of each `node_helper` on SIGINT before exiting.\n- Link update subtext to Github diff of current version versus tracking branch.\n- Add Catalan translation.\n- Add ability to filter out newsfeed items based on prohibited words found in title (resolves #1071)\n- Add options to truncate description support of a feed in newsfeed module\n- Add reloadInterval option for particular feed in newsfeed module\n- Add no-cache entries of HTTP headers in newsfeed module (fetcher)\n- Add Czech translation.\n- Add option for decimal symbols other than the decimal point for temperature values in both default weather modules: WeatherForecast and CurrentWeather.\n\n### Fixed\n\n- Fixed issue with calendar module showing more than `maximumEntries` allows\n- WeatherForecast and CurrentWeather are now using HTTPS instead of HTTP\n- Correcting translation for Indonesian language\n- Fix issue where calendar icons wouldn't align correctly\n\n## [2.1.3] - 2017-10-01\n\n**Note:** This update uses new dependencies. Please update using the following command: `git pull && npm install`\n\n### Changed\n\n- Remove Roboto fonts files inside `fonts` and these are installed by npm install command.\n\n### Added\n\n- Add `clientonly` script to start only the electron client for a remote server.\n- Add symbol and color properties of event when `CALENDAR_EVENTS` notification is broadcasted from `default/calendar` module.\n- Add `.vscode/` folder to `.gitignore` to keep custom Visual Studio Code config out of git.\n- Add unit test the capitalizeFirstLetter function of newsfeed module.\n- Add new unit tests for function `shorten` in calendar module.\n- Add new unit tests for function `getLocaleSpecification` in calendar module.\n- Add unit test for js/class.js.\n- Add unit tests for function `roundValue` in currentweather module.\n- Add test e2e showWeek feature in spanish language.\n- Add warning Log when is used old authentication method in the calendar module.\n- Add test e2e for helloworld module with default config text.\n- Add ability for `currentweather` module to display indoor humidity via INDOOR_HUMIDITY notification.\n- Add Welsh (Cymraeg) translation.\n- Add Slack badge to Readme.\n\n### Updated\n\n- Changed 'default.js' - listen on all attached interfaces by default.\n- Add execution of `npm list` after the test are ran in Travis CI.\n- Change hooks for the vendors e2e tests.\n- Add log when clientonly failed on starting.\n- Add warning color when are using full ip whitelist.\n- Set version of the `express-ipfilter` on 0.3.1.\n\n### Fixed\n\n- Fixed issue with incorrect alignment of analog clock when displayed in the center column of the MM.\n- Fixed ipWhitelist behavior to make empty whitelist ([]) allow any and all hosts access to the MM.\n- Fixed issue with calendar module where 'excludedEvents' count towards 'maximumEntries'.\n- Fixed issue with calendar module where global configuration of maximumEntries was not overridden by calendar specific config (see module doc).\n- Fixed issue where `this.file(filename)` returns a path with two hashes.\n- Workaround for the WeatherForecast API limitation.\n\n## [2.1.2] - 2017-07-01\n\n### Changed\n\n- Revert Docker related changes in favor of [docker-MagicMirror](https://github.com/bastilimbach/docker-MagicMirror). All Docker images are outsourced. ([#856](https://github.com/MagicMirrorOrg/MagicMirror/pull/856))\n- Change Docker base image (Debian + Node) to an arm based distro (AlpineARM + Node) ([#846](https://github.com/MagicMirrorOrg/MagicMirror/pull/846))\n- Fix the dockerfile to have it running from the first time.\n\n### Added\n\n- Add in option to wrap long calendar events to multiple lines using `wrapEvents` configuration option.\n- Add test e2e `show title newsfeed` for newsfeed module.\n- Add task to check configuration file.\n- Add test check URLs of vendors.\n- Add test of match current week number on clock module with showWeek configuration.\n- Add test default modules present modules/default/defaultmodules.js.\n- Add unit test calendar_modules function capFirst.\n- Add test for check if exists the directories present in defaults modules.\n- Add support for showing wind direction as an arrow instead of abbreviation in currentWeather module.\n- Add support for writing translation functions to support flexible word order\n- Add test for check if exits the directories present in defaults modules.\n- Add calendar option to set a separate date format for full day events.\n- Add ability for `currentweather` module to display indoor temperature via INDOOR_TEMPERATURE notification\n- Add ability to change the path of the `custom.css`.\n- Add translation Dutch to Alert module.\n- Added Romanian translation.\n\n### Updated\n\n- Added missing keys to Polish translation.\n- Added missing key to German translation.\n- Added better translation with flexible word order to Finnish translation.\n\n### Fixed\n\n- Fix instruction in README for using automatically installer script.\n- Bug of [duplicated compliments](https://forum.magicmirror.builders/topic/2381/compliments-module-stops-cycling-compliments).\n- Fix double message about port when server is starting\n- Corrected Swedish translations for TODAY/TOMORROW/DAYAFTERTOMORROW.\n- Removed unused import from js/electron.js\n- Made calendar.js respect config.timeFormat irrespective of locale setting.\n- Fixed alignment of analog clock when a large calendar is displayed in the same side bar.\n\n## [2.1.1] - 2017-04-01\n\n**Note:** This update uses new dependencies. Please update using the following command: `git pull && npm install`\n\n### Changed\n\n- Add `anytime` group for Compliments module.\n- Compliments module can use remoteFile without default daytime arrays defined.\n- Installer: Use init config.js from config.js.sample.\n- Switched out `rrule` package for `rrule-alt` and fixes in `ical.js` in order to fix calendar issues. ([#565](https://github.com/MagicMirrorOrg/MagicMirror/issues/565))\n- Make mouse events pass through the region fullscreen_above to modules below.\n- Scaled the splash screen down to make it a bit more subtle.\n- Replace HTML tables with markdown tables in README files.\n- Added `DAYAFTERTOMORROW`, `UPDATE_NOTIFICATION` and `UPDATE_NOTIFICATION_MODULE` to Finnish translations.\n- Run `npm test` on Travis automatically.\n- Show the splash screen image even when is reboot or halted.\n- Added some missing translation strings in the sv.json file.\n- Run task jsonlint to check translation files.\n- Restructured Test Suite.\n\n### Added\n\n- Added Docker support (Pull Request [#673](https://github.com/MagicMirrorOrg/MagicMirror/pull/673)).\n- Calendar-specific support for `maximumEntries`, and `maximumNumberOfDays`.\n- Add loaded function to modules, providing an async callback.\n- Made default newsfeed module aware of gesture events from [MMM-Gestures](https://github.com/thobach/MMM-Gestures)\n- Add use pm2 for manager process into Installer RaspberryPi script.\n- Russian Translation.\n- Afrikaans Translation.\n- Add postinstall script to notify user that MagicMirror² installed successfully despite warnings from NPM.\n- Init tests using mocha.\n- Option to use RegExp in Calendar's titleReplace.\n- Hungarian Translation.\n- Icelandic Translation.\n- Add use a script to prevent when is run by SSH session set DISPLAY environment.\n- Enable ability to set configuration file by the environment variable called MM_CONFIG_FILE.\n- Option to give each calendar a different color.\n- Option for colored min-temp and max-temp.\n- Add test e2e helloworld.\n- Add test e2e environment.\n- Add `chai-as-promised` npm module to devDependencies.\n- Basic set of tests for clock module.\n- Run e2e test in Travis.\n- Estonian Translation.\n- Add test for compliments module for parts of day.\n- Korean Translation.\n- Added console warning on startup when deprecated config options are used.\n- Add option to display temperature unit label to the current weather module.\n- Added ability to disable wrapping of news items.\n- Added in the ability to hide events in the calendar module based on simple string filters.\n- Updated Norwegian translation.\n- Added hideLoading option for News Feed module.\n- Added configurable dateFormat to clock module.\n- Added multiple calendar icon support.\n- Added tests for Translations, dev argument, version, dev console.\n- Added test anytime feature compliments module.\n- Added test ipWhitelist configuration directive.\n- Added test for calendar module: default, basic-auth, backward compatibility, fail-basic-auth.\n- Added meta tags to support fullscreen mode on iOS (for server mode)\n- Added `ignoreOldItems` and `ignoreOlderThan` options to the News Feed module\n- Added test for MM_PORT environment variable.\n- Added a configurable Week section to the clock module.\n\n### Fixed\n\n- Updated .gitignore to not ignore default modules folder.\n- Remove white flash on boot up.\n- Added `update` in Raspberry Pi installation script.\n- Fix an issue where the analog clock looked scrambled. ([#611](https://github.com/MagicMirrorOrg/MagicMirror/issues/611))\n- If units are set to imperial, the showRainAmount option of weatherforecast will show the correct unit.\n- Module currentWeather: check if temperature received from api is defined.\n- Fix an issue with module hidden status changing to `true` although lock string prevented showing it.\n- Fix newsfeed module bug (removeStartTags)\n- Fix when is set MM_PORT environment variable.\n- Fixed missing animation on `this.show(speed)` when module is alone in a region.\n\n## [2.1.0] - 2016-12-31\n\n**Note:** This update uses new dependencies. Please update using the following command: `git pull && npm install`\n\n### Added\n\n- Finnish translation.\n- Danish translation.\n- Turkish translation.\n- Option to limit access to certain IP addresses based on the value of `ipWhitelist` in the `config.js`, default is access from localhost only (Issue [#456](https://github.com/MagicMirrorOrg/MagicMirror/issues/456)).\n- Added ability to change the point of time when calendar events get relative.\n- Add Splash screen on boot.\n- Add option to show humidity in currentWeather module.\n- Add VSCode IntelliSense support.\n- Module API: Add Visibility locking to module system. [See documentation](https://github.com/MagicMirrorOrg/MagicMirror/tree/develop/modules#visibility-locking) for more information.\n- Module API: Method to overwrite the module's header. [See documentation](https://github.com/MagicMirrorOrg/MagicMirror/tree/develop/modules#getheader) for more information.\n- Module API: Option to define the minimum MagicMirror² version to run a module. [See documentation](https://github.com/MagicMirrorOrg/MagicMirror/tree/develop/modules#requiresversion) for more information.\n- Calendar module now broadcasts the event list to all other modules using the notification system. [See documentation](https://github.com/MagicMirrorOrg/MagicMirror/tree/develop/modules/default/calendar) for more information.\n- Possibility to use the calendar feed as the source for the weather (currentweather & weatherforecast) location data. [See documentation](https://github.com/MagicMirrorOrg/MagicMirror/tree/develop/modules/default/weatherforecast) for more information.\n- Added option to show rain amount in the weatherforecast default module\n- Add module `updatenotification` to get an update whenever a new version is available. [See documentation](https://github.com/MagicMirrorOrg/MagicMirror/tree/develop/modules/default/updatenotification) for more information.\n- Add the ability to set timezone on the date display in the Clock Module\n- Ability to set date format in calendar module\n- Possibility to use currentweather for the compliments\n- Added option `disabled` for modules.\n- Added option `address` to set bind address.\n- Added option `onlyTemp` for currentweather module to show only current temperature and weather icon.\n- Added option `remoteFile` to compliments module to load compliment array from filesystem.\n- Added option `zoom` to scale the whole mirror display with a given factor.\n- Added option `roundTemp` for currentweather and weatherforecast modules to display temperatures rounded to nearest integer.\n- Added ability set the classes option to compliments module for style and text size of compliments.\n- Added ability to configure electronOptions\n- Calendar module: option to hide private events\n- Add root_path for global vars\n\n### Updated\n\n- Modified translations for Frysk.\n- Modified core English translations.\n- Updated package.json as a result of Snyk security update.\n- Improve object instantiation to prevent reference errors.\n- Improve logger. `Log.log()` now accepts multiple arguments.\n- Remove extensive logging in newsfeed node helper.\n- Calendar times are now uniformly capitalized.\n- Modules are now secure, and Helmet is now used to prevent abuse of the Mirror's API.\n\n### Fixed\n\n- Solve an issue where module margins would appear when the first module of a section was hidden.\n- Solved visual display errors on chrome, if all modules in one of the right sections are hidden.\n- Global and Module default config values are no longer modified when setting config values.\n- Hide a region if all modules in a region are hidden. Prevention unwanted margins.\n- Replaced `electron-prebuilt` package with `electron` in order to fix issues that would happen after 2017.\n- Documentation of alert module\n\n## [2.0.5] - 2016-09-20\n\n### Added\n\n- Added ability to remove tags from the beginning or end of newsfeed items in 'newsfeed.js'.\n- Added ability to define \"the day after tomorrow\" for calendar events (Definition for German and Dutch already included).\n- Added CII Badge (we are compliant with the CII Best Practices)\n- Add support for doing http basic auth when loading calendars\n- Add the ability to turn off and on the date display in the Clock Module\n\n### Fixed\n\n- Fix typo in installer.\n- Add message to unsupported Pi error to mention that Pi Zeros must use server only mode, as ARMv6 is unsupported. Closes #374.\n- Fix API url for weather API.\n\n### Updated\n\n- Force fullscreen when kioskmode is active.\n- Updated the .github templates and information with more modern information.\n- Updated the Gruntfile with a more functional StyleLint implementation.\n\n## [2.0.4] - 2016-08-07\n\n### Added\n\n- Brazilian Portuguese Translation.\n- Option to enable Kiosk mode.\n- Added ability to start the app with Dev Tools.\n- Added ability to turn off the date display in `clock.js` when in analog mode.\n- Greek Translation\n\n### Fixed\n\n- Prevent `getModules()` selectors from returning duplicate entries.\n- Append endpoints of weather modules with `/` to retrieve the correct data. (Issue [#337](https://github.com/MagicMirrorOrg/MagicMirror/issues/337))\n- Corrected grammar in `module.js` from 'suspend' to 'suspended'.\n- Fixed openweathermap.org URL in config sample.\n- Prevent currentweather module from crashing when received data object is incorrect.\n- Fix issue where translation loading prevented the UI start-up when the language was set to 'en'. (Issue [#388](https://github.com/MagicMirrorOrg/MagicMirror/issues/388))\n\n### Updated\n\n- Updated package.json to fix possible vulnerabilities. (Using Snyk)\n- Updated weathericons\n- Updated default weatherforecast to work with the new icons.\n- More detailed error message in case config file couldn't be loaded.\n\n## [2.0.3] - 2016-07-12\n\n### Added\n\n- Add max newsitems parameter to the newsfeed module.\n- Translations for Simplified Chinese, Traditional Chinese and Japanese.\n- Polish Translation\n- Add an analog clock in addition to the digital one.\n\n### Fixed\n\n- Edit Alert Module to display title & message if they are provided in the notification (Issue [#300](https://github.com/MagicMirrorOrg/MagicMirror/issues/300))\n- Removed 'null' reference from updateModuleContent(). This fixes recent Edge and Internet Explorer browser displays (Issue [#319](https://github.com/MagicMirrorOrg/MagicMirror/issues/319))\n\n### Changed\n\n- Added default string to calendar titleReplace.\n\n## [2.0.2] - 2016-06-05\n\n### Added\n\n- Norwegian Translations (nb and nn)\n- Portuguese Translation\n- Swedish Translation\n\n### Fixed\n\n- Added reference to Italian Translation.\n- Added the missing NE translation to all languages. [#344](https://github.com/MagicMirrorOrg/MagicMirror/issues/344)\n- Added proper User-Agent string to calendar call.\n\n### Changed\n\n- Add option to use locationID in weather modules.\n\n## [2.0.1] - 2016-05-18\n\n### Added\n\n- Changelog\n- Italian Translation\n\n### Changed\n\n- Improve the installer by fetching the latest Node.js without any 3rd party interferences.\n\n## [2.0.0] - 2016-05-03\n\n### Initial release of MagicMirror²\n\nIt includes (but is not limited to) the following features:\n\n- Modular system allowing 3rd party plugins.\n- An Node/Electron based application taking away the need for external servers or browsers.\n- A complete development API documentation.\n- Small cute fairies that kiss you while you sleep.\n\n## [1.0.0] - 2014-02-16\n\n### Initial release of MagicMirror\n\nThis was part of the blogpost: [https://michaelteeuw.nl/post/83916869600/magic-mirror-part-vi-production-of-the](https://michaelteeuw.nl/post/83916869600/magic-mirror-part-vi-production-of-the)\n\n[2.33.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.32.0...v2.33.0\n[2.32.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.31.0...v2.32.0\n[2.31.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.30.0...v2.31.0\n[2.30.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.29.0...v2.30.0\n[2.29.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.28.0...v2.29.0\n[2.28.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.27.0...v2.28.0\n[2.27.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.26.0...v2.27.0\n[2.26.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.25.0...v2.26.0\n[2.25.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.24.0...v2.25.0\n[2.24.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.23.0...v2.24.0\n[2.23.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.22.0...v2.23.0\n[2.22.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.21.0...v2.22.0\n[2.21.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.20.0...v2.21.0\n[2.20.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.19.0...v2.20.0\n[2.19.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.18.0...v2.19.0\n[2.18.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.17.1...v2.18.0\n[2.17.1]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.17.0...v2.17.1\n[2.17.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.16.0...v2.17.0\n[2.16.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.15.0...v2.16.0\n[2.15.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.14.0...v2.15.0\n[2.14.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.13.0...v2.14.0\n[2.13.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.12.0...v2.13.0\n[2.12.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.11.0...v2.12.0\n[2.11.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.10.1...v2.11.0\n[2.10.1]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.10.0...v2.10.1\n[2.10.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.9.0...v2.10.0\n[2.9.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.8.0...v2.9.0\n[2.8.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.7.1...v2.8.0\n[2.7.1]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.7.0...v2.7.1\n[2.7.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.6.0...v2.7.0\n[2.6.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.5.0...v2.6.0\n[2.5.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.4.1...v2.5.0\n[2.4.1]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.4.0...v2.4.1\n[2.4.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.3.1...v2.4.0\n[2.3.1]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.3.0...v2.3.1\n[2.3.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.2.2...v2.3.0\n[2.2.2]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.2.1...v2.2.2\n[2.2.1]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.2.0...v2.2.1\n[2.2.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.1.3...v2.2.0\n[2.1.3]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.1.2...v2.1.3\n[2.1.2]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.1.1...v2.1.2\n[2.1.1]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.1.0...v2.1.1\n[2.1.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.0.5...v2.1.0\n[2.0.5]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.0.4...v2.0.5\n[2.0.4]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.0.3...v2.0.4\n[2.0.3]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.0.2...v2.0.3\n[2.0.2]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.0.1...v2.0.2\n[2.0.1]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.0.0...v2.0.1\n[2.0.0]: https://github.com/MagicMirrorOrg/MagicMirror/releases/tag/v2.0.0\n"
  },
  {
    "path": "Collaboration.md",
    "content": "# Collaboration\n\nThis document describes how collaborators of this repository should work together.\n\n## Pull Requests\n\n- never merge your own PR's\n- never merge without someone having approved (approving and merging from same person is allowed)\n- wait for all approvals requested (or the author decides something different in the comments)\n- merge to `master` only for releases or other urgent issues (update notification is only triggered by tags)\n- merges to master should be tagged with the \"mastermerge\" label so that the test runs through\n\n## Issues\n\n- \"real\" Issues are closed if the problem is solved and the fix is released\n- unrelated Issues (e.g. related to a foreign module) are closed immediately with a comment to open an issue in the module repository or to discuss this further in the forum or discord\n\n## Releases\n\nAre done by\n\n- [ ] @rejas\n- [ ] @sdetweil\n- [ ] @khassel\n- [ ] @KristjanESPERANTO\n\n### Pre-Deployment steps\n\n- [ ] update dependencies (a few days before)\n\n### Deployment steps\n\n- [ ] pull latest `develop` branch\n- [ ] create `prep-release` branch from `develop`\n  - [ ] update `package.json` and `package-lock.json` to reflect correct version number `2.xx.0`\n  - [ ] test `prep-release` branch\n  - [ ] commit and push all changes\n  - [ ] create pull request from `prep-release` to `develop` branch with title `Prepare Release 2.xx.0`\n  - [ ] after successful test run via github actions: merge pull request to `develop`\n- [ ] review the content of the automatically generated draft release named `unreleased`\n  - [ ] check contributor names\n  - [ ] check auto generated min. node version and adjust it for better readability if necessary\n  - [ ] check if all elements are assigned to the correct category\n  - [ ] change release name to `v2.xx.0`\n- [ ] after successful test run via github actions: create pull request from `develop` to `master` branch\n  - [ ] add label `mastermerge`\n  - [ ] title of the PR is `Release 2.xx.0`\n  - [ ] description of the PR is the body of the draft release with name `v2.xx.0`\n- [ ] after PR tests run without issues, merge PR\n- [ ] edit draft release with name `v2.xx.0`\n  - [ ] set corresponding version tag `v2.xx.0` (with `Select tag` and then `Create new tag`)\n  - [ ] update release link in `Compare to previous Release` by replacing `develop` with new tag `v2.xx.0`\n  - [ ] publish the release (button at the bottom)\n\n### Draft new development release\n\n- [ ] checkout `develop` branch\n- [ ] update `package.json` and `package-lock.json` to reflect correct version number `2.xx.0-develop`\n- [ ] commit and push `develop` branch\n- [ ] if new release will be in January, update the year in LICENSE.md\n\n### After release\n\n- [ ] publish release notes with link to github release on forum in new locked topic\n- [ ] close all issues with label `ready (coming with next release)`\n- [ ] release new documentation by merging `develop` on `master` in documentation repository\n- [ ] publish new version on [npm](https://www.npmjs.com/package/magicmirror)\n  - [ ] use a clean environment (e.g. container)\n  - [ ] clone this repository with the new `master` branch and `cd` into the local repository directory\n  - [ ] log in to npm with `npm login --auth-type legacy` which will ask for username and password and one-time-password which is sent via mail\n  - [ ] execute `npm publish`\n"
  },
  {
    "path": "LICENSE.md",
    "content": "# The MIT License (MIT)\n\nCopyright © 2016-2026 Michael Teeuw\n\nPermission is hereby granted, free of charge, to any person\nobtaining a copy of this software and associated documentation\nfiles (the “Software”), to deal in the Software without\nrestriction, including without limitation the rights to use,\ncopy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the\nSoftware is furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\n**The software is provided “as is”, without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose and noninfringement. In no event shall the authors or copyright holders be liable for any claim, damages or other liability, whether in an action of contract, tort or otherwise, arising from, out of or in connection with the software or the use or other dealings in the software.**\n"
  },
  {
    "path": "README.md",
    "content": "# ![MagicMirror²: The open source modular smart mirror platform.](.github/header.png)\n\n<p style=\"text-align: center\">\n  <a href=\"https://choosealicense.com/licenses/mit\">\n  <img src=\"https://img.shields.io/badge/license-MIT-blue.svg\" alt=\"License\">\n </a>\n <img src=\"https://img.shields.io/github/actions/workflow/status/magicmirrororg/magicmirror/automated-tests.yaml\" alt=\"GitHub Actions\">\n <img src=\"https://img.shields.io/github/check-runs/magicmirrororg/magicmirror/master\" alt=\"Build Status\">\n <a href=\"https://github.com/MagicMirrorOrg/MagicMirror\">\n  <img src=\"https://img.shields.io/github/stars/magicmirrororg/magicmirror?style=social\" alt=\"GitHub Stars\">\n </a>\n</p>\n\n**MagicMirror²** is an open source modular smart mirror platform. With a growing list of installable modules, the **MagicMirror²** allows you to convert your hallway or bathroom mirror into your personal assistant. **MagicMirror²** is built by the creator of [the original MagicMirror](https://michaelteeuw.nl/tagged/magicmirror) with the incredible help of a [growing community of contributors](https://github.com/MagicMirrorOrg/MagicMirror/graphs/contributors).\n\nMagicMirror² focuses on a modular plugin system and uses [Electron](https://www.electronjs.org/) as an application wrapper. So no more web server or browser installs necessary!\n\n![Animated demonstration of MagicMirror²](https://magicmirror.builders/img/demo.gif)\n\n## Documentation\n\nFor the full documentation including **[installation instructions](https://docs.magicmirror.builders/getting-started/installation.html)**, please visit our dedicated documentation website: [https://docs.magicmirror.builders](https://docs.magicmirror.builders).\n\n## Links\n\n- Website: [https://magicmirror.builders](https://magicmirror.builders)\n- Documentation: [https://docs.magicmirror.builders](https://docs.magicmirror.builders)\n- Forum: [https://forum.magicmirror.builders](https://forum.magicmirror.builders)\n  - Technical discussions: <https://forum.magicmirror.builders/category/11/core-system>\n- Discord: [https://discord.gg/J5BAtvx](https://discord.gg/J5BAtvx)\n- Blog: [https://michaelteeuw.nl/tagged/magicmirror](https://michaelteeuw.nl/tagged/magicmirror)\n- Donations: [https://magicmirror.builders/#donate](https://magicmirror.builders/#donate)\n\n## Contributing Guidelines\n\nContributions of all kinds are welcome, not only in the form of code but also with regards to\n\n- bug reports\n- documentation\n- translations\n\nFor the full contribution guidelines, check out: [https://docs.magicmirror.builders/about/contributing.html](https://docs.magicmirror.builders/about/contributing.html)\n\n## Enjoying MagicMirror? Consider a donation!\n\nMagicMirror² is Open Source and free. That doesn't mean we don't need any money.\n\nPlease consider a donation to help us cover the ongoing costs like webservers and email services.\nIf we receive enough donations we might even be able to free up some working hours and spend some extra time improving the MagicMirror² core.\n\nTo donate, please follow [this](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=G5D8E9MR5DTD2&source=url) link.\n\n<p style=\"text-align: center\">\n  <a href=\"https://forum.magicmirror.builders/topic/728/magicmirror-is-voted-number-1-in-the-magpi-top-50\"><img src=\"https://magicmirror.builders/img/magpi-best-watermark-custom.png\" width=\"150\" alt=\"MagPi Top 50\"></a>\n</p>\n"
  },
  {
    "path": "clientonly/index.js",
    "content": "\"use strict\";\n\n// Use separate scope to prevent global scope pollution\n(function () {\n\tconst config = {};\n\n\t/**\n\t * Helper function to get server address/hostname from either the commandline or env\n\t */\n\tfunction getServerAddress () {\n\n\t\t/**\n\t\t * Get command line parameters\n\t\t * Assumes that a cmdline parameter is defined with `--key [value]`\n\t\t * @param {string} key key to look for at the command line\n\t\t * @param {string} defaultValue value if no key is given at the command line\n\t\t * @returns {string} the value of the parameter\n\t\t */\n\t\tfunction getCommandLineParameter (key, defaultValue = undefined) {\n\t\t\tconst index = process.argv.indexOf(`--${key}`);\n\t\t\tconst value = index > -1 ? process.argv[index + 1] : undefined;\n\t\t\treturn value !== undefined ? String(value) : defaultValue;\n\t\t}\n\n\t\t// Prefer command line arguments over environment variables\n\t\t[\"address\", \"port\"].forEach((key) => {\n\t\t\tconfig[key] = getCommandLineParameter(key, process.env[key.toUpperCase()]);\n\t\t});\n\n\t\t// determine if \"--use-tls\"-flag was provided\n\t\tconfig.tls = process.argv.indexOf(\"--use-tls\") > 0;\n\t}\n\n\t/**\n\t * Gets the config from the specified server url\n\t * @param {string} url location where the server is running.\n\t * @returns {Promise} the config\n\t */\n\tfunction getServerConfig (url) {\n\t\t// Return new pending promise\n\t\treturn new Promise((resolve, reject) => {\n\t\t\t// Select http or https module, depending on requested url\n\t\t\tconst lib = url.startsWith(\"https\") ? require(\"node:https\") : require(\"node:http\");\n\t\t\tconst request = lib.get(url, (response) => {\n\t\t\t\tlet configData = \"\";\n\n\t\t\t\t// Gather incoming data\n\t\t\t\tresponse.on(\"data\", function (chunk) {\n\t\t\t\t\tconfigData += chunk;\n\t\t\t\t});\n\t\t\t\t// Resolve promise at the end of the HTTP/HTTPS stream\n\t\t\t\tresponse.on(\"end\", function () {\n\t\t\t\t\tresolve(JSON.parse(configData));\n\t\t\t\t});\n\t\t\t});\n\n\t\t\trequest.on(\"error\", function (error) {\n\t\t\t\treject(new Error(`Unable to read config from server (${url} (${error.message}`));\n\t\t\t});\n\t\t});\n\t}\n\n\t/**\n\t * Print a message to the console in case of errors\n\t * @param {string} message error message to print\n\t * @param {number} code error code for the exit call\n\t */\n\tfunction fail (message, code = 1) {\n\t\tif (message !== undefined && typeof message === \"string\") {\n\t\t\tconsole.log(message);\n\t\t} else {\n\t\t\tconsole.log(\"Usage: 'node clientonly --address 192.168.1.10 --port 8080 [--use-tls]'\");\n\t\t}\n\t\tprocess.exit(code);\n\t}\n\n\tgetServerAddress();\n\n\t(config.address && config.port) || fail();\n\tconst prefix = config.tls ? \"https://\" : \"http://\";\n\n\t// Only start the client if a non-local server was provided\n\tif ([\"localhost\", \"127.0.0.1\", \"::1\", \"::ffff:127.0.0.1\", undefined].indexOf(config.address) === -1) {\n\t\tgetServerConfig(`${prefix}${config.address}:${config.port}/config/`)\n\t\t\t.then(function (configReturn) {\n\t\t\t\t// check environment for DISPLAY or WAYLAND_DISPLAY\n\t\t\t\tconst elecParams = [\"js/electron.js\"];\n\t\t\t\tif (process.env.WAYLAND_DISPLAY) {\n\t\t\t\t\tconsole.log(`Client: Using WAYLAND_DISPLAY=${process.env.WAYLAND_DISPLAY}`);\n\t\t\t\t\telecParams.push(\"--enable-features=UseOzonePlatform\");\n\t\t\t\t\telecParams.push(\"--ozone-platform=wayland\");\n\t\t\t\t} else if (process.env.DISPLAY) {\n\t\t\t\t\tconsole.log(`Client: Using DISPLAY=${process.env.DISPLAY}`);\n\t\t\t\t} else {\n\t\t\t\t\tfail(\"Error: Requires environment variable WAYLAND_DISPLAY or DISPLAY, none is provided.\");\n\t\t\t\t}\n\t\t\t\t// Pass along the server config via an environment variable\n\t\t\t\tconst env = Object.create(process.env);\n\t\t\t\tenv.clientonly = true; // set to pass to electron.js\n\t\t\t\tconst options = { env: env };\n\t\t\t\tconfigReturn.address = config.address;\n\t\t\t\tconfigReturn.port = config.port;\n\t\t\t\tconfigReturn.tls = config.tls;\n\t\t\t\tenv.config = JSON.stringify(configReturn);\n\n\t\t\t\t// Spawn electron application\n\t\t\t\tconst electron = require(\"electron\");\n\t\t\t\tconst child = require(\"node:child_process\").spawn(electron, elecParams, options);\n\n\t\t\t\t// Pipe all child process output to current stdout\n\t\t\t\tchild.stdout.on(\"data\", function (buf) {\n\t\t\t\t\tprocess.stdout.write(`Client: ${buf}`);\n\t\t\t\t});\n\n\t\t\t\t// Pipe all child process errors to current stderr\n\t\t\t\tchild.stderr.on(\"data\", function (buf) {\n\t\t\t\t\tprocess.stderr.write(`Client: ${buf}`);\n\t\t\t\t});\n\n\t\t\t\tchild.on(\"error\", function (err) {\n\t\t\t\t\tprocess.stdout.write(`Client: ${err}`);\n\t\t\t\t});\n\n\t\t\t\tchild.on(\"close\", (code) => {\n\t\t\t\t\tif (code !== 0) {\n\t\t\t\t\t\tconsole.log(`There something wrong. The clientonly is not running code ${code}`);\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t})\n\t\t\t.catch(function (reason) {\n\t\t\t\tfail(`Unable to connect to server: (${reason})`);\n\t\t\t});\n\t} else {\n\t\tfail();\n\t}\n}());\n"
  },
  {
    "path": "cspell.config.json",
    "content": "{\n\t\"version\": \"0.2\",\n\t\"language\": \"en\",\n\t\"words\": [\n\t\t\"aarch\",\n\t\t\"Adak\",\n\t\t\"Alvinger\",\n\t\t\"Ampio\",\n\t\t\"andrezibaia\",\n\t\t\"angeldeejay\",\n\t\t\"apikey\",\n\t\t\"apiontek\",\n\t\t\"armv\",\n\t\t\"ashishtank\",\n\t\t\"autoplay\",\n\t\t\"Autorestart\",\n\t\t\"beada\",\n\t\t\"Behaviour\",\n\t\t\"Binney\",\n\t\t\"bluemanos\",\n\t\t\"bnitkin\",\n\t\t\"bokmål\",\n\t\t\"bouncyflip\",\n\t\t\"boxspinner\",\n\t\t\"Brasileiro\",\n\t\t\"Brento\",\n\t\t\"browserwindow\",\n\t\t\"bryanzzhu\",\n\t\t\"btoconnor\",\n\t\t\"bughaver\",\n\t\t\"bugsounet\",\n\t\t\"buxxi\",\n\t\t\"byday\",\n\t\t\"calcage\",\n\t\t\"calendarfetcher\",\n\t\t\"calendarfetcherutils\",\n\t\t\"calendarutils\",\n\t\t\"calevents\",\n\t\t\"chamakura\",\n\t\t\"Citypage\",\n\t\t\"cjbrunner\",\n\t\t\"clearsky\",\n\t\t\"clientonly\",\n\t\t\"clockfaces\",\n\t\t\"cloudcover\",\n\t\t\"cmdline\",\n\t\t\"codac\",\n\t\t\"Codrops\",\n\t\t\"cornerexpand\",\n\t\t\"Crazylegstoo\",\n\t\t\"crazyscot\",\n\t\t\"Creepin\",\n\t\t\"currentweather\",\n\t\t\"CUSTOMCSS\",\n\t\t\"customregions\",\n\t\t\"cxmj\",\n\t\t\"Cymraeg\",\n\t\t\"dariom\",\n\t\t\"darksky\",\n\t\t\"dataheaders\",\n\t\t\"Datamart\",\n\t\t\"dateheader\",\n\t\t\"dateheaders\",\n\t\t\"datekey\",\n\t\t\"dathbe\",\n\t\t\"davide\",\n\t\t\"DAYAFTERTOMORROW\",\n\t\t\"DAYBEFOREYESTERDAY\",\n\t\t\"defaultmodules\",\n\t\t\"Deificit\",\n\t\t\"Descr\",\n\t\t\"dewpoint\",\n\t\t\"dgoth\",\n\t\t\"difflink\",\n\t\t\"dismissttl\",\n\t\t\"Displayer\",\n\t\t\"dkallen\",\n\t\t\"drivelist\",\n\t\t\"DTEND\",\n\t\t\"DTSTAMP\",\n\t\t\"DTSTART\",\n\t\t\"Duffman\",\n\t\t\"earlman\",\n\t\t\"easyas\",\n\t\t\"eddiehung\",\n\t\t\"Edgardos\",\n\t\t\"Ekristoffe\",\n\t\t\"elec\",\n\t\t\"elif\",\n\t\t\"eltociear\",\n\t\t\"endfor\",\n\t\t\"endmacro\",\n\t\t\"envcanada\",\n\t\t\"envsub\",\n\t\t\"envsubst\",\n\t\t\"eouia\",\n\t\t\"Evapotranspration\",\n\t\t\"exdate\",\n\t\t\"exdates\",\n\t\t\"expectedheaders\",\n\t\t\"exploader\",\n\t\t\"ezeholz\",\n\t\t\"Fadesteps\",\n\t\t\"Faizan\",\n\t\t\"feedme\",\n\t\t\"feelslike\",\n\t\t\"Fenner\",\n\t\t\"Feuchte\",\n\t\t\"fewieden\",\n\t\t\"fixuppm\",\n\t\t\"flopp\",\n\t\t\"fontawesome\",\n\t\t\"fontface\",\n\t\t\"forecastweather\",\n\t\t\"fortawesome\",\n\t\t\"frameguard\",\n\t\t\"freezinglevel\",\n\t\t\"Frysk\",\n\t\t\"fullarticle\",\n\t\t\"fulldate\",\n\t\t\"fullday\",\n\t\t\"fullscreen\",\n\t\t\"geraki\",\n\t\t\"Gevoelstemperatuur\",\n\t\t\"GHSA\",\n\t\t\"ghsas\",\n\t\t\"grenagit\",\n\t\t\"Halfclear\",\n\t\t\"heavyrain\",\n\t\t\"heavyrainandthunder\",\n\t\t\"heavyrainshowers\",\n\t\t\"heavyrainshowersandthunder\",\n\t\t\"heavysleet\",\n\t\t\"heavysleetshowersandthunder\",\n\t\t\"heavysnow\",\n\t\t\"heavysnowandthunder\",\n\t\t\"Heiko\",\n\t\t\"Hirschberger\",\n\t\t\"hourlyweather\",\n\t\t\"humidex\",\n\t\t\"Hwind\",\n\t\t\"ical\",\n\t\t\"illimarkangur\",\n\t\t\"Ingan\",\n\t\t\"ipfilter\",\n\t\t\"ismarslomic\",\n\t\t\"jakemulley\",\n\t\t\"jakobsarwary\",\n\t\t\"jalibu\",\n\t\t\"jargordon\",\n\t\t\"jetson\",\n\t\t\"jkriegshauser\",\n\t\t\"jsdocs\",\n\t\t\"jsonlint\",\n\t\t\"jupadin\",\n\t\t\"kaennchenstruggle\",\n\t\t\"Kalenderwoche\",\n\t\t\"kenzal\",\n\t\t\"Keyport\",\n\t\t\"khassel\",\n\t\t\"Kingdon\",\n\t\t\"kioskmode\",\n\t\t\"klaernie\",\n\t\t\"kleinmantara\",\n\t\t\"Kmph\",\n\t\t\"Knapoc\",\n\t\t\"Koepke\",\n\t\t\"kolbyjack\",\n\t\t\"Komplex\",\n\t\t\"krekos\",\n\t\t\"Kristjan\",\n\t\t\"krukle\",\n\t\t\"labwc\",\n\t\t\"Landis\",\n\t\t\"larryare\",\n\t\t\"Lastberechnung\",\n\t\t\"letsencrypt\",\n\t\t\"libgpiod\",\n\t\t\"Lightspeed\",\n\t\t\"loadingcircle\",\n\t\t\"locationforecast\",\n\t\t\"lockstring\",\n\t\t\"lstrip\",\n\t\t\"Luciella\",\n\t\t\"luxon\",\n\t\t\"lxsession\",\n\t\t\"magicmirror\",\n\t\t\"martingron\",\n\t\t\"marvai\",\n\t\t\"mastermerge\",\n\t\t\"matchtype\",\n\t\t\"maxentries\",\n\t\t\"Meteo\",\n\t\t\"michaelteeuw\",\n\t\t\"michmich\",\n\t\t\"Midori\",\n\t\t\"mirontoli\",\n\t\t\"MISSINGLANG\",\n\t\t\"mixasgr\",\n\t\t\"MMPM\",\n\t\t\"modernizr\",\n\t\t\"modulename\",\n\t\t\"multiday\",\n\t\t\"Mystara\",\n\t\t\"Ñandú\",\n\t\t\"nathannaveen\",\n\t\t\"naveensrinivasan\",\n\t\t\"nbsp\",\n\t\t\"ndom\",\n\t\t\"Nerfzooka\",\n\t\t\"NEWSFEED\",\n\t\t\"newsfeedfetcher\",\n\t\t\"newsfetcher\",\n\t\t\"newsitems\",\n\t\t\"nfogal\",\n\t\t\"njwilliams\",\n\t\t\"nonrepeating\",\n\t\t\"Norsk\",\n\t\t\"nunjuck\",\n\t\t\"odroid\",\n\t\t\"oemel\",\n\t\t\"oldconfig\",\n\t\t\"onecall\",\n\t\t\"onevent\",\n\t\t\"openmeteo\",\n\t\t\"openmeto\",\n\t\t\"openweathermap\",\n\t\t\"oraclesean\",\n\t\t\"oscarb\",\n\t\t\"pcat\",\n\t\t\"philnagel\",\n\t\t\"pirateweather\",\n\t\t\"plained\",\n\t\t\"plebcity\",\n\t\t\"pmax\",\n\t\t\"pmean\",\n\t\t\"pmedian\",\n\t\t\"pmin\",\n\t\t\"Português\",\n\t\t\"PRECIP\",\n\t\t\"Problema\",\n\t\t\"psieg\",\n\t\t\"pubdate\",\n\t\t\"radokristof\",\n\t\t\"rajniszp\",\n\t\t\"rebuilded\",\n\t\t\"Reis\",\n\t\t\"rejas\",\n\t\t\"relativehumidity\",\n\t\t\"Resig\",\n\t\t\"roboto\",\n\t\t\"rohitdharavath\",\n\t\t\"Rosso\",\n\t\t\"Rothfusz\",\n\t\t\"rrule\",\n\t\t\"savvadam\",\n\t\t\"sdetweil\",\n\t\t\"searchstr\",\n\t\t\"sendheaders\",\n\t\t\"serveronly\",\n\t\t\"sexualized\",\n\t\t\"Sitecode\",\n\t\t\"skpanagiotis\",\n\t\t\"SMHI\",\n\t\t\"Snille\",\n\t\t\"snowandthunder\",\n\t\t\"snowshowersandthunder\",\n\t\t\"socketclient\",\n\t\t\"socketio\",\n\t\t\"spectron\",\n\t\t\"Starinvest\",\n\t\t\"stationid\",\n\t\t\"STEADMAN\",\n\t\t\"sthuber\",\n\t\t\"Stieber\",\n\t\t\"strinner\",\n\t\t\"stylelintrc\",\n\t\t\"sunaction\",\n\t\t\"suncalc\",\n\t\t\"suntimes\",\n\t\t\"symboltest\",\n\t\t\"systeminformation\",\n\t\t\"tada\",\n\t\t\"taglist\",\n\t\t\"Teeuw\",\n\t\t\"Teil\",\n\t\t\"TESTMODE\",\n\t\t\"thomasrockhu\",\n\t\t\"thumbslider\",\n\t\t\"timeformat\",\n\t\t\"titlereplacestr\",\n\t\t\"titlesearchstr\",\n\t\t\"todaytemp\",\n\t\t\"tomzt\",\n\t\t\"trunc\",\n\t\t\"ttlms\",\n\t\t\"ukmetoffice\",\n\t\t\"ukmetofficedatahub\",\n\t\t\"unitless\",\n\t\t\"unixtime\",\n\t\t\"unparseable\",\n\t\t\"updatenotification\",\n\t\t\"uxdt\",\n\t\t\"Vaice\",\n\t\t\"veeck\",\n\t\t\"verjaardag\",\n\t\t\"VEVENT\",\n\t\t\"vgtu\",\n\t\t\"Vitest\",\n\t\t\"Voelt\",\n\t\t\"Vorberechnung\",\n\t\t\"vppencilsharpener\",\n\t\t\"Wallys\",\n\t\t\"Weatherbit\",\n\t\t\"weathercode\",\n\t\t\"WEATHERDATA\",\n\t\t\"Weatherflow\",\n\t\t\"weatherforecast\",\n\t\t\"weathergov\",\n\t\t\"weathericon\",\n\t\t\"weathericons\",\n\t\t\"weatherobject\",\n\t\t\"weatherprovider\",\n\t\t\"weatherutils\",\n\t\t\"webcal\",\n\t\t\"winddirection\",\n\t\t\"windgusts\",\n\t\t\"windspeed\",\n\t\t\"Woolridge\",\n\t\t\"worktree\",\n\t\t\"Wsymb\",\n\t\t\"xlarge\",\n\t\t\"xmark\",\n\t\t\"xrandr\",\n\t\t\"xsmall\",\n\t\t\"xsorifc\",\n\t\t\"xwindows\",\n\t\t\"xxxe\",\n\t\t\"Ybbet\",\n\t\t\"yearmatch\",\n\t\t\"yearmatchgroup\"\n\t],\n\t\"ignorePaths\": [\n\t\t\"css/roboto.css\",\n\t\t\"node_modules/**\",\n\t\t\"modules/!(default)/**\",\n\t\t\"modules/default/**/translations/!(en).json\",\n\t\t\"modules/default/calendar/windowsZones.json\",\n\t\t\"modules/default/clock/faces/*.svg\",\n\t\t\"modules/default/weather/providers/yr.js\",\n\t\t\"tests/mocks/**\",\n\t\t\"tests/e2e/modules/clock_es_spec.js\",\n\t\t\"translations/**\"\n\t],\n\t\"dictionaries\": [\"node\"]\n}\n"
  },
  {
    "path": "css/custom.css.sample",
    "content": "/* Custom CSS Sample\n *\n * Change color and fonts here.\n *\n * Beware that properties cannot be unitless, so for example write '--gap-body: 0px;' instead of just '--gap-body: 0;'\n */\n\n/* Uncomment and adjust accordingly if you want to import another font from the google-fonts-api: */\n/* @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@100;300;400;700&display=swap'); */\n\n:root {\n  --color-text: #999;\n  --color-text-dimmed: #666;\n  --color-text-bright: #fff;\n  --color-background: black;\n\n  --font-primary: \"Roboto Condensed\";\n  --font-secondary: \"Roboto\";\n\n  --font-size: 20px;\n  --font-size-small: 0.75rem;\n\n  --gap-body-top: 60px;\n  --gap-body-right: 60px;\n  --gap-body-bottom: 60px;\n  --gap-body-left: 60px;\n\n  --gap-modules: 30px;\n}\n"
  },
  {
    "path": "css/font-awesome.css",
    "content": "@import url(\"../node_modules/@fortawesome/fontawesome-free/css/all.min.css\");\n@import url(\"../node_modules/@fortawesome/fontawesome-free/css/v4-shims.min.css\");\n"
  },
  {
    "path": "css/main.css",
    "content": ":root {\n  --color-text: #999;\n  --color-text-dimmed: #666;\n  --color-text-bright: #fff;\n  --color-background: #000;\n  --font-primary: \"Roboto Condensed\";\n  --font-secondary: \"Roboto\";\n  --font-size: 20px;\n  --font-size-xsmall: 0.75rem;\n  --font-size-small: 1rem;\n  --font-size-medium: 1.5rem;\n  --font-size-large: 3.25rem;\n  --font-size-xlarge: 3.75rem;\n  --gap-body-top: 60px;\n  --gap-body-right: 60px;\n  --gap-body-bottom: 60px;\n  --gap-body-left: 60px;\n  --gap-modules: 30px;\n}\n\nhtml {\n  cursor: none;\n  overflow: hidden;\n  background: var(--color-background);\n  user-select: none;\n  font-size: var(--font-size);\n}\n\n::-webkit-scrollbar {\n  display: none;\n}\n\nbody {\n  margin: var(--gap-body-top) var(--gap-body-right) var(--gap-body-bottom) var(--gap-body-left);\n  position: absolute;\n  height: calc(100% - var(--gap-body-top) - var(--gap-body-bottom));\n  width: calc(100% - var(--gap-body-right) - var(--gap-body-left));\n  background: var(--color-background);\n  color: var(--color-text);\n  font-family: var(--font-primary), sans-serif;\n  font-weight: 400;\n  line-height: 1.5;\n  -webkit-font-smoothing: antialiased;\n}\n\n/**\n * Default styles.\n */\n\n.dimmed {\n  color: var(--color-text-dimmed);\n}\n\n.normal {\n  color: var(--color-text);\n}\n\n.bright {\n  color: var(--color-text-bright);\n}\n\n.xsmall {\n  font-size: var(--font-size-xsmall);\n  line-height: 1.275;\n}\n\n.small {\n  font-size: var(--font-size-small);\n  line-height: 1.25;\n}\n\n.medium {\n  font-size: var(--font-size-medium);\n  line-height: 1.225;\n}\n\n.large {\n  font-size: var(--font-size-large);\n  line-height: 1;\n}\n\n.xlarge {\n  font-size: var(--font-size-xlarge);\n  line-height: 1;\n  letter-spacing: -3px;\n}\n\n.thin {\n  font-family: var(--font-secondary), sans-serif;\n  font-weight: 100;\n}\n\n.light {\n  font-family: var(--font-primary), sans-serif;\n  font-weight: 300;\n}\n\n.regular {\n  font-family: var(--font-primary), sans-serif;\n  font-weight: 400;\n}\n\n.bold {\n  font-family: var(--font-primary), sans-serif;\n  font-weight: 700;\n}\n\n.align-right {\n  text-align: right;\n}\n\n.align-left {\n  text-align: left;\n}\n\nheader {\n  text-transform: uppercase;\n  font-size: var(--font-size-xsmall);\n  font-family: var(--font-primary), Arial, Helvetica, sans-serif;\n  font-weight: 400;\n  border-bottom: 1px solid var(--color-text-dimmed);\n  line-height: 15px;\n  padding-bottom: 5px;\n  margin-bottom: 10px;\n  color: var(--color-text);\n}\n\nsup {\n  font-size: 50%;\n  line-height: 50%;\n}\n\n/**\n * Module styles.\n */\n\n.module {\n  margin-bottom: var(--gap-modules);\n}\n\n.module.hidden {\n  pointer-events: none;\n}\n\n.module:not(.hidden) {\n  pointer-events: auto;\n}\n\n.region.bottom .module {\n  margin-top: var(--gap-modules);\n  margin-bottom: 0;\n}\n\n.no-wrap {\n  white-space: nowrap;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n.pre-line {\n  white-space: pre-line;\n}\n\n/**\n * Region Definitions.\n */\n\n.region {\n  position: absolute;\n}\n\n.region.fullscreen {\n  position: absolute;\n  inset: calc(-1 * var(--gap-body-top)) calc(-1 * var(--gap-body-right)) calc(-1 * var(--gap-body-bottom)) calc(-1 * var(--gap-body-left));\n  pointer-events: none;\n}\n\n.region.right {\n  right: 0;\n  text-align: right;\n}\n\n.region.top {\n  top: 0;\n}\n\n.region.top.center,\n.region.bottom.center {\n  left: 50%;\n  transform: translateX(-50%);\n}\n\n.region.top.right,\n.region.top.left,\n.region.top.center {\n  top: 100%;\n}\n\n.region.bottom {\n  bottom: 0;\n}\n\n.region.bottom.right,\n.region.bottom.center,\n.region.bottom.left {\n  bottom: 100%;\n}\n\n.region.bar {\n  width: 100%;\n  text-align: center;\n}\n\n.region.third,\n.region.middle.center {\n  width: 100%;\n  text-align: center;\n  transform: translateY(-50%);\n}\n\n.region.upper.third {\n  top: 33%;\n}\n\n.region.middle.center {\n  top: 50%;\n}\n\n.region.lower.third {\n  top: 66%;\n}\n\n.region.left {\n  text-align: left;\n}\n\n.region table {\n  width: 100%;\n  border-spacing: 0;\n  border-collapse: separate;\n}\n\n/**\n * Container Definitions.\n */\n\n.region .container {\n  display: flex;\n  flex-direction: column;\n}\n\n.region .container.hidden {\n  display: none;\n}\n\n.region.left .flex {\n  justify-content: flex-start;\n}\n\n.region.center .flex {\n  justify-content: center;\n}\n\n.region.right .flex {\n  justify-content: flex-end;\n}\n"
  },
  {
    "path": "css/roboto.css",
    "content": "/* roboto-cyrillic-ext-100-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 100;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-100-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-100-normal.woff\") format(\"woff\");\n  unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;\n}\n\n/* roboto-cyrillic-100-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 100;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-cyrillic-100-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-cyrillic-100-normal.woff\") format(\"woff\");\n  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;\n}\n\n/* roboto-greek-ext-100-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 100;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-greek-ext-100-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-greek-ext-100-normal.woff\") format(\"woff\");\n  unicode-range: U+1F00-1FFF;\n}\n\n/* roboto-greek-100-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 100;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-greek-100-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-greek-100-normal.woff\") format(\"woff\");\n  unicode-range: U+0370-03FF;\n}\n\n/* roboto-vietnamese-100-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 100;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-vietnamese-100-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-vietnamese-100-normal.woff\") format(\"woff\");\n  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;\n}\n\n/* roboto-latin-ext-100-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 100;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-latin-ext-100-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-latin-ext-100-normal.woff\") format(\"woff\");\n  unicode-range: U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n\n/* roboto-latin-100-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 100;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-latin-100-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-latin-100-normal.woff\") format(\"woff\");\n  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n\n/* roboto-cyrillic-ext-300-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 300;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-300-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-300-normal.woff\") format(\"woff\");\n  unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;\n}\n\n/* roboto-cyrillic-300-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 300;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-cyrillic-300-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-cyrillic-300-normal.woff\") format(\"woff\");\n  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;\n}\n\n/* roboto-greek-ext-300-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 300;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-greek-ext-300-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-greek-ext-300-normal.woff\") format(\"woff\");\n  unicode-range: U+1F00-1FFF;\n}\n\n/* roboto-greek-300-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 300;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-greek-300-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-greek-300-normal.woff\") format(\"woff\");\n  unicode-range: U+0370-03FF;\n}\n\n/* roboto-vietnamese-300-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 300;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-vietnamese-300-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-vietnamese-300-normal.woff\") format(\"woff\");\n  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;\n}\n\n/* roboto-latin-ext-300-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 300;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-latin-ext-300-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-latin-ext-300-normal.woff\") format(\"woff\");\n  unicode-range: U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n\n/* roboto-latin-300-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 300;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-latin-300-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-latin-300-normal.woff\") format(\"woff\");\n  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n\n/* roboto-cyrillic-ext-400-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 400;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-400-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-400-normal.woff\") format(\"woff\");\n  unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;\n}\n\n/* roboto-cyrillic-400-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 400;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-cyrillic-400-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-cyrillic-400-normal.woff\") format(\"woff\");\n  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;\n}\n\n/* roboto-greek-ext-400-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 400;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-greek-ext-400-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-greek-ext-400-normal.woff\") format(\"woff\");\n  unicode-range: U+1F00-1FFF;\n}\n\n/* roboto-greek-400-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 400;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-greek-400-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-greek-400-normal.woff\") format(\"woff\");\n  unicode-range: U+0370-03FF;\n}\n\n/* roboto-vietnamese-400-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 400;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-vietnamese-400-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-vietnamese-400-normal.woff\") format(\"woff\");\n  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;\n}\n\n/* roboto-latin-ext-400-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 400;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-latin-ext-400-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-latin-ext-400-normal.woff\") format(\"woff\");\n  unicode-range: U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n\n/* roboto-latin-400-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 400;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-latin-400-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-latin-400-normal.woff\") format(\"woff\");\n  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n\n/* roboto-cyrillic-ext-500-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 500;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-500-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-500-normal.woff\") format(\"woff\");\n  unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;\n}\n\n/* roboto-cyrillic-500-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 500;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-cyrillic-500-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-cyrillic-500-normal.woff\") format(\"woff\");\n  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;\n}\n\n/* roboto-greek-ext-500-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 500;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-greek-ext-500-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-greek-ext-500-normal.woff\") format(\"woff\");\n  unicode-range: U+1F00-1FFF;\n}\n\n/* roboto-greek-500-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 500;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-greek-500-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-greek-500-normal.woff\") format(\"woff\");\n  unicode-range: U+0370-03FF;\n}\n\n/* roboto-vietnamese-500-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 500;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-vietnamese-500-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-vietnamese-500-normal.woff\") format(\"woff\");\n  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;\n}\n\n/* roboto-latin-ext-500-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 500;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-latin-ext-500-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-latin-ext-500-normal.woff\") format(\"woff\");\n  unicode-range: U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n\n/* roboto-latin-500-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 500;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-latin-500-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-latin-500-normal.woff\") format(\"woff\");\n  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n\n/* roboto-cyrillic-ext-700-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 700;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-700-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-700-normal.woff\") format(\"woff\");\n  unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;\n}\n\n/* roboto-cyrillic-700-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 700;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-cyrillic-700-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-cyrillic-700-normal.woff\") format(\"woff\");\n  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;\n}\n\n/* roboto-greek-ext-700-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 700;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-greek-ext-700-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-greek-ext-700-normal.woff\") format(\"woff\");\n  unicode-range: U+1F00-1FFF;\n}\n\n/* roboto-greek-700-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 700;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-greek-700-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-greek-700-normal.woff\") format(\"woff\");\n  unicode-range: U+0370-03FF;\n}\n\n/* roboto-vietnamese-700-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 700;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-vietnamese-700-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-vietnamese-700-normal.woff\") format(\"woff\");\n  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;\n}\n\n/* roboto-latin-ext-700-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 700;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-latin-ext-700-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-latin-ext-700-normal.woff\") format(\"woff\");\n  unicode-range: U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n\n/* roboto-latin-700-normal */\n@font-face {\n  font-family: Roboto;\n  font-style: normal;\n  font-display: swap;\n  font-weight: 700;\n  src:\n    url(\"../node_modules/@fontsource/roboto/files/roboto-latin-700-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto/files/roboto-latin-700-normal.woff\") format(\"woff\");\n  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n\n/* roboto-condensed-cyrillic-ext-300-normal */\n@font-face {\n  font-family: \"Roboto Condensed\";\n  font-style: normal;\n  font-display: swap;\n  font-weight: 300;\n  src:\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-ext-300-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-ext-300-normal.woff\") format(\"woff\");\n  unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;\n}\n\n/* roboto-condensed-cyrillic-300-normal */\n@font-face {\n  font-family: \"Roboto Condensed\";\n  font-style: normal;\n  font-display: swap;\n  font-weight: 300;\n  src:\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-300-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-300-normal.woff\") format(\"woff\");\n  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;\n}\n\n/* roboto-condensed-greek-ext-300-normal */\n@font-face {\n  font-family: \"Roboto Condensed\";\n  font-style: normal;\n  font-display: swap;\n  font-weight: 300;\n  src:\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-ext-300-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-ext-300-normal.woff\") format(\"woff\");\n  unicode-range: U+1F00-1FFF;\n}\n\n/* roboto-condensed-greek-300-normal */\n@font-face {\n  font-family: \"Roboto Condensed\";\n  font-style: normal;\n  font-display: swap;\n  font-weight: 300;\n  src:\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-300-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-300-normal.woff\") format(\"woff\");\n  unicode-range: U+0370-03FF;\n}\n\n/* roboto-condensed-vietnamese-300-normal */\n@font-face {\n  font-family: \"Roboto Condensed\";\n  font-style: normal;\n  font-display: swap;\n  font-weight: 300;\n  src:\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-vietnamese-300-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-vietnamese-300-normal.woff\") format(\"woff\");\n  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;\n}\n\n/* roboto-condensed-latin-ext-300-normal */\n@font-face {\n  font-family: \"Roboto Condensed\";\n  font-style: normal;\n  font-display: swap;\n  font-weight: 300;\n  src:\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-ext-300-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-ext-300-normal.woff\") format(\"woff\");\n  unicode-range: U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n\n/* roboto-condensed-latin-300-normal */\n@font-face {\n  font-family: \"Roboto Condensed\";\n  font-style: normal;\n  font-display: swap;\n  font-weight: 300;\n  src:\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-300-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-300-normal.woff\") format(\"woff\");\n  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n\n/* roboto-condensed-cyrillic-ext-400-normal */\n@font-face {\n  font-family: \"Roboto Condensed\";\n  font-style: normal;\n  font-display: swap;\n  font-weight: 400;\n  src:\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-ext-400-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-ext-400-normal.woff\") format(\"woff\");\n  unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;\n}\n\n/* roboto-condensed-cyrillic-400-normal */\n@font-face {\n  font-family: \"Roboto Condensed\";\n  font-style: normal;\n  font-display: swap;\n  font-weight: 400;\n  src:\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-400-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-400-normal.woff\") format(\"woff\");\n  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;\n}\n\n/* roboto-condensed-greek-ext-400-normal */\n@font-face {\n  font-family: \"Roboto Condensed\";\n  font-style: normal;\n  font-display: swap;\n  font-weight: 400;\n  src:\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-ext-400-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-ext-400-normal.woff\") format(\"woff\");\n  unicode-range: U+1F00-1FFF;\n}\n\n/* roboto-condensed-greek-400-normal */\n@font-face {\n  font-family: \"Roboto Condensed\";\n  font-style: normal;\n  font-display: swap;\n  font-weight: 400;\n  src:\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-400-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-400-normal.woff\") format(\"woff\");\n  unicode-range: U+0370-03FF;\n}\n\n/* roboto-condensed-vietnamese-400-normal */\n@font-face {\n  font-family: \"Roboto Condensed\";\n  font-style: normal;\n  font-display: swap;\n  font-weight: 400;\n  src:\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-vietnamese-400-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-vietnamese-400-normal.woff\") format(\"woff\");\n  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;\n}\n\n/* roboto-condensed-latin-ext-400-normal */\n@font-face {\n  font-family: \"Roboto Condensed\";\n  font-style: normal;\n  font-display: swap;\n  font-weight: 400;\n  src:\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-ext-400-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-ext-400-normal.woff\") format(\"woff\");\n  unicode-range: U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n\n/* roboto-condensed-latin-400-normal */\n@font-face {\n  font-family: \"Roboto Condensed\";\n  font-style: normal;\n  font-display: swap;\n  font-weight: 400;\n  src:\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-400-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-400-normal.woff\") format(\"woff\");\n  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n\n/* roboto-condensed-cyrillic-ext-700-normal */\n@font-face {\n  font-family: \"Roboto Condensed\";\n  font-style: normal;\n  font-display: swap;\n  font-weight: 700;\n  src:\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-ext-700-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-ext-700-normal.woff\") format(\"woff\");\n  unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;\n}\n\n/* roboto-condensed-cyrillic-700-normal */\n@font-face {\n  font-family: \"Roboto Condensed\";\n  font-style: normal;\n  font-display: swap;\n  font-weight: 700;\n  src:\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-700-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-700-normal.woff\") format(\"woff\");\n  unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;\n}\n\n/* roboto-condensed-greek-ext-700-normal */\n@font-face {\n  font-family: \"Roboto Condensed\";\n  font-style: normal;\n  font-display: swap;\n  font-weight: 700;\n  src:\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-ext-700-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-ext-700-normal.woff\") format(\"woff\");\n  unicode-range: U+1F00-1FFF;\n}\n\n/* roboto-condensed-greek-700-normal */\n@font-face {\n  font-family: \"Roboto Condensed\";\n  font-style: normal;\n  font-display: swap;\n  font-weight: 700;\n  src:\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-700-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-700-normal.woff\") format(\"woff\");\n  unicode-range: U+0370-03FF;\n}\n\n/* roboto-condensed-vietnamese-700-normal */\n@font-face {\n  font-family: \"Roboto Condensed\";\n  font-style: normal;\n  font-display: swap;\n  font-weight: 700;\n  src:\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-vietnamese-700-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-vietnamese-700-normal.woff\") format(\"woff\");\n  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;\n}\n\n/* roboto-condensed-latin-ext-700-normal */\n@font-face {\n  font-family: \"Roboto Condensed\";\n  font-style: normal;\n  font-display: swap;\n  font-weight: 700;\n  src:\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-ext-700-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-ext-700-normal.woff\") format(\"woff\");\n  unicode-range: U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n\n/* roboto-condensed-latin-700-normal */\n@font-face {\n  font-family: \"Roboto Condensed\";\n  font-style: normal;\n  font-display: swap;\n  font-weight: 700;\n  src:\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-700-normal.woff2\") format(\"woff2\"),\n    url(\"../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-700-normal.woff\") format(\"woff\");\n  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n"
  },
  {
    "path": "eslint.config.mjs",
    "content": "import {defineConfig, globalIgnores} from \"eslint/config\";\nimport globals from \"globals\";\nimport {flatConfigs as importX} from \"eslint-plugin-import-x\";\nimport js from \"@eslint/js\";\nimport jsdocPlugin from \"eslint-plugin-jsdoc\";\nimport packageJson from \"eslint-plugin-package-json\";\nimport playwright from \"eslint-plugin-playwright\";\nimport stylistic from \"@stylistic/eslint-plugin\";\nimport vitest from \"eslint-plugin-vitest\";\n\nexport default defineConfig([\n\tglobalIgnores([\"config/**\", \"modules/**/*\", \"!modules/default/**\", \"js/positions.js\"]),\n\t{\n\t\tfiles: [\"**/*.js\"],\n\t\tlanguageOptions: {\n\t\t\tecmaVersion: \"latest\",\n\t\t\tglobals: {\n\t\t\t\t...globals.browser,\n\t\t\t\t...globals.node,\n\t\t\t\t...vitest.environments.env.globals,\n\t\t\t\tLog: \"readonly\",\n\t\t\t\tMM: \"readonly\",\n\t\t\t\tModule: \"readonly\",\n\t\t\t\tconfig: \"readonly\",\n\t\t\t\tmoment: \"readonly\"\n\t\t\t}\n\t\t},\n\t\tplugins: {js, stylistic, vitest},\n\t\textends: [importX.recommended, vitest.configs.recommended, \"js/recommended\", jsdocPlugin.configs[\"flat/recommended\"], \"stylistic/all\"],\n\t\trules: {\n\t\t\t\"@stylistic/array-element-newline\": [\"error\", \"consistent\"],\n\t\t\t\"@stylistic/arrow-parens\": [\"error\", \"always\"],\n\t\t\t\"@stylistic/brace-style\": \"off\",\n\t\t\t\"@stylistic/comma-dangle\": [\"error\", \"never\"],\n\t\t\t\"@stylistic/dot-location\": [\"error\", \"property\"],\n\t\t\t\"@stylistic/function-call-argument-newline\": [\"error\", \"consistent\"],\n\t\t\t\"@stylistic/function-paren-newline\": [\"error\", \"consistent\"],\n\t\t\t\"@stylistic/implicit-arrow-linebreak\": [\"error\", \"beside\"],\n\t\t\t\"@stylistic/indent\": [\"error\", \"tab\"],\n\t\t\t\"@stylistic/max-statements-per-line\": [\"error\", {max: 2}],\n\t\t\t\"@stylistic/multiline-comment-style\": \"off\",\n\t\t\t\"@stylistic/multiline-ternary\": [\"error\", \"always-multiline\"],\n\t\t\t\"@stylistic/newline-per-chained-call\": [\"error\", {ignoreChainWithDepth: 4}],\n\t\t\t\"@stylistic/no-extra-parens\": \"off\",\n\t\t\t\"@stylistic/no-tabs\": \"off\",\n\t\t\t\"@stylistic/object-curly-spacing\": [\"error\", \"always\"],\n\t\t\t\"@stylistic/object-property-newline\": [\"error\", {allowAllPropertiesOnSameLine: true}],\n\t\t\t\"@stylistic/operator-linebreak\": [\"error\", \"before\"],\n\t\t\t\"@stylistic/padded-blocks\": \"off\",\n\t\t\t\"@stylistic/quote-props\": [\"error\", \"as-needed\"],\n\t\t\t\"@stylistic/quotes\": [\"error\", \"double\"],\n\t\t\t\"@stylistic/semi\": [\"error\", \"always\"],\n\t\t\t\"@stylistic/space-before-function-paren\": [\"error\", \"always\"],\n\t\t\t\"@stylistic/spaced-comment\": \"off\",\n\t\t\t\"dot-notation\": \"error\",\n\t\t\teqeqeq: \"error\",\n\t\t\t\"id-length\": \"off\",\n\t\t\t\"import-x/extensions\": \"error\",\n\t\t\t\"import-x/newline-after-import\": \"error\",\n\t\t\t\"import-x/order\": \"error\",\n\t\t\t\"init-declarations\": \"off\",\n\t\t\t\"vitest/consistent-test-it\": \"warn\",\n\t\t\t\"vitest/expect-expect\": [\n\t\t\t\t\"warn\",\n\t\t\t\t{\n\t\t\t\t\tassertFunctionNames: [\n\t\t\t\t\t\t\"expect\",\n\t\t\t\t\t\t\"testElementLength\",\n\t\t\t\t\t\t\"testTextContain\",\n\t\t\t\t\t\t\"doTest\",\n\t\t\t\t\t\t\"runAnimationTest\",\n\t\t\t\t\t\t\"waitForAnimationClass\",\n\t\t\t\t\t\t\"assertNoAnimationWithin\"\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"vitest/prefer-to-be\": \"warn\",\n\t\t\t\"vitest/prefer-to-have-length\": \"warn\",\n\t\t\t\"max-lines-per-function\": [\"warn\", 400],\n\t\t\t\"max-statements\": \"off\",\n\t\t\t\"no-global-assign\": \"off\",\n\t\t\t\"no-inline-comments\": \"off\",\n\t\t\t\"no-magic-numbers\": \"off\",\n\t\t\t\"no-param-reassign\": \"error\",\n\t\t\t\"no-plusplus\": \"off\",\n\t\t\t\"no-prototype-builtins\": \"off\",\n\t\t\t\"no-ternary\": \"off\",\n\t\t\t\"no-throw-literal\": \"error\",\n\t\t\t\"no-undefined\": \"off\",\n\t\t\t\"no-unneeded-ternary\": \"error\",\n\t\t\t\"no-unused-vars\": \"off\",\n\t\t\t\"no-useless-return\": \"error\",\n\t\t\t\"no-warning-comments\": \"off\",\n\t\t\t\"object-shorthand\": [\"error\", \"methods\"],\n\t\t\t\"one-var\": \"off\",\n\t\t\t\"prefer-template\": \"error\",\n\t\t\t\"sort-keys\": \"off\"\n\t\t}\n\t},\n\t{\n\t\tfiles: [\"**/*.js\"],\n\t\tignores: [\n\t\t\t\"clientonly/index.js\",\n\t\t\t\"js/logger.js\",\n\t\t\t\"tests/**/*.js\"\n\t\t],\n\t\trules: {\"no-console\": \"error\"}\n\t},\n\t{\n\t\tfiles: [\"**/package.json\"],\n\t\tplugins: {packageJson},\n\t\textends: [\"packageJson/recommended\"]\n\t},\n\t{\n\t\tfiles: [\"**/*.mjs\"],\n\t\tlanguageOptions: {\n\t\t\tecmaVersion: \"latest\",\n\t\t\tglobals: {\n\t\t\t\t...globals.node\n\t\t\t},\n\t\t\tsourceType: \"module\"\n\t\t},\n\t\tplugins: {js, stylistic},\n\t\textends: [importX.recommended, \"js/all\", \"stylistic/all\"],\n\t\trules: {\n\t\t\t\"@stylistic/array-element-newline\": \"off\",\n\t\t\t\"@stylistic/indent\": [\"error\", \"tab\"],\n\t\t\t\"@stylistic/object-property-newline\": [\"error\", {allowAllPropertiesOnSameLine: true}],\n\t\t\t\"@stylistic/padded-blocks\": [\"error\", \"never\"],\n\t\t\t\"@stylistic/quote-props\": [\"error\", \"as-needed\"],\n\t\t\t\"import-x/no-unresolved\": [\"error\", {ignore: [\"eslint/config\"]}],\n\t\t\t\"max-lines-per-function\": [\"error\", 100],\n\t\t\t\"no-magic-numbers\": \"off\",\n\t\t\t\"one-var\": [\"error\", \"never\"],\n\t\t\t\"sort-keys\": \"off\"\n\t\t}\n\t},\n\t{\n\t\tfiles: [\"tests/configs/modules/weather/*.js\"],\n\t\trules: {\n\t\t\t\"@stylistic/quotes\": \"off\"\n\t\t}\n\t},\n\t{\n\t\tfiles: [\"tests/e2e/**/*.js\"],\n\t\textends: [playwright.configs[\"flat/recommended\"]],\n\t\trules: {\n\t\t\t\"playwright/no-standalone-expect\": \"off\"\n\t\t}\n\t}\n]);\n"
  },
  {
    "path": "index.html",
    "content": "<!doctype html>\n<html>\n  <head>\n    <title>MagicMirror²</title>\n    <meta name=\"google\" content=\"notranslate\" />\n    <meta http-equiv=\"Content-type\" content=\"text/html; charset=utf-8\" />\n\n    <meta name=\"apple-mobile-web-app-capable\" content=\"yes\" />\n    <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black\" />\n    <meta name=\"format-detection\" content=\"telephone=no\" />\n    <meta name=\"mobile-web-app-capable\" content=\"yes\" />\n\n    <link rel=\"icon\" href=\"data:;base64,iVBORw0KGgo=\" />\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"css/main.css\" />\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"css/roboto.css\" />\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"node_modules/animate.css/animate.min.css\" />\n    <!-- custom.css is loaded by the loader.js to make sure it's loaded after the module css files. -->\n\n    <script type=\"text/javascript\">\n      window.mmVersion = \"#VERSION#\";\n      window.mmTestMode = \"#TESTMODE#\";\n    </script>\n  </head>\n  <body>\n    <div class=\"region fullscreen below\"><div class=\"container\"></div></div>\n    <div class=\"region top bar\">\n      <div class=\"container\"></div>\n      <div class=\"region top left\"><div class=\"container\"></div></div>\n      <div class=\"region top center\"><div class=\"container\"></div></div>\n      <div class=\"region top right\"><div class=\"container\"></div></div>\n    </div>\n    <div class=\"region upper third\"><div class=\"container\"></div></div>\n    <div class=\"region middle center\"><div class=\"container\"></div></div>\n    <div class=\"region lower third\">\n      <div class=\"container\"><br /></div>\n    </div>\n    <div class=\"region bottom bar\">\n      <div class=\"container\"></div>\n      <div class=\"region bottom left\"><div class=\"container\"></div></div>\n      <div class=\"region bottom center\"><div class=\"container\"></div></div>\n      <div class=\"region bottom right\"><div class=\"container\"></div></div>\n    </div>\n    <div class=\"region fullscreen above\"><div class=\"container\"></div></div>\n    <script type=\"text/javascript\" src=\"socket.io/socket.io.js\"></script>\n    <script type=\"text/javascript\" src=\"node_modules/nunjucks/browser/nunjucks.min.js\"></script>\n    <script type=\"text/javascript\" src=\"js/defaults.js\"></script>\n    <script type=\"text/javascript\" src=\"#CONFIG_FILE#\"></script>\n    <script type=\"text/javascript\" src=\"js/vendor.js\"></script>\n    <script type=\"text/javascript\" src=\"modules/default/defaultmodules.js\"></script>\n    <script type=\"text/javascript\" src=\"modules/default/utils.js\"></script>\n    <script type=\"text/javascript\" src=\"js/logger.js\"></script>\n    <script type=\"text/javascript\" src=\"translations/translations.js\"></script>\n    <script type=\"text/javascript\" src=\"js/translator.js\"></script>\n    <script type=\"text/javascript\" src=\"js/class.js\"></script>\n    <script type=\"text/javascript\" src=\"js/module.js\"></script>\n    <script type=\"text/javascript\" src=\"js/loader.js\"></script>\n    <script type=\"text/javascript\" src=\"js/socketclient.js\"></script>\n    <script type=\"text/javascript\" src=\"js/animateCSS.js\"></script>\n    <script type=\"text/javascript\" src=\"js/positions.js\"></script>\n    <script type=\"text/javascript\" src=\"js/main.js\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "jest.config.js",
    "content": "const aliasMapper = {\n\tlogger: \"<rootDir>/js/logger.js\"\n};\n\nconst config = {\n\tverbose: true,\n\ttestTimeout: 20000,\n\ttestSequencer: \"<rootDir>/tests/utils/test_sequencer.js\",\n\tprojects: [\n\t\t{\n\t\t\tdisplayName: \"unit\",\n\t\t\tglobalSetup: \"<rootDir>/tests/unit/helpers/global-setup.js\",\n\t\t\tmoduleNameMapper: aliasMapper,\n\t\t\ttestMatch: [\"**/tests/unit/**/*.[jt]s?(x)\"],\n\t\t\ttestPathIgnorePatterns: [\"<rootDir>/tests/unit/mocks\", \"<rootDir>/tests/unit/helpers\"]\n\t\t},\n\t\t{\n\t\t\tdisplayName: \"electron\",\n\t\t\ttestMatch: [\"**/tests/electron/**/*.[jt]s?(x)\"],\n\t\t\tmoduleNameMapper: aliasMapper,\n\t\t\ttestPathIgnorePatterns: [\"<rootDir>/tests/electron/helpers\"]\n\t\t},\n\t\t{\n\t\t\tdisplayName: \"e2e\",\n\t\t\ttestMatch: [\"**/tests/e2e/**/*.[jt]s?(x)\"],\n\t\t\tmodulePaths: [\"<rootDir>/js/\"],\n\t\t\tmoduleNameMapper: aliasMapper,\n\t\t\ttestPathIgnorePatterns: [\"<rootDir>/tests/e2e/helpers\", \"<rootDir>/tests/e2e/mocks\"]\n\t\t}\n\t],\n\tcollectCoverageFrom: [\n\t\t\"<rootDir>/clientonly/**/*.js\",\n\t\t\"<rootDir>/js/**/*.js\",\n\t\t\"<rootDir>/modules/default/**/*.js\",\n\t\t\"<rootDir>/serveronly/**/*.js\"\n\t],\n\tcoverageReporters: [\"lcov\", \"text\"],\n\tcoverageProvider: \"v8\"\n};\n\nmodule.exports = config;\n"
  },
  {
    "path": "js/alias-resolver.js",
    "content": "// Internal alias mapping for default and 3rd party modules.\n// Provides short require identifiers: \"logger\" and \"node_helper\".\n// For a future ESM migration, replace this with a public export/import surface.\n\nconst path = require(\"node:path\");\nconst Module = require(\"module\");\n\nconst root = path.join(__dirname, \"..\");\n\n// Keep this list minimal; do not add new aliases without architectural review.\nconst ALIASES = {\n\tlogger: \"js/logger.js\",\n\tnode_helper: \"js/node_helper.js\"\n};\n\n// Resolve to absolute paths now.\nconst resolved = Object.fromEntries(\n\tObject.entries(ALIASES).map(([k, rel]) => [k, path.join(root, rel)])\n);\n\n// Prevent multiple patching if this file is required more than once.\nif (!Module._mmAliasPatched) {\n\tconst origResolveFilename = Module._resolveFilename;\n\tModule._resolveFilename = function (request, parent, isMain, options) {\n\t\tif (Object.prototype.hasOwnProperty.call(resolved, request)) {\n\t\t\treturn resolved[request];\n\t\t}\n\t\treturn origResolveFilename.call(this, request, parent, isMain, options);\n\t};\n\tModule._mmAliasPatched = true; // non-enumerable marker would be overkill here\n}\n"
  },
  {
    "path": "js/animateCSS.js",
    "content": "/* enumeration of animations in Array **/\nconst AnimateCSSIn = [\n\t// Attention seekers\n\t\"bounce\",\n\t\"flash\",\n\t\"pulse\",\n\t\"rubberBand\",\n\t\"shakeX\",\n\t\"shakeY\",\n\t\"headShake\",\n\t\"swing\",\n\t\"tada\",\n\t\"wobble\",\n\t\"jello\",\n\t\"heartBeat\",\n\t// Back entrances\n\t\"backInDown\",\n\t\"backInLeft\",\n\t\"backInRight\",\n\t\"backInUp\",\n\t// Bouncing entrances\n\t\"bounceIn\",\n\t\"bounceInDown\",\n\t\"bounceInLeft\",\n\t\"bounceInRight\",\n\t\"bounceInUp\",\n\t// Fading entrances\n\t\"fadeIn\",\n\t\"fadeInDown\",\n\t\"fadeInDownBig\",\n\t\"fadeInLeft\",\n\t\"fadeInLeftBig\",\n\t\"fadeInRight\",\n\t\"fadeInRightBig\",\n\t\"fadeInUp\",\n\t\"fadeInUpBig\",\n\t\"fadeInTopLeft\",\n\t\"fadeInTopRight\",\n\t\"fadeInBottomLeft\",\n\t\"fadeInBottomRight\",\n\t// Flippers\n\t\"flip\",\n\t\"flipInX\",\n\t\"flipInY\",\n\t// Lightspeed\n\t\"lightSpeedInRight\",\n\t\"lightSpeedInLeft\",\n\t// Rotating entrances\n\t\"rotateIn\",\n\t\"rotateInDownLeft\",\n\t\"rotateInDownRight\",\n\t\"rotateInUpLeft\",\n\t\"rotateInUpRight\",\n\t// Specials\n\t\"jackInTheBox\",\n\t\"rollIn\",\n\t// Zooming entrances\n\t\"zoomIn\",\n\t\"zoomInDown\",\n\t\"zoomInLeft\",\n\t\"zoomInRight\",\n\t\"zoomInUp\",\n\t// Sliding entrances\n\t\"slideInDown\",\n\t\"slideInLeft\",\n\t\"slideInRight\",\n\t\"slideInUp\"\n];\n\nconst AnimateCSSOut = [\n\t// Back exits\n\t\"backOutDown\",\n\t\"backOutLeft\",\n\t\"backOutRight\",\n\t\"backOutUp\",\n\t// Bouncing exits\n\t\"bounceOut\",\n\t\"bounceOutDown\",\n\t\"bounceOutLeft\",\n\t\"bounceOutRight\",\n\t\"bounceOutUp\",\n\t// Fading exits\n\t\"fadeOut\",\n\t\"fadeOutDown\",\n\t\"fadeOutDownBig\",\n\t\"fadeOutLeft\",\n\t\"fadeOutLeftBig\",\n\t\"fadeOutRight\",\n\t\"fadeOutRightBig\",\n\t\"fadeOutUp\",\n\t\"fadeOutUpBig\",\n\t\"fadeOutTopLeft\",\n\t\"fadeOutTopRight\",\n\t\"fadeOutBottomRight\",\n\t\"fadeOutBottomLeft\",\n\t// Flippers\n\t\"flipOutX\",\n\t\"flipOutY\",\n\t// Lightspeed\n\t\"lightSpeedOutRight\",\n\t\"lightSpeedOutLeft\",\n\t// Rotating exits\n\t\"rotateOut\",\n\t\"rotateOutDownLeft\",\n\t\"rotateOutDownRight\",\n\t\"rotateOutUpLeft\",\n\t\"rotateOutUpRight\",\n\t// Specials\n\t\"hinge\",\n\t\"rollOut\",\n\t// Zooming exits\n\t\"zoomOut\",\n\t\"zoomOutDown\",\n\t\"zoomOutLeft\",\n\t\"zoomOutRight\",\n\t\"zoomOutUp\",\n\t// Sliding exits\n\t\"slideOutDown\",\n\t\"slideOutLeft\",\n\t\"slideOutRight\",\n\t\"slideOutUp\"\n];\n\n/**\n * Create an animation with Animate CSS\n * @param {string} [element] div element to animate.\n * @param {string} [animation] animation name.\n * @param {number} [animationTime] animation duration.\n */\nfunction addAnimateCSS (element, animation, animationTime) {\n\tconst animationName = `animate__${animation}`;\n\tconst node = document.getElementById(element);\n\tif (!node) {\n\t\t// don't execute animate: we don't find div\n\t\tLog.warn(\"node not found for adding\", element);\n\t\treturn;\n\t}\n\tnode.style.setProperty(\"--animate-duration\", `${animationTime}s`);\n\tnode.classList.add(\"animate__animated\", animationName);\n}\n\n/**\n * Remove an animation with Animate CSS\n * @param {string} [element] div element to animate.\n * @param {string} [animation] animation name.\n */\nfunction removeAnimateCSS (element, animation) {\n\tconst animationName = `animate__${animation}`;\n\tconst node = document.getElementById(element);\n\tif (!node) {\n\t\t// don't execute animate: we don't find div\n\t\tLog.warn(\"node not found for removing\", element);\n\t\treturn;\n\t}\n\tnode.classList.remove(\"animate__animated\", animationName);\n\tnode.style.removeProperty(\"--animate-duration\");\n}\nif (typeof window === \"undefined\") module.exports = { AnimateCSSIn, AnimateCSSOut };\n"
  },
  {
    "path": "js/app.js",
    "content": "// Load lightweight internal alias resolver\nrequire(\"./alias-resolver\");\n\nconst fs = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst envsub = require(\"envsub\");\nconst Log = require(\"logger\");\n\n// global absolute root path\nglobal.root_path = path.resolve(`${__dirname}/../`);\n\nconst Server = require(`${__dirname}/server`);\nconst Utils = require(`${__dirname}/utils`);\n\nconst defaultModules = require(`${global.root_path}/modules/default/defaultmodules`);\n// used to control fetch timeout for node_helpers\nconst { setGlobalDispatcher, Agent } = require(\"undici\");\nconst { getEnvVarsAsObj, getConfigFilePath } = require(\"#server_functions\");\n// common timeout value, provide environment override in case\nconst fetch_timeout = process.env.mmFetchTimeout !== undefined ? process.env.mmFetchTimeout : 30000;\n\n// Get version number.\nglobal.version = require(`${global.root_path}/package.json`).version;\nglobal.mmTestMode = process.env.mmTestMode === \"true\";\nLog.log(`Starting MagicMirror: v${global.version}`);\n\n// Log system information.\nUtils.logSystemInformation(global.version);\n\nif (process.env.MM_CONFIG_FILE) {\n\tglobal.configuration_file = process.env.MM_CONFIG_FILE.replace(`${global.root_path}/`, \"\");\n}\n\n// FIXME: Hotfix Pull Request\n// https://github.com/MagicMirrorOrg/MagicMirror/pull/673\nif (process.env.MM_PORT) {\n\tglobal.mmPort = process.env.MM_PORT;\n}\n\n// The next part is here to prevent a major exception when there\n// is no internet connection. This could probable be solved better.\nprocess.on(\"uncaughtException\", function (err) {\n\t// ignore strange exceptions under aarch64 coming from systeminformation:\n\tif (!err.stack.includes(\"node_modules/systeminformation\")) {\n\t\tLog.error(\"Whoops! There was an uncaught exception...\");\n\t\tLog.error(err);\n\t\tLog.error(\"MagicMirror² will not quit, but it might be a good idea to check why this happened. Maybe no internet connection?\");\n\t\tLog.error(\"If you think this really is an issue, please open an issue on GitHub: https://github.com/MagicMirrorOrg/MagicMirror/issues\");\n\t}\n});\n\n/**\n * The core app.\n * @class\n */\nfunction App () {\n\tlet nodeHelpers = [];\n\tlet httpServer;\n\n\t/**\n\t * Loads the config file. Combines it with the defaults and returns the config\n\t * @async\n\t * @returns {Promise<object>} the loaded config or the defaults if something goes wrong\n\t */\n\tasync function loadConfig () {\n\t\tLog.log(\"Loading config ...\");\n\t\tconst defaults = require(`${__dirname}/defaults`);\n\t\tif (global.mmTestMode) {\n\t\t\t// if we are running in test mode\n\t\t\tdefaults.address = \"0.0.0.0\";\n\t\t}\n\n\t\t// For this check proposed to TestSuite\n\t\t// https://forum.magicmirror.builders/topic/1456/test-suite-for-magicmirror/8\n\t\tconst configFilename = getConfigFilePath();\n\t\tlet templateFile = `${configFilename}.template`;\n\n\t\t// check if templateFile exists\n\t\ttry {\n\t\t\tfs.accessSync(templateFile, fs.constants.F_OK);\n\t\t} catch (err) {\n\t\t\ttemplateFile = null;\n\t\t\tLog.log(\"config template file not exists, no envsubst\");\n\t\t}\n\n\t\tif (templateFile) {\n\t\t\t// save current config.js\n\t\t\ttry {\n\t\t\t\tif (fs.existsSync(configFilename)) {\n\t\t\t\t\tfs.copyFileSync(configFilename, `${configFilename}-old`);\n\t\t\t\t}\n\t\t\t} catch (err) {\n\t\t\t\tLog.warn(`Could not copy ${configFilename}: ${err.message}`);\n\t\t\t}\n\n\t\t\t// check if config.env exists\n\t\t\tconst envFiles = [];\n\t\t\tconst configEnvFile = `${configFilename.substr(0, configFilename.lastIndexOf(\".\"))}.env`;\n\t\t\ttry {\n\t\t\t\tif (fs.existsSync(configEnvFile)) {\n\t\t\t\t\tenvFiles.push(configEnvFile);\n\t\t\t\t}\n\t\t\t} catch (err) {\n\t\t\t\tLog.log(`${configEnvFile} does not exist. ${err.message}`);\n\t\t\t}\n\n\t\t\tlet options = {\n\t\t\t\tall: true,\n\t\t\t\tdiff: false,\n\t\t\t\tenvFiles: envFiles,\n\t\t\t\tprotect: false,\n\t\t\t\tsyntax: \"default\",\n\t\t\t\tsystem: true\n\t\t\t};\n\n\t\t\t// envsubst variables in templateFile and create new config.js\n\t\t\t// naming for envsub must be templateFile and outputFile\n\t\t\tconst outputFile = configFilename;\n\t\t\ttry {\n\t\t\t\tawait envsub({ templateFile, outputFile, options });\n\t\t\t} catch (err) {\n\t\t\t\tLog.error(`Could not envsubst variables: ${err.message}`);\n\t\t\t}\n\t\t}\n\n\t\trequire(`${global.root_path}/js/check_config.js`);\n\n\t\ttry {\n\t\t\tfs.accessSync(configFilename, fs.constants.F_OK);\n\t\t\tconst c = require(configFilename);\n\t\t\tif (Object.keys(c).length === 0) {\n\t\t\t\tLog.error(\"WARNING! Config file appears empty, maybe missing module.exports last line?\");\n\t\t\t}\n\t\t\tcheckDeprecatedOptions(c);\n\t\t\treturn Object.assign(defaults, c);\n\t\t} catch (e) {\n\t\t\tif (e.code === \"ENOENT\") {\n\t\t\t\tLog.error(\"WARNING! Could not find config file. Please create one. Starting with default configuration.\");\n\t\t\t} else if (e instanceof ReferenceError || e instanceof SyntaxError) {\n\t\t\t\tLog.error(`WARNING! Could not validate config file. Starting with default configuration. Please correct syntax errors at or above this line: ${e.stack}`);\n\t\t\t} else {\n\t\t\t\tLog.error(`WARNING! Could not load config file. Starting with default configuration. Error found: ${e}`);\n\t\t\t}\n\t\t}\n\n\t\treturn defaults;\n\t}\n\n\t/**\n\t * Checks the config for deprecated options and throws a warning in the logs\n\t * if it encounters one option from the deprecated.js list\n\t * @param {object} userConfig The user config\n\t */\n\tfunction checkDeprecatedOptions (userConfig) {\n\t\tconst deprecated = require(`${global.root_path}/js/deprecated`);\n\n\t\t// check for deprecated core options\n\t\tconst deprecatedOptions = deprecated.configs;\n\t\tconst usedDeprecated = deprecatedOptions.filter((option) => userConfig.hasOwnProperty(option));\n\t\tif (usedDeprecated.length > 0) {\n\t\t\tLog.warn(`WARNING! Your config is using deprecated option(s): ${usedDeprecated.join(\", \")}. Check README and Documentation for more up-to-date ways of getting the same functionality.`);\n\t\t}\n\n\t\t// check for deprecated module options\n\t\tfor (const element of userConfig.modules) {\n\t\t\tif (deprecated[element.module] !== undefined && element.config !== undefined) {\n\t\t\t\tconst deprecatedModuleOptions = deprecated[element.module];\n\t\t\t\tconst usedDeprecatedModuleOptions = deprecatedModuleOptions.filter((option) => element.config.hasOwnProperty(option));\n\t\t\t\tif (usedDeprecatedModuleOptions.length > 0) {\n\t\t\t\t\tLog.warn(`WARNING! Your config for module ${element.module} is using deprecated option(s): ${usedDeprecatedModuleOptions.join(\", \")}. Check README and Documentation for more up-to-date ways of getting the same functionality.`);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Loads a specific module.\n\t * @param {string} module The name of the module (including subpath).\n\t */\n\tfunction loadModule (module) {\n\t\tconst elements = module.split(\"/\");\n\t\tconst moduleName = elements[elements.length - 1];\n\t\tconst env = getEnvVarsAsObj();\n\t\tlet moduleFolder = path.resolve(`${global.root_path}/${env.modulesDir}`, module);\n\n\t\tif (defaultModules.includes(moduleName)) {\n\t\t\tconst defaultModuleFolder = path.resolve(`${global.root_path}/modules/default/`, module);\n\t\t\tif (!global.mmTestMode) {\n\t\t\t\tmoduleFolder = defaultModuleFolder;\n\t\t\t} else {\n\t\t\t\t// running in test mode, allow defaultModules placed under moduleDir for testing\n\t\t\t\tif (env.modulesDir === \"modules\" || env.modulesDir === \"tests/mocks\") {\n\t\t\t\t\tmoduleFolder = defaultModuleFolder;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tconst moduleFile = `${moduleFolder}/${moduleName}.js`;\n\n\t\ttry {\n\t\t\tfs.accessSync(moduleFile, fs.constants.R_OK);\n\t\t} catch (e) {\n\t\t\tLog.warn(`No ${moduleFile} found for module: ${moduleName}.`);\n\t\t}\n\n\t\tconst helperPath = `${moduleFolder}/node_helper.js`;\n\n\t\tlet loadHelper = true;\n\t\ttry {\n\t\t\tfs.accessSync(helperPath, fs.constants.R_OK);\n\t\t} catch (e) {\n\t\t\tloadHelper = false;\n\t\t\tLog.log(`No helper found for module: ${moduleName}.`);\n\t\t}\n\n\t\t// if the helper was found\n\t\tif (loadHelper) {\n\t\t\tlet Module;\n\t\t\ttry {\n\t\t\t\tModule = require(helperPath);\n\t\t\t} catch (e) {\n\t\t\t\tLog.error(`Error when loading ${moduleName}:`, e.message);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tlet m = new Module();\n\n\t\t\tif (m.requiresVersion) {\n\t\t\t\tLog.log(`Check MagicMirror² version for node helper '${moduleName}' - Minimum version: ${m.requiresVersion} - Current version: ${global.version}`);\n\t\t\t\tif (cmpVersions(global.version, m.requiresVersion) >= 0) {\n\t\t\t\t\tLog.log(\"Version is ok!\");\n\t\t\t\t} else {\n\t\t\t\t\tLog.warn(`Version is incorrect. Skip module: '${moduleName}'`);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tm.setName(moduleName);\n\t\t\tm.setPath(path.resolve(moduleFolder));\n\t\t\tnodeHelpers.push(m);\n\n\t\t\tm.loaded();\n\t\t}\n\t}\n\n\t/**\n\t * Loads all modules.\n\t * @param {Module[]} modules All modules to be loaded\n\t * @returns {Promise} A promise that is resolved when all modules been loaded\n\t */\n\tasync function loadModules (modules) {\n\t\tLog.log(\"Loading module helpers ...\");\n\n\t\tfor (let module of modules) {\n\t\t\tawait loadModule(module);\n\t\t}\n\n\t\tLog.log(\"All module helpers loaded.\");\n\t}\n\n\t/**\n\t * Compare two semantic version numbers and return the difference.\n\t * @param {string} a Version number a.\n\t * @param {string} b Version number b.\n\t * @returns {number} A positive number if a is larger than b, a negative\n\t * number if a is smaller and 0 if they are the same\n\t */\n\tfunction cmpVersions (a, b) {\n\t\tlet i, diff;\n\t\tconst regExStrip0 = /(\\.0+)+$/;\n\t\tconst segmentsA = a.replace(regExStrip0, \"\").split(\".\");\n\t\tconst segmentsB = b.replace(regExStrip0, \"\").split(\".\");\n\t\tconst l = Math.min(segmentsA.length, segmentsB.length);\n\n\t\tfor (i = 0; i < l; i++) {\n\t\t\tdiff = parseInt(segmentsA[i], 10) - parseInt(segmentsB[i], 10);\n\t\t\tif (diff) {\n\t\t\t\treturn diff;\n\t\t\t}\n\t\t}\n\t\treturn segmentsA.length - segmentsB.length;\n\t}\n\n\t/**\n\t * Start the core app.\n\t *\n\t * It loads the config, then it loads all modules.\n\t * @async\n\t * @returns {Promise<object>} the config used\n\t */\n\tthis.start = async function () {\n\t\tconfig = await loadConfig();\n\n\t\tLog.setLogLevel(config.logLevel);\n\n\t\t// get the used module positions\n\t\tUtils.getModulePositions();\n\n\t\tlet modules = [];\n\t\tfor (const module of config.modules) {\n\t\t\tif (module.disabled) continue;\n\t\t\tif (module.module) {\n\t\t\t\tif (Utils.moduleHasValidPosition(module.position) || typeof (module.position) === \"undefined\") {\n\t\t\t\t\t// Only add this module to be loaded if it is not a duplicate (repeated instance of the same module)\n\t\t\t\t\tif (!modules.includes(module.module)) {\n\t\t\t\t\t\tmodules.push(module.module);\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tLog.warn(\"Invalid module position found for this configuration:\" + `\\n${JSON.stringify(module, null, 2)}`);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tLog.warn(\"No module name found for this configuration:\" + `\\n${JSON.stringify(module, null, 2)}`);\n\t\t\t}\n\t\t}\n\n\t\tsetGlobalDispatcher(new Agent({ connect: { timeout: fetch_timeout } }));\n\n\t\tawait loadModules(modules);\n\n\t\thttpServer = new Server(config);\n\t\tconst { app, io } = await httpServer.open();\n\t\tLog.log(\"Server started ...\");\n\n\t\tconst nodePromises = [];\n\t\tfor (let nodeHelper of nodeHelpers) {\n\t\t\tnodeHelper.setExpressApp(app);\n\t\t\tnodeHelper.setSocketIO(io);\n\n\t\t\ttry {\n\t\t\t\tnodePromises.push(nodeHelper.start());\n\t\t\t} catch (error) {\n\t\t\t\tLog.error(`Error when starting node_helper for module ${nodeHelper.name}:`);\n\t\t\t\tLog.error(error);\n\t\t\t}\n\t\t}\n\n\t\tconst results = await Promise.allSettled(nodePromises);\n\n\t\t// Log errors that happened during async node_helper startup\n\t\tresults.forEach((result) => {\n\t\t\tif (result.status === \"rejected\") {\n\t\t\t\tLog.error(result.reason);\n\t\t\t}\n\t\t});\n\n\t\tLog.log(\"Sockets connected & modules started ...\");\n\n\t\treturn config;\n\t};\n\n\t/**\n\t * Stops the core app. This calls each node_helper's STOP() function, if it\n\t * exists.\n\t *\n\t * Added to fix #1056\n\t * @returns {Promise} A promise that is resolved when all node_helpers and\n\t * the http server has been closed\n\t */\n\tthis.stop = async function () {\n\t\tconst nodePromises = [];\n\t\tfor (let nodeHelper of nodeHelpers) {\n\t\t\ttry {\n\t\t\t\tif (typeof nodeHelper.stop === \"function\") {\n\t\t\t\t\tnodePromises.push(nodeHelper.stop());\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tLog.error(`Error when stopping node_helper for module ${nodeHelper.name}:`);\n\t\t\t\tLog.error(error);\n\t\t\t}\n\t\t}\n\n\t\tconst results = await Promise.allSettled(nodePromises);\n\n\t\t// Log errors that happened during async node_helper stopping\n\t\tresults.forEach((result) => {\n\t\t\tif (result.status === \"rejected\") {\n\t\t\t\tLog.error(result.reason);\n\t\t\t}\n\t\t});\n\n\t\tLog.log(\"Node_helpers stopped ...\");\n\n\t\t// To be able to stop the app even if it hasn't been started (when\n\t\t// running with Electron against another server)\n\t\tif (!httpServer) {\n\t\t\treturn Promise.resolve();\n\t\t}\n\n\t\treturn httpServer.close();\n\t};\n\n\t/**\n\t * Listen for SIGINT signal and call stop() function.\n\t *\n\t * Added to fix #1056\n\t * Note: this is only used if running `server-only`. Otherwise\n\t * this.stop() is called by app.on(\"before-quit\"... in `electron.js`\n\t */\n\tprocess.on(\"SIGINT\", async () => {\n\t\tLog.log(\"[SIGINT] Received. Shutting down server...\");\n\t\tsetTimeout(() => {\n\t\t\tprocess.exit(0);\n\t\t}, 3000); // Force quit after 3 seconds\n\t\tawait this.stop();\n\t\tprocess.exit(0);\n\t});\n\n\t/**\n\t * Listen to SIGTERM signals so we can stop everything when we\n\t * are asked to stop by the OS.\n\t */\n\tprocess.on(\"SIGTERM\", async () => {\n\t\tLog.log(\"[SIGTERM] Received. Shutting down server...\");\n\t\tsetTimeout(() => {\n\t\t\tprocess.exit(0);\n\t\t}, 3000); // Force quit after 3 seconds\n\t\tawait this.stop();\n\t\tprocess.exit(0);\n\t});\n}\n\nmodule.exports = new App();\n"
  },
  {
    "path": "js/check_config.js",
    "content": "// Ensure internal require aliases (e.g., \"logger\") resolve when this file is run as a standalone script\nrequire(\"./alias-resolver\");\n\nconst path = require(\"node:path\");\nconst fs = require(\"node:fs\");\nconst { styleText } = require(\"node:util\");\nconst Ajv = require(\"ajv\");\nconst globals = require(\"globals\");\nconst { Linter } = require(\"eslint\");\nconst Log = require(\"logger\");\n\nconst rootPath = path.resolve(`${__dirname}/../`);\nconst Utils = require(`${rootPath}/js/utils.js`);\n\nconst linter = new Linter({ configType: \"flat\" });\nconst ajv = new Ajv();\n\n/**\n * Returns a string with path of configuration file.\n * Check if set by environment variable MM_CONFIG_FILE\n * @returns {string} path and filename of the config file\n */\nfunction getConfigFile () {\n\t// FIXME: This function should be in core. Do you want refactor me ;) ?, be good!\n\treturn path.resolve(process.env.MM_CONFIG_FILE || `${rootPath}/config/config.js`);\n}\n\n/**\n * Checks the config file using eslint.\n */\nfunction checkConfigFile () {\n\tconst configFileName = getConfigFile();\n\n\t// Check if file exists and is accessible\n\ttry {\n\t\tfs.accessSync(configFileName, fs.constants.R_OK);\n\t} catch (error) {\n\t\tif (error.code === \"ENOENT\") {\n\t\t\tLog.error(`File not found: ${configFileName}`);\n\t\t} else if (error.code === \"EACCES\") {\n\t\t\tLog.error(`No permission to read config file: ${configFileName}`);\n\t\t} else {\n\t\t\tLog.error(`Cannot access config file: ${configFileName}\\n${error.message}`);\n\t\t}\n\t\tprocess.exit(1);\n\t}\n\n\t// Validate syntax of the configuration file.\n\tLog.info(`Checking config file ${configFileName} ...`);\n\n\t// I'm not sure if all ever is utf-8\n\tconst configFile = fs.readFileSync(configFileName, \"utf-8\");\n\n\tconst errors = linter.verify(\n\t\tconfigFile,\n\t\t{\n\t\t\tlanguageOptions: {\n\t\t\t\tecmaVersion: \"latest\",\n\t\t\t\tglobals: {\n\t\t\t\t\t...globals.browser,\n\t\t\t\t\t...globals.node\n\t\t\t\t}\n\t\t\t},\n\t\t\trules: {\n\t\t\t\t\"no-sparse-arrays\": \"error\",\n\t\t\t\t\"no-undef\": \"error\"\n\t\t\t}\n\t\t},\n\t\tconfigFileName\n\t);\n\n\tif (errors.length === 0) {\n\t\tLog.info(styleText(\"green\", \"Your configuration file doesn't contain syntax errors :)\"));\n\t\tvalidateModulePositions(configFileName);\n\t} else {\n\t\tlet errorMessage = \"Your configuration file contains syntax errors :(\";\n\n\t\tfor (const error of errors) {\n\t\t\terrorMessage += `\\nLine ${error.line} column ${error.column}: ${error.message}`;\n\t\t}\n\t\tLog.error(errorMessage);\n\t\tprocess.exit(1);\n\t}\n}\n\n/**\n *\n * @param {string} configFileName - The path and filename of the configuration file to validate.\n */\nfunction validateModulePositions (configFileName) {\n\tLog.info(\"Checking modules structure configuration ...\");\n\n\tconst positionList = Utils.getModulePositions();\n\n\t// Make Ajv schema configuration of modules config\n\t// Only scan \"module\" and \"position\"\n\tconst schema = {\n\t\ttype: \"object\",\n\t\tproperties: {\n\t\t\tmodules: {\n\t\t\t\ttype: \"array\",\n\t\t\t\titems: {\n\t\t\t\t\ttype: \"object\",\n\t\t\t\t\tproperties: {\n\t\t\t\t\t\tmodule: {\n\t\t\t\t\t\t\ttype: \"string\"\n\t\t\t\t\t\t},\n\t\t\t\t\t\tposition: {\n\t\t\t\t\t\t\ttype: \"string\"\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\trequired: [\"module\"]\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t};\n\n\t// Scan all modules\n\tconst validate = ajv.compile(schema);\n\tconst data = require(configFileName);\n\n\tconst valid = validate(data);\n\tif (valid) {\n\t\tLog.info(styleText(\"green\", \"Your modules structure configuration doesn't contain errors :)\"));\n\n\t\t// Check for unknown positions (warning only, not an error)\n\t\tif (data.modules) {\n\t\t\tfor (const [index, module] of data.modules.entries()) {\n\t\t\t\tif (module.position && !positionList.includes(module.position)) {\n\t\t\t\t\tLog.warn(`Module ${index} (\"${module.module}\") uses unknown position: \"${module.position}\"`);\n\t\t\t\t\tLog.warn(`Known positions are: ${positionList.join(\", \")}`);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t} else {\n\t\tconst module = validate.errors[0].instancePath.split(\"/\")[2];\n\t\tconst position = validate.errors[0].instancePath.split(\"/\")[3];\n\t\tlet errorMessage = \"This module configuration contains errors:\";\n\t\terrorMessage += `\\n${JSON.stringify(data.modules[module], null, 2)}`;\n\t\tif (position) {\n\t\t\terrorMessage += `\\n${position}: ${validate.errors[0].message}`;\n\t\t\terrorMessage += `\\n${JSON.stringify(validate.errors[0].params.allowedValues, null, 2).slice(1, -1)}`;\n\t\t} else {\n\t\t\terrorMessage += validate.errors[0].message;\n\t\t}\n\t\tLog.error(errorMessage);\n\t\tprocess.exit(1);\n\t}\n}\n\ntry {\n\tcheckConfigFile();\n} catch (error) {\n\tconst message = error && error.message ? error.message : error;\n\tLog.error(`Unexpected error: ${message}`);\n\tprocess.exit(1);\n}\n"
  },
  {
    "path": "js/class.js",
    "content": "/* global Class, xyz */\n\n/*\n * Simple JavaScript Inheritance\n * By John Resig https://johnresig.com/\n *\n * Inspired by base2 and Prototype\n *\n * MIT Licensed.\n */\n(function () {\n\tlet initializing = false;\n\tconst fnTest = (/xyz/).test(function () {\n\t\txyz;\n\t})\n\t\t? /\\b_super\\b/\n\t\t: /.*/;\n\n\t// The base Class implementation (does nothing)\n\tthis.Class = function () {};\n\n\t// Create a new Class that inherits from this class\n\tClass.extend = function (prop) {\n\t\tlet _super = this.prototype;\n\n\t\t/*\n\t\t * Instantiate a base class (but only create the instance,\n\t\t * don't run the init constructor)\n\t\t */\n\t\tinitializing = true;\n\t\tconst prototype = new this();\n\t\tinitializing = false;\n\n\t\t// Make a copy of all prototype properties, to prevent reference issues.\n\t\tfor (const p in prototype) {\n\t\t\tprototype[p] = cloneObject(prototype[p]);\n\t\t}\n\n\t\t// Copy the properties over onto the new prototype\n\t\tfor (const name in prop) {\n\t\t\t// Check if we're overwriting an existing function\n\t\t\tprototype[name]\n\t\t\t\t= typeof prop[name] === \"function\" && typeof _super[name] === \"function\" && fnTest.test(prop[name])\n\t\t\t\t\t? (function (name, fn) {\n\t\t\t\t\t\treturn function () {\n\t\t\t\t\t\t\tconst tmp = this._super;\n\n\t\t\t\t\t\t\t/*\n\t\t\t\t\t\t\t * Add a new ._super() method that is the same method\n\t\t\t\t\t\t\t * but on the super-class\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\tthis._super = _super[name];\n\n\t\t\t\t\t\t\t/*\n\t\t\t\t\t\t\t * The method only need to be bound temporarily, so we\n\t\t\t\t\t\t\t * remove it when we're done executing\n\t\t\t\t\t\t\t */\n\t\t\t\t\t\t\tconst ret = fn.apply(this, arguments);\n\t\t\t\t\t\t\tthis._super = tmp;\n\n\t\t\t\t\t\t\treturn ret;\n\t\t\t\t\t\t};\n\t\t\t\t\t}(name, prop[name]))\n\t\t\t\t\t: prop[name];\n\t\t}\n\n\t\t/**\n\t\t * The dummy class constructor\n\t\t */\n\t\tfunction Class () {\n\t\t\t// All construction is actually done in the init method\n\t\t\tif (!initializing && this.init) {\n\t\t\t\tthis.init.apply(this, arguments);\n\t\t\t}\n\t\t}\n\n\t\t// Populate our constructed prototype object\n\t\tClass.prototype = prototype;\n\n\t\t// Enforce the constructor to be what we expect\n\t\tClass.prototype.constructor = Class;\n\n\t\t// And make this class extendable\n\t\tClass.extend = arguments.callee;\n\n\t\treturn Class;\n\t};\n}());\n\n/**\n * Define the clone method for later use. Helper Method.\n * @param {object} obj Object to be cloned\n * @returns {object} the cloned object\n */\nfunction cloneObject (obj) {\n\tif (obj === null || typeof obj !== \"object\") {\n\t\treturn obj;\n\t}\n\n\tif (obj.constructor.name === \"RegExp\") {\n\t\treturn new RegExp(obj);\n\t}\n\n\tconst temp = obj.constructor(); // give temp the original obj's constructor\n\tfor (const key in obj) {\n\t\ttemp[key] = cloneObject(obj[key]);\n\n\t\tif (key === \"lockStrings\") {\n\t\t\tLog.log(key);\n\t\t}\n\t}\n\n\treturn temp;\n}\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = Class;\n}\n"
  },
  {
    "path": "js/defaults.js",
    "content": "/* global mmPort */\n\nconst address = \"localhost\";\nlet port = 8080;\nif (typeof mmPort !== \"undefined\") {\n\tport = mmPort;\n}\nconst defaults = {\n\taddress: address,\n\tport: port,\n\tbasePath: \"/\",\n\tkioskmode: false,\n\telectronOptions: {},\n\tipWhitelist: [\"127.0.0.1\", \"::ffff:127.0.0.1\", \"::1\"],\n\n\tlanguage: \"en\",\n\tlogLevel: [\"INFO\", \"LOG\", \"WARN\", \"ERROR\"],\n\ttimeFormat: 24,\n\tunits: \"metric\",\n\tzoom: 1,\n\tcustomCss: \"css/custom.css\",\n\tforeignModulesDir: \"modules\",\n\t// httpHeaders used by helmet, see https://helmetjs.github.io/. You can add other/more object values by overriding this in config.js,\n\t// e.g. you need to add `frameguard: false` for embedding MagicMirror in another website, see https://github.com/MagicMirrorOrg/MagicMirror/issues/2847\n\thttpHeaders: { contentSecurityPolicy: false, crossOriginOpenerPolicy: false, crossOriginEmbedderPolicy: false, crossOriginResourcePolicy: false, originAgentCluster: false },\n\n\t// properties for checking if server is alive and has same startup-timestamp, the check is per default enabled\n\t// (interval 30 seconds). If startup-timestamp has changed the client reloads the magicmirror webpage.\n\tcheckServerInterval: 30 * 1000,\n\treloadAfterServerRestart: false,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"updatenotification\",\n\t\t\tposition: \"top_center\"\n\t\t},\n\t\t{\n\t\t\tmodule: \"helloworld\",\n\t\t\tposition: \"upper_third\",\n\t\t\tclasses: \"large thin\",\n\t\t\tconfig: {\n\t\t\t\ttext: \"MagicMirror²\"\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\tmodule: \"helloworld\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\ttext: \"Please create a config file or check the existing one for errors.\"\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\tmodule: \"helloworld\",\n\t\t\tposition: \"middle_center\",\n\t\t\tclasses: \"small dimmed\",\n\t\t\tconfig: {\n\t\t\t\ttext: \"See README for more information.\"\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\tmodule: \"helloworld\",\n\t\t\tposition: \"middle_center\",\n\t\t\tclasses: \"xsmall\",\n\t\t\tconfig: {\n\t\t\t\ttext: \"If you get this message while your config file is already created,<br>\" + \"it probably contains an error. To validate your config file run in your MagicMirror² directory<br>\" + \"<pre>node --run config:check</pre>\"\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\tmodule: \"helloworld\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tclasses: \"xsmall dimmed\",\n\t\t\tconfig: {\n\t\t\t\ttext: \"https://magicmirror.builders/\"\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = defaults;\n}\n"
  },
  {
    "path": "js/deprecated.js",
    "content": "module.exports = {\n\tconfigs: [\"kioskmode\"],\n\tclock: [\"secondsColor\"]\n};\n"
  },
  {
    "path": "js/electron.js",
    "content": "\"use strict\";\n\nconst electron = require(\"electron\");\nconst core = require(\"./app\");\nconst Log = require(\"./logger\");\n\n// Config\nlet config = process.env.config ? JSON.parse(process.env.config) : {};\n// Module to control application life.\nconst app = electron.app;\n\n/*\n * Per default electron is started with --disable-gpu flag, if you want the gpu enabled,\n * you must set the env var ELECTRON_ENABLE_GPU=1 on startup.\n * See https://www.electronjs.org/docs/latest/tutorial/offscreen-rendering for more info.\n */\nif (process.env.ELECTRON_ENABLE_GPU !== \"1\") {\n\tapp.disableHardwareAcceleration();\n}\n\n// Module to create native browser window.\nconst BrowserWindow = electron.BrowserWindow;\n\n/*\n * Keep a global reference of the window object, if you don't, the window will\n * be closed automatically when the JavaScript object is garbage collected.\n */\nlet mainWindow;\n\n/**\n *\n */\nfunction createWindow () {\n\n\t/*\n\t * see https://www.electronjs.org/docs/latest/api/screen\n\t * Create a window that fills the screen's available work area.\n\t */\n\tlet electronSize = (800, 600);\n\ttry {\n\t\telectronSize = electron.screen.getPrimaryDisplay().workAreaSize;\n\t} catch {\n\t\tLog.warn(\"Could not get display size, using defaults ...\");\n\t}\n\n\tlet electronSwitchesDefaults = [\"autoplay-policy\", \"no-user-gesture-required\"];\n\tapp.commandLine.appendSwitch(...new Set(electronSwitchesDefaults, config.electronSwitches));\n\tlet electronOptionsDefaults = {\n\t\twidth: electronSize.width,\n\t\theight: electronSize.height,\n\t\ticon: \"mm2.png\",\n\t\tx: 0,\n\t\ty: 0,\n\t\tdarkTheme: true,\n\t\twebPreferences: {\n\t\t\tcontextIsolation: true,\n\t\t\tnodeIntegration: false,\n\t\t\tzoomFactor: config.zoom\n\t\t},\n\t\tbackgroundColor: \"#000000\"\n\t};\n\n\t/*\n\t * DEPRECATED: \"kioskmode\" backwards compatibility, to be removed\n\t * settings these options directly instead provides cleaner interface\n\t */\n\tif (config.kioskmode) {\n\t\telectronOptionsDefaults.kiosk = true;\n\t} else {\n\t\telectronOptionsDefaults.show = false;\n\t\telectronOptionsDefaults.frame = false;\n\t\telectronOptionsDefaults.transparent = true;\n\t\telectronOptionsDefaults.hasShadow = false;\n\t\telectronOptionsDefaults.fullscreen = true;\n\t}\n\n\tconst electronOptions = Object.assign({}, electronOptionsDefaults, config.electronOptions);\n\n\tif (process.env.MOCK_DATE !== undefined) {\n\t\t// if we are running tests and we want to mock the current date\n\t\tconst fakeNow = new Date(process.env.MOCK_DATE).valueOf();\n\t\tDate = class extends Date {\n\t\t\tconstructor (...args) {\n\t\t\t\tif (args.length === 0) {\n\t\t\t\t\tsuper(fakeNow);\n\t\t\t\t} else {\n\t\t\t\t\tsuper(...args);\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\t\tconst __DateNowOffset = fakeNow - Date.now();\n\t\tconst __DateNow = Date.now;\n\t\tDate.now = () => __DateNow() + __DateNowOffset;\n\t}\n\n\t// Create the browser window.\n\tmainWindow = new BrowserWindow(electronOptions);\n\n\t/*\n\t * and load the index.html of the app.\n\t * If config.address is not defined or is an empty string (listening on all interfaces), connect to localhost\n\t */\n\n\tlet prefix;\n\tif ((config.tls !== null && config.tls) || config.useHttps) {\n\t\tprefix = \"https://\";\n\t} else {\n\t\tprefix = \"http://\";\n\t}\n\n\tlet address = (config.address === void 0) | (config.address === \"\") | (config.address === \"0.0.0.0\") ? (config.address = \"localhost\") : config.address;\n\tconst port = process.env.MM_PORT || config.port;\n\tmainWindow.loadURL(`${prefix}${address}:${port}`);\n\n\t// Open the DevTools if run with \"node --run start:dev\"\n\tif (process.argv.includes(\"dev\")) {\n\t\tif (process.env.mmTestMode) {\n\t\t\t// if we are running tests\n\t\t\tconst devtools = new BrowserWindow(electronOptions);\n\t\t\tmainWindow.webContents.setDevToolsWebContents(devtools.webContents);\n\t\t}\n\t\tmainWindow.webContents.openDevTools();\n\t}\n\n\t// simulate mouse move to hide black cursor on start\n\tmainWindow.webContents.on(\"dom-ready\", (event) => {\n\t\tmainWindow.webContents.sendInputEvent({ type: \"mouseMove\", x: 0, y: 0 });\n\t});\n\n\t// Set responders for window events.\n\tmainWindow.on(\"closed\", function () {\n\t\tmainWindow = null;\n\t});\n\n\tif (config.kioskmode) {\n\t\tmainWindow.on(\"blur\", function () {\n\t\t\tmainWindow.focus();\n\t\t});\n\n\t\tmainWindow.on(\"leave-full-screen\", function () {\n\t\t\tmainWindow.setFullScreen(true);\n\t\t});\n\n\t\tmainWindow.on(\"resize\", function () {\n\t\t\tsetTimeout(function () {\n\t\t\t\tmainWindow.reload();\n\t\t\t}, 1000);\n\t\t});\n\t}\n\n\t//remove response headers that prevent sites of being embedded into iframes if configured\n\tmainWindow.webContents.session.webRequest.onHeadersReceived((details, callback) => {\n\t\tlet curHeaders = details.responseHeaders;\n\t\tif (config.ignoreXOriginHeader || false) {\n\t\t\tcurHeaders = Object.fromEntries(Object.entries(curHeaders).filter((header) => !(/x-frame-options/i).test(header[0])));\n\t\t}\n\n\t\tif (config.ignoreContentSecurityPolicy || false) {\n\t\t\tcurHeaders = Object.fromEntries(Object.entries(curHeaders).filter((header) => !(/content-security-policy/i).test(header[0])));\n\t\t}\n\n\t\tcallback({ responseHeaders: curHeaders });\n\t});\n\n\tmainWindow.once(\"ready-to-show\", () => {\n\t\tmainWindow.show();\n\t});\n}\n\n// Quit when all windows are closed.\napp.on(\"window-all-closed\", function () {\n\tif (process.env.mmTestMode) {\n\t\t// if we are running tests\n\t\tapp.quit();\n\t} else {\n\t\tcreateWindow();\n\t}\n});\n\napp.on(\"activate\", function () {\n\n\t/*\n\t * On OS X it's common to re-create a window in the app when the\n\t * dock icon is clicked and there are no other windows open.\n\t */\n\tif (mainWindow === null) {\n\t\tcreateWindow();\n\t}\n});\n\n/*\n * This method will be called when SIGINT is received and will call\n * each node_helper's stop function if it exists. Added to fix #1056\n *\n * Note: this is only used if running Electron. Otherwise\n * core.stop() is called by process.on(\"SIGINT\"... in `app.js`\n */\napp.on(\"before-quit\", async (event) => {\n\tLog.log(\"Shutting down server...\");\n\tevent.preventDefault();\n\tsetTimeout(() => {\n\t\tprocess.exit(0);\n\t}, 3000); // Force-quit after 3 seconds.\n\tawait core.stop();\n\tprocess.exit(0);\n});\n\n/**\n * Handle errors from self-signed certificates\n */\napp.on(\"certificate-error\", (event, webContents, url, error, certificate, callback) => {\n\tevent.preventDefault();\n\tcallback(true);\n});\n\nif (process.env.clientonly) {\n\tapp.whenReady().then(() => {\n\t\tLog.log(\"Launching client viewer application.\");\n\t\tcreateWindow();\n\t});\n}\n\n/*\n * Start the core application if server is run on localhost\n * This starts all node helpers and starts the webserver.\n */\nif ([\"localhost\", \"127.0.0.1\", \"::1\", \"::ffff:127.0.0.1\", undefined].includes(config.address)) {\n\tcore.start().then((c) => {\n\t\tconfig = c;\n\t\tapp.whenReady().then(() => {\n\t\t\tLog.log(\"Launching application.\");\n\t\t\tcreateWindow();\n\t\t});\n\t});\n}\n"
  },
  {
    "path": "js/ip_access_control.js",
    "content": "const ipaddr = require(\"ipaddr.js\");\nconst Log = require(\"logger\");\n\n/**\n * Checks if a client IP matches any entry in the whitelist\n * @param {string} clientIp - The IP address to check\n * @param {string[]} whitelist - Array of IP addresses or CIDR ranges\n * @returns {boolean} True if IP is allowed\n */\nfunction isAllowed (clientIp, whitelist) {\n\ttry {\n\t\tconst addr = ipaddr.process(clientIp);\n\n\t\treturn whitelist.some((entry) => {\n\t\t\ttry {\n\t\t\t\t// CIDR notation\n\t\t\t\tif (entry.includes(\"/\")) {\n\t\t\t\t\tconst [rangeAddr, prefixLen] = ipaddr.parseCIDR(entry);\n\t\t\t\t\treturn addr.match(rangeAddr, prefixLen);\n\t\t\t\t}\n\n\t\t\t\t// Single IP address - let ipaddr.process normalize both\n\t\t\t\tconst allowedAddr = ipaddr.process(entry);\n\t\t\t\treturn addr.toString() === allowedAddr.toString();\n\t\t\t} catch (err) {\n\t\t\t\tLog.warn(`Invalid whitelist entry: ${entry}`);\n\t\t\t\treturn false;\n\t\t\t}\n\t\t});\n\t} catch (err) {\n\t\tLog.warn(`Failed to parse client IP: ${clientIp}`);\n\t\treturn false;\n\t}\n}\n\n/**\n * Creates an Express middleware for IP whitelisting\n * @param {string[]} whitelist - Array of allowed IP addresses or CIDR ranges\n * @returns {import(\"express\").RequestHandler} Express middleware function\n */\nfunction ipAccessControl (whitelist) {\n\t// Empty whitelist means allow all\n\tif (!Array.isArray(whitelist) || whitelist.length === 0) {\n\t\treturn function (req, res, next) {\n\t\t\tres.header(\"Access-Control-Allow-Origin\", \"*\");\n\t\t\tnext();\n\t\t};\n\t}\n\n\treturn function (req, res, next) {\n\t\tconst clientIp = req.ip || req.socket.remoteAddress;\n\n\t\tif (isAllowed(clientIp, whitelist)) {\n\t\t\tres.header(\"Access-Control-Allow-Origin\", \"*\");\n\t\t\tnext();\n\t\t} else {\n\t\t\tLog.log(`IP ${clientIp} is not allowed to access the mirror`);\n\t\t\tres.status(403).send(\"This device is not allowed to access your mirror. <br> Please check your config.js or config.js.sample to change this.\");\n\t\t}\n\t};\n}\n\nmodule.exports = { ipAccessControl };\n"
  },
  {
    "path": "js/loader.js",
    "content": "/* global defaultModules, vendor */\n\nconst Loader = (function () {\n\n\t/* Create helper variables */\n\n\tconst loadedModuleFiles = [];\n\tconst loadedFiles = [];\n\tconst moduleObjects = [];\n\n\t/* Private Methods */\n\n\t/**\n\t * Get environment variables from config.\n\t * @returns {object} Env vars with modulesDir and customCss paths from config.\n\t */\n\tconst getEnvVarsFromConfig = function () {\n\t\treturn {\n\t\t\tmodulesDir: config.foreignModulesDir || \"modules\",\n\t\t\tcustomCss: config.customCss || \"css/custom.css\"\n\t\t};\n\t};\n\n\t/**\n\t * Retrieve object of env variables.\n\t * @returns {object} with key: values as assembled in js/server_functions.js\n\t */\n\tconst getEnvVars = async function () {\n\t\t// In test mode, skip server fetch and use config values directly\n\t\tif (typeof process !== \"undefined\" && process.env && process.env.mmTestMode === \"true\") {\n\t\t\treturn getEnvVarsFromConfig();\n\t\t}\n\n\t\t// In production, fetch env vars from server\n\t\ttry {\n\t\t\tconst res = await fetch(new URL(\"env\", `${location.origin}${config.basePath}`));\n\t\t\treturn JSON.parse(await res.text());\n\t\t} catch (error) {\n\t\t\t// Fallback to config values if server fetch fails\n\t\t\tLog.error(\"Unable to retrieve env configuration\", error);\n\t\t\treturn getEnvVarsFromConfig();\n\t\t}\n\t};\n\n\t/**\n\t * Loops through all modules and requests start for every module.\n\t */\n\tconst startModules = async function () {\n\t\tconst modulePromises = [];\n\t\tfor (const module of moduleObjects) {\n\t\t\ttry {\n\t\t\t\tmodulePromises.push(module.start());\n\t\t\t} catch (error) {\n\t\t\t\tLog.error(`Error when starting node_helper for module ${module.name}:`);\n\t\t\t\tLog.error(error);\n\t\t\t}\n\t\t}\n\n\t\tconst results = await Promise.allSettled(modulePromises);\n\n\t\t// Log errors that happened during async node_helper startup\n\t\tresults.forEach((result) => {\n\t\t\tif (result.status === \"rejected\") {\n\t\t\t\tLog.error(result.reason);\n\t\t\t}\n\t\t});\n\n\t\t// Notify core of loaded modules.\n\t\tMM.modulesStarted(moduleObjects);\n\n\t\t// Starting modules also hides any modules that have requested to be initially hidden\n\t\tfor (const thisModule of moduleObjects) {\n\t\t\tif (thisModule.data.hiddenOnStartup) {\n\t\t\t\tLog.info(`Initially hiding ${thisModule.name}`);\n\t\t\t\tthisModule.hide();\n\t\t\t}\n\t\t}\n\t};\n\n\t/**\n\t * Retrieve list of all modules.\n\t * @returns {object[]} module data as configured in config\n\t */\n\tconst getAllModules = function () {\n\t\tconst AllModules = config.modules.filter((module) => (module.module !== undefined) && (MM.getAvailableModulePositions.indexOf(module.position) > -1 || typeof (module.position) === \"undefined\"));\n\t\treturn AllModules;\n\t};\n\n\t/**\n\t * Generate array with module information including module paths.\n\t * @returns {object[]} Module information.\n\t */\n\tconst getModuleData = async function () {\n\t\tconst modules = getAllModules();\n\t\tconst moduleFiles = [];\n\t\tconst envVars = await getEnvVars();\n\n\t\tmodules.forEach(function (moduleData, index) {\n\t\t\tconst module = moduleData.module;\n\n\t\t\tconst elements = module.split(\"/\");\n\t\t\tconst moduleName = elements[elements.length - 1];\n\t\t\tlet moduleFolder = `${envVars.modulesDir}/${module}`;\n\n\t\t\tif (defaultModules.indexOf(moduleName) !== -1) {\n\t\t\t\tconst defaultModuleFolder = `modules/default/${module}`;\n\t\t\t\tif (window.name !== \"jsdom\") {\n\t\t\t\t\tmoduleFolder = defaultModuleFolder;\n\t\t\t\t} else {\n\t\t\t\t\t// running in test mode, allow defaultModules placed under moduleDir for testing\n\t\t\t\t\tif (envVars.modulesDir === \"modules\") {\n\t\t\t\t\t\tmoduleFolder = defaultModuleFolder;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (moduleData.disabled === true) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tmoduleFiles.push({\n\t\t\t\tindex: index,\n\t\t\t\tidentifier: `module_${index}_${module}`,\n\t\t\t\tname: moduleName,\n\t\t\t\tpath: `${moduleFolder}/`,\n\t\t\t\tfile: `${moduleName}.js`,\n\t\t\t\tposition: moduleData.position,\n\t\t\t\tanimateIn: moduleData.animateIn,\n\t\t\t\tanimateOut: moduleData.animateOut,\n\t\t\t\thiddenOnStartup: moduleData.hiddenOnStartup,\n\t\t\t\theader: moduleData.header,\n\t\t\t\tconfigDeepMerge: typeof moduleData.configDeepMerge === \"boolean\" ? moduleData.configDeepMerge : false,\n\t\t\t\tconfig: moduleData.config,\n\t\t\t\tclasses: typeof moduleData.classes !== \"undefined\" ? `${moduleData.classes} ${module}` : module,\n\t\t\t\torder: (typeof moduleData.order === \"number\" && Number.isInteger(moduleData.order)) ? moduleData.order : 0\n\t\t\t});\n\t\t});\n\n\t\treturn moduleFiles;\n\t};\n\n\t/**\n\t * Load modules via ajax request and create module objects.\n\t * @param {object} module Information about the module we want to load.\n\t * @returns {Promise<void>} resolved when module is loaded\n\t */\n\tconst loadModule = async function (module) {\n\t\tconst url = module.path + module.file;\n\n\t\t/**\n\t\t * @returns {Promise<void>}\n\t\t */\n\t\tconst afterLoad = async function () {\n\t\t\tconst moduleObject = Module.create(module.name);\n\t\t\tif (moduleObject) {\n\t\t\t\tawait bootstrapModule(module, moduleObject);\n\t\t\t}\n\t\t};\n\n\t\tif (loadedModuleFiles.indexOf(url) !== -1) {\n\t\t\tawait afterLoad();\n\t\t} else {\n\t\t\tawait loadFile(url);\n\t\t\tloadedModuleFiles.push(url);\n\t\t\tawait afterLoad();\n\t\t}\n\t};\n\n\t/**\n\t * Bootstrap modules by setting the module data and loading the scripts & styles.\n\t * @param {object} module Information about the module we want to load.\n\t * @param {Module} mObj Modules instance.\n\t */\n\tconst bootstrapModule = async function (module, mObj) {\n\t\tLog.info(`Bootstrapping module: ${module.name}`);\n\t\tmObj.setData(module);\n\n\t\tawait mObj.loadScripts();\n\t\tLog.log(`Scripts loaded for: ${module.name}`);\n\n\t\tawait mObj.loadStyles();\n\t\tLog.log(`Styles loaded for: ${module.name}`);\n\n\t\tawait mObj.loadTranslations();\n\t\tLog.log(`Translations loaded for: ${module.name}`);\n\n\t\tmoduleObjects.push(mObj);\n\t};\n\n\t/**\n\t * Load a script or stylesheet by adding it to the dom.\n\t * @param {string} fileName Path of the file we want to load.\n\t * @returns {Promise} resolved when the file is loaded\n\t */\n\tconst loadFile = async function (fileName) {\n\t\tconst extension = fileName.slice((Math.max(0, fileName.lastIndexOf(\".\")) || Infinity) + 1);\n\t\tlet script, stylesheet;\n\n\t\tswitch (extension.toLowerCase()) {\n\t\t\tcase \"js\":\n\t\t\t\treturn new Promise((resolve) => {\n\t\t\t\t\tLog.log(`Load script: ${fileName}`);\n\t\t\t\t\tscript = document.createElement(\"script\");\n\t\t\t\t\tscript.type = \"text/javascript\";\n\t\t\t\t\tscript.src = fileName;\n\t\t\t\t\tscript.onload = function () {\n\t\t\t\t\t\tresolve();\n\t\t\t\t\t};\n\t\t\t\t\tscript.onerror = function () {\n\t\t\t\t\t\tLog.error(\"Error on loading script:\", fileName);\n\t\t\t\t\t\tscript.remove();\n\t\t\t\t\t\tresolve();\n\t\t\t\t\t};\n\t\t\t\t\tdocument.getElementsByTagName(\"body\")[0].appendChild(script);\n\t\t\t\t});\n\t\t\tcase \"css\":\n\t\t\t\treturn new Promise((resolve) => {\n\t\t\t\t\tLog.log(`Load stylesheet: ${fileName}`);\n\n\t\t\t\t\tstylesheet = document.createElement(\"link\");\n\t\t\t\t\tstylesheet.rel = \"stylesheet\";\n\t\t\t\t\tstylesheet.type = \"text/css\";\n\t\t\t\t\tstylesheet.href = fileName;\n\t\t\t\t\tstylesheet.onload = function () {\n\t\t\t\t\t\tresolve();\n\t\t\t\t\t};\n\t\t\t\t\tstylesheet.onerror = function () {\n\t\t\t\t\t\tLog.error(\"Error on loading stylesheet:\", fileName);\n\t\t\t\t\t\tstylesheet.remove();\n\t\t\t\t\t\tresolve();\n\t\t\t\t\t};\n\t\t\t\t\tdocument.getElementsByTagName(\"head\")[0].appendChild(stylesheet);\n\t\t\t\t});\n\t\t}\n\t};\n\n\t/* Public Methods */\n\treturn {\n\n\t\t/**\n\t\t * Load all modules as defined in the config.\n\t\t */\n\t\tasync loadModules () {\n\t\t\tconst moduleData = await getModuleData();\n\t\t\tconst envVars = await getEnvVars();\n\t\t\tconst customCss = envVars.customCss;\n\n\t\t\t// Load all modules\n\t\t\tfor (const module of moduleData) {\n\t\t\t\tawait loadModule(module);\n\t\t\t}\n\n\t\t\t// Load custom.css\n\t\t\t// Since this happens after loading the modules,\n\t\t\t// it overwrites the default styles.\n\t\t\tawait loadFile(customCss);\n\n\t\t\t// Start all modules.\n\t\t\tawait startModules();\n\t\t},\n\n\t\t/**\n\t\t * Load a file (script or stylesheet).\n\t\t * Prevent double loading and search for files in the vendor folder.\n\t\t * @param {string} fileName Path of the file we want to load.\n\t\t * @param {Module} module The module that calls the loadFile function.\n\t\t * @returns {Promise} resolved when the file is loaded\n\t\t */\n\t\tasync loadFileForModule (fileName, module) {\n\t\t\tif (loadedFiles.indexOf(fileName.toLowerCase()) !== -1) {\n\t\t\t\tLog.log(`File already loaded: ${fileName}`);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (fileName.indexOf(\"http://\") === 0 || fileName.indexOf(\"https://\") === 0 || fileName.indexOf(\"/\") !== -1) {\n\t\t\t\t// This is an absolute or relative path.\n\t\t\t\t// Load it and then return.\n\t\t\t\tloadedFiles.push(fileName.toLowerCase());\n\t\t\t\treturn loadFile(fileName);\n\t\t\t}\n\n\t\t\tif (vendor[fileName] !== undefined) {\n\t\t\t\t// This file is available in the vendor folder.\n\t\t\t\t// Load it from this vendor folder.\n\t\t\t\tloadedFiles.push(fileName.toLowerCase());\n\t\t\t\treturn loadFile(`${vendor[fileName]}`);\n\t\t\t}\n\n\t\t\t// File not loaded yet.\n\t\t\t// Load it based on the module path.\n\t\t\tloadedFiles.push(fileName.toLowerCase());\n\t\t\treturn loadFile(module.file(fileName));\n\t\t}\n\t};\n}());\n"
  },
  {
    "path": "js/logger.js",
    "content": "// This logger is very simple, but needs to be extended.\n(function (root, factory) {\n\tif (typeof exports === \"object\") {\n\t\tif (process.env.mmTestMode !== \"true\") {\n\t\t\tconst { styleText } = require(\"node:util\");\n\n\t\t\t// add timestamps in front of log messages\n\t\t\trequire(\"console-stamp\")(console, {\n\t\t\t\tformat: \":date(yyyy-mm-dd HH:MM:ss.l) :label(7) :pre() :msg\",\n\t\t\t\ttokens: {\n\t\t\t\t\tpre: () => {\n\t\t\t\t\t\tconst err = new Error();\n\t\t\t\t\t\tError.prepareStackTrace = (_, stack) => stack;\n\t\t\t\t\t\tconst stack = err.stack;\n\t\t\t\t\t\tError.prepareStackTrace = undefined;\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tfor (const line of stack) {\n\t\t\t\t\t\t\t\tconst file = line.getFileName();\n\t\t\t\t\t\t\t\tif (file && !file.includes(\"node:\") && !file.includes(\"js/logger.js\") && !file.includes(\"node_modules\")) {\n\t\t\t\t\t\t\t\t\tconst filename = file.replace(/.*\\/(.*).js/, \"$1\");\n\t\t\t\t\t\t\t\t\tconst filepath = file.replace(/.*\\/(.*)\\/.*.js/, \"$1\");\n\t\t\t\t\t\t\t\t\tif (filepath === \"js\") {\n\t\t\t\t\t\t\t\t\t\treturn styleText(\"grey\", `[${filename}]`);\n\t\t\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t\t\treturn styleText(\"grey\", `[${filepath}]`);\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\treturn styleText(\"grey\", \"[unknown]\");\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tlabel: (arg) => {\n\t\t\t\t\t\tconst { method, defaultTokens } = arg;\n\t\t\t\t\t\tlet label = defaultTokens.label(arg);\n\t\t\t\t\t\tswitch (method) {\n\t\t\t\t\t\t\tcase \"error\":\n\t\t\t\t\t\t\t\tlabel = styleText(\"red\", label);\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\tcase \"warn\":\n\t\t\t\t\t\t\t\tlabel = styleText(\"yellow\", label);\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\tcase \"debug\":\n\t\t\t\t\t\t\t\tlabel = styleText(\"bgBlue\", label);\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\tcase \"info\":\n\t\t\t\t\t\t\t\tlabel = styleText(\"blue\", label);\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn label;\n\t\t\t\t\t},\n\t\t\t\t\tmsg: (arg) => {\n\t\t\t\t\t\tconst { method, defaultTokens } = arg;\n\t\t\t\t\t\tlet msg = defaultTokens.msg(arg);\n\t\t\t\t\t\tswitch (method) {\n\t\t\t\t\t\t\tcase \"error\":\n\t\t\t\t\t\t\t\tmsg = styleText(\"red\", msg);\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\tcase \"warn\":\n\t\t\t\t\t\t\t\tmsg = styleText(\"yellow\", msg);\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\tcase \"info\":\n\t\t\t\t\t\t\t\tmsg = styleText(\"blue\", msg);\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\t\t\t\t\t\treturn msg;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\t\t// Node, CommonJS-like\n\t\tmodule.exports = factory(root.config);\n\t} else {\n\t\t// Browser globals (root is window)\n\t\troot.Log = factory(root.config);\n\t}\n}(this, function (config) {\n\tlet logLevel;\n\tlet enableLog;\n\tif (typeof exports === \"object\") {\n\t\t// in nodejs and not running in test mode\n\t\tenableLog = process.env.mmTestMode !== \"true\";\n\t} else {\n\t\t// in browser and not running with jsdom\n\t\tenableLog = typeof window === \"object\" && window.name !== \"jsdom\";\n\t}\n\n\tif (enableLog) {\n\t\tlogLevel = {\n\t\t\tdebug: Function.prototype.bind.call(console.debug, console),\n\t\t\tlog: Function.prototype.bind.call(console.log, console),\n\t\t\tinfo: Function.prototype.bind.call(console.info, console),\n\t\t\twarn: Function.prototype.bind.call(console.warn, console),\n\t\t\terror: Function.prototype.bind.call(console.error, console),\n\t\t\tgroup: Function.prototype.bind.call(console.group, console),\n\t\t\tgroupCollapsed: Function.prototype.bind.call(console.groupCollapsed, console),\n\t\t\tgroupEnd: Function.prototype.bind.call(console.groupEnd, console),\n\t\t\ttime: Function.prototype.bind.call(console.time, console),\n\t\t\ttimeEnd: Function.prototype.bind.call(console.timeEnd, console),\n\t\t\ttimeStamp: console.timeStamp ? Function.prototype.bind.call(console.timeStamp, console) : function () {}\n\t\t};\n\n\t\tlogLevel.setLogLevel = function (newLevel) {\n\t\t\tif (newLevel) {\n\t\t\t\tObject.keys(logLevel).forEach(function (key) {\n\t\t\t\t\tif (!newLevel.includes(key.toLocaleUpperCase())) {\n\t\t\t\t\t\tlogLevel[key] = function () {};\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t}\n\t\t};\n\t} else {\n\t\tlogLevel = {\n\t\t\tdebug () {},\n\t\t\tlog () {},\n\t\t\tinfo () {},\n\t\t\twarn () {},\n\t\t\terror () {},\n\t\t\tgroup () {},\n\t\t\tgroupCollapsed () {},\n\t\t\tgroupEnd () {},\n\t\t\ttime () {},\n\t\t\ttimeEnd () {},\n\t\t\ttimeStamp () {}\n\t\t};\n\n\t\tlogLevel.setLogLevel = function () {};\n\t}\n\n\treturn logLevel;\n}));\n"
  },
  {
    "path": "js/main.js",
    "content": "/* global Loader, defaults, Translator, addAnimateCSS, removeAnimateCSS, AnimateCSSIn, AnimateCSSOut, modulePositions, io */\n\nconst MM = (function () {\n\tlet modules = [];\n\n\t/* Private Methods */\n\n\t/**\n\t * Create dom objects for all modules that are configured for a specific position.\n\t */\n\tconst createDomObjects = function () {\n\t\tconst domCreationPromises = [];\n\n\t\tmodules.forEach(function (module) {\n\t\t\tif (typeof module.data.position !== \"string\") {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tlet haveAnimateIn = null;\n\t\t\t// check if have valid animateIn in module definition (module.data.animateIn)\n\t\t\tif (module.data.animateIn && AnimateCSSIn.indexOf(module.data.animateIn) !== -1) haveAnimateIn = module.data.animateIn;\n\n\t\t\tconst wrapper = selectWrapper(module.data.position);\n\n\t\t\tconst dom = document.createElement(\"div\");\n\t\t\tdom.id = module.identifier;\n\t\t\tdom.className = module.name;\n\n\t\t\tif (typeof module.data.classes === \"string\") {\n\t\t\t\tdom.className = `module ${dom.className} ${module.data.classes}`;\n\t\t\t}\n\n\t\t\tdom.style.order = (typeof module.data.order === \"number\" && Number.isInteger(module.data.order)) ? module.data.order : 0;\n\n\t\t\tdom.opacity = 0;\n\t\t\twrapper.appendChild(dom);\n\n\t\t\tconst moduleHeader = document.createElement(\"header\");\n\t\t\tmoduleHeader.innerHTML = module.getHeader();\n\t\t\tmoduleHeader.className = \"module-header\";\n\t\t\tdom.appendChild(moduleHeader);\n\n\t\t\tif (typeof module.getHeader() === \"undefined\" || module.getHeader() !== \"\") {\n\t\t\t\tmoduleHeader.style.display = \"none;\";\n\t\t\t} else {\n\t\t\t\tmoduleHeader.style.display = \"block;\";\n\t\t\t}\n\n\t\t\tconst moduleContent = document.createElement(\"div\");\n\t\t\tmoduleContent.className = \"module-content\";\n\t\t\tdom.appendChild(moduleContent);\n\n\t\t\t// create the domCreationPromise with AnimateCSS (with animateIn of module definition)\n\t\t\t// or just display it\n\t\t\tvar domCreationPromise;\n\t\t\tif (haveAnimateIn) domCreationPromise = updateDom(module, { options: { speed: 1000, animate: { in: haveAnimateIn } } }, true);\n\t\t\telse domCreationPromise = updateDom(module, 0);\n\n\t\t\tdomCreationPromises.push(domCreationPromise);\n\t\t\tdomCreationPromise\n\t\t\t\t.then(function () {\n\t\t\t\t\tsendNotification(\"MODULE_DOM_CREATED\", null, null, module);\n\t\t\t\t})\n\t\t\t\t.catch(Log.error);\n\t\t});\n\n\t\tupdateWrapperStates();\n\n\t\tPromise.all(domCreationPromises).then(function () {\n\t\t\tsendNotification(\"DOM_OBJECTS_CREATED\");\n\t\t});\n\t};\n\n\t/**\n\t * Select the wrapper dom object for a specific position.\n\t * @param {string} position The name of the position.\n\t * @returns {HTMLElement | void} the wrapper element\n\t */\n\tconst selectWrapper = function (position) {\n\t\tconst classes = position.replace(\"_\", \" \");\n\t\tconst parentWrapper = document.getElementsByClassName(classes);\n\t\tif (parentWrapper.length > 0) {\n\t\t\tconst wrapper = parentWrapper[0].getElementsByClassName(\"container\");\n\t\t\tif (wrapper.length > 0) {\n\t\t\t\treturn wrapper[0];\n\t\t\t}\n\t\t}\n\t};\n\n\t/**\n\t * Send a notification to all modules.\n\t * @param {string} notification The identifier of the notification.\n\t * @param {object} payload The payload of the notification.\n\t * @param {Module} sender The module that sent the notification.\n\t * @param {Module} [sendTo] The (optional) module to send the notification to.\n\t */\n\tconst sendNotification = function (notification, payload, sender, sendTo) {\n\t\tfor (const m in modules) {\n\t\t\tconst module = modules[m];\n\t\t\tif (module !== sender && (!sendTo || module === sendTo)) {\n\t\t\t\tmodule.notificationReceived(notification, payload, sender);\n\t\t\t}\n\t\t}\n\t};\n\n\t/**\n\t * Update the dom for a specific module.\n\t * @param {Module} module The module that needs an update.\n\t * @param {object|number} [updateOptions] The (optional) number of microseconds for the animation or object with updateOptions (speed/animates)\n\t * @param {boolean} [createAnimatedDom] for displaying only animateIn (used on first start of MagicMirror)\n\t * @returns {Promise} Resolved when the dom is fully updated.\n\t */\n\tconst updateDom = function (module, updateOptions, createAnimatedDom = false) {\n\t\treturn new Promise(function (resolve) {\n\t\t\tlet speed = updateOptions;\n\t\t\tlet animateOut = null;\n\t\t\tlet animateIn = null;\n\t\t\tif (typeof updateOptions === \"object\") {\n\t\t\t\tif (typeof updateOptions.options === \"object\" && updateOptions.options.speed !== undefined) {\n\t\t\t\t\tspeed = updateOptions.options.speed;\n\t\t\t\t\tLog.debug(`updateDom: ${module.identifier} Has speed in object: ${speed}`);\n\t\t\t\t\tif (typeof updateOptions.options.animate === \"object\") {\n\t\t\t\t\t\tanimateOut = updateOptions.options.animate.out;\n\t\t\t\t\t\tanimateIn = updateOptions.options.animate.in;\n\t\t\t\t\t\tLog.debug(`updateDom: ${module.identifier} Has animate in object: out->${animateOut}, in->${animateIn}`);\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tLog.debug(`updateDom: ${module.identifier} Has no speed in object`);\n\t\t\t\t\tspeed = 0;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst newHeader = module.getHeader();\n\t\t\tlet newContentPromise = module.getDom();\n\n\t\t\tif (!(newContentPromise instanceof Promise)) {\n\t\t\t\t// convert to a promise if not already one to avoid if/else's everywhere\n\t\t\t\tnewContentPromise = Promise.resolve(newContentPromise);\n\t\t\t}\n\n\t\t\tnewContentPromise\n\t\t\t\t.then(function (newContent) {\n\t\t\t\t\tconst updatePromise = updateDomWithContent(module, speed, newHeader, newContent, animateOut, animateIn, createAnimatedDom);\n\n\t\t\t\t\tupdatePromise.then(resolve).catch(Log.error);\n\t\t\t\t})\n\t\t\t\t.catch(Log.error);\n\t\t});\n\t};\n\n\t/**\n\t * Update the dom with the specified content\n\t * @param {Module} module The module that needs an update.\n\t * @param {number} [speed] The (optional) number of microseconds for the animation.\n\t * @param {string} newHeader The new header that is generated.\n\t * @param {HTMLElement} newContent The new content that is generated.\n\t * @param {string} [animateOut] AnimateCss animation name before hidden\n\t * @param {string} [animateIn] AnimateCss animation name on show\n\t * @param {boolean} [createAnimatedDom] for displaying only animateIn (used on first start)\n\t * @returns {Promise} Resolved when the module dom has been updated.\n\t */\n\tconst updateDomWithContent = function (module, speed, newHeader, newContent, animateOut, animateIn, createAnimatedDom = false) {\n\t\treturn new Promise(function (resolve) {\n\t\t\tif (module.hidden || !speed) {\n\t\t\t\tupdateModuleContent(module, newHeader, newContent);\n\t\t\t\tresolve();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (!moduleNeedsUpdate(module, newHeader, newContent)) {\n\t\t\t\tresolve();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (!speed) {\n\t\t\t\tupdateModuleContent(module, newHeader, newContent);\n\t\t\t\tresolve();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (createAnimatedDom && animateIn !== null) {\n\t\t\t\tLog.debug(`${module.identifier} createAnimatedDom (${animateIn})`);\n\t\t\t\tupdateModuleContent(module, newHeader, newContent);\n\t\t\t\tif (!module.hidden) {\n\t\t\t\t\tshowModule(module, speed, null, { animate: animateIn });\n\t\t\t\t}\n\t\t\t\tresolve();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\thideModule(\n\t\t\t\tmodule,\n\t\t\t\tspeed / 2,\n\t\t\t\tfunction () {\n\t\t\t\t\tupdateModuleContent(module, newHeader, newContent);\n\t\t\t\t\tif (!module.hidden) {\n\t\t\t\t\t\tshowModule(module, speed / 2, null, { animate: animateIn });\n\t\t\t\t\t}\n\t\t\t\t\tresolve();\n\t\t\t\t},\n\t\t\t\t{ animate: animateOut }\n\t\t\t);\n\t\t});\n\t};\n\n\t/**\n\t * Check if the content has changed.\n\t * @param {Module} module The module to check.\n\t * @param {string} newHeader The new header that is generated.\n\t * @param {HTMLElement} newContent The new content that is generated.\n\t * @returns {boolean} True if the module need an update, false otherwise\n\t */\n\tconst moduleNeedsUpdate = function (module, newHeader, newContent) {\n\t\tconst moduleWrapper = document.getElementById(module.identifier);\n\t\tif (moduleWrapper === null) {\n\t\t\treturn false;\n\t\t}\n\n\t\tconst contentWrapper = moduleWrapper.getElementsByClassName(\"module-content\");\n\t\tconst headerWrapper = moduleWrapper.getElementsByClassName(\"module-header\");\n\n\t\tlet headerNeedsUpdate = false;\n\t\tlet contentNeedsUpdate;\n\n\t\tif (headerWrapper.length > 0) {\n\t\t\theaderNeedsUpdate = newHeader !== headerWrapper[0].innerHTML;\n\t\t}\n\n\t\tconst tempContentWrapper = document.createElement(\"div\");\n\t\ttempContentWrapper.appendChild(newContent);\n\t\tcontentNeedsUpdate = tempContentWrapper.innerHTML !== contentWrapper[0].innerHTML;\n\n\t\treturn headerNeedsUpdate || contentNeedsUpdate;\n\t};\n\n\t/**\n\t * Update the content of a module on screen.\n\t * @param {Module} module The module to check.\n\t * @param {string} newHeader The new header that is generated.\n\t * @param {HTMLElement} newContent The new content that is generated.\n\t */\n\tconst updateModuleContent = function (module, newHeader, newContent) {\n\t\tconst moduleWrapper = document.getElementById(module.identifier);\n\t\tif (moduleWrapper === null) {\n\t\t\treturn;\n\t\t}\n\t\tconst headerWrapper = moduleWrapper.getElementsByClassName(\"module-header\");\n\t\tconst contentWrapper = moduleWrapper.getElementsByClassName(\"module-content\");\n\n\t\tcontentWrapper[0].innerHTML = \"\";\n\t\tcontentWrapper[0].appendChild(newContent);\n\n\t\theaderWrapper[0].innerHTML = newHeader;\n\t\tif (headerWrapper.length > 0 && newHeader) {\n\t\t\theaderWrapper[0].style.display = \"block\";\n\t\t} else {\n\t\t\theaderWrapper[0].style.display = \"none\";\n\t\t}\n\t};\n\n\t/**\n\t * Hide the module.\n\t * @param {Module} module The module to hide.\n\t * @param {number} speed The speed of the hide animation.\n\t * @param {Promise} callback Called when the animation is done.\n\t * @param {object} [options] Optional settings for the hide method.\n\t */\n\tconst hideModule = function (module, speed, callback, options = {}) {\n\t\t// set lockString if set in options.\n\t\tif (options.lockString) {\n\t\t\tif (module.lockStrings.indexOf(options.lockString) === -1) {\n\t\t\t\tmodule.lockStrings.push(options.lockString);\n\t\t\t}\n\t\t}\n\n\t\tconst moduleWrapper = document.getElementById(module.identifier);\n\t\tif (moduleWrapper !== null) {\n\t\t\tclearTimeout(module.showHideTimer);\n\t\t\t// reset all animations if needed\n\t\t\tif (module.hasAnimateOut) {\n\t\t\t\tremoveAnimateCSS(module.identifier, module.hasAnimateOut);\n\t\t\t\tLog.debug(`${module.identifier} Force remove animateOut (in hide): ${module.hasAnimateOut}`);\n\t\t\t\tmodule.hasAnimateOut = false;\n\t\t\t}\n\t\t\tif (module.hasAnimateIn) {\n\t\t\t\tremoveAnimateCSS(module.identifier, module.hasAnimateIn);\n\t\t\t\tLog.debug(`${module.identifier} Force remove animateIn (in hide): ${module.hasAnimateIn}`);\n\t\t\t\tmodule.hasAnimateIn = false;\n\t\t\t}\n\t\t\t// haveAnimateName for verify if we are using AnimateCSS library\n\t\t\t// we check AnimateCSSOut Array for validate it\n\t\t\t// and finally return the animate name or `null` (for default MM² animation)\n\t\t\tlet haveAnimateName = null;\n\t\t\t// check if have valid animateOut in module definition (module.data.animateOut)\n\t\t\tif (module.data.animateOut && AnimateCSSOut.indexOf(module.data.animateOut) !== -1) haveAnimateName = module.data.animateOut;\n\t\t\t// can't be override with options.animate\n\t\t\telse if (options.animate && AnimateCSSOut.indexOf(options.animate) !== -1) haveAnimateName = options.animate;\n\n\t\t\tif (haveAnimateName) {\n\t\t\t\t// with AnimateCSS\n\t\t\t\tLog.debug(`${module.identifier} Has animateOut: ${haveAnimateName}`);\n\t\t\t\tmodule.hasAnimateOut = haveAnimateName;\n\t\t\t\taddAnimateCSS(module.identifier, haveAnimateName, speed / 1000);\n\t\t\t\tmodule.showHideTimer = setTimeout(function () {\n\t\t\t\t\tremoveAnimateCSS(module.identifier, haveAnimateName);\n\t\t\t\t\tLog.debug(`${module.identifier} Remove animateOut: ${module.hasAnimateOut}`);\n\t\t\t\t\t// AnimateCSS is now done\n\t\t\t\t\tmoduleWrapper.style.opacity = 0;\n\t\t\t\t\tmoduleWrapper.classList.add(\"hidden\");\n\t\t\t\t\tmoduleWrapper.style.position = \"fixed\";\n\t\t\t\t\tmodule.hasAnimateOut = false;\n\n\t\t\t\t\tupdateWrapperStates();\n\t\t\t\t\tif (typeof callback === \"function\") {\n\t\t\t\t\t\tcallback();\n\t\t\t\t\t}\n\t\t\t\t}, speed);\n\t\t\t} else {\n\t\t\t\t// default MM² Animate\n\t\t\t\tmoduleWrapper.style.transition = `opacity ${speed / 1000}s`;\n\t\t\t\tmoduleWrapper.style.opacity = 0;\n\t\t\t\tmoduleWrapper.classList.add(\"hidden\");\n\t\t\t\tmodule.showHideTimer = setTimeout(function () {\n\t\t\t\t\t// To not take up any space, we just make the position absolute.\n\t\t\t\t\t// since it's fade out anyway, we can see it lay above or\n\t\t\t\t\t// below other modules. This works way better than adjusting\n\t\t\t\t\t// the .display property.\n\t\t\t\t\tmoduleWrapper.style.position = \"fixed\";\n\n\t\t\t\t\tupdateWrapperStates();\n\n\t\t\t\t\tif (typeof callback === \"function\") {\n\t\t\t\t\t\tcallback();\n\t\t\t\t\t}\n\t\t\t\t}, speed);\n\t\t\t}\n\t\t} else {\n\t\t\t// invoke callback even if no content, issue 1308\n\t\t\tif (typeof callback === \"function\") {\n\t\t\t\tcallback();\n\t\t\t}\n\t\t}\n\t};\n\n\t/**\n\t * Show the module.\n\t * @param {Module} module The module to show.\n\t * @param {number} speed The speed of the show animation.\n\t * @param {Promise} callback Called when the animation is done.\n\t * @param {object} [options] Optional settings for the show method.\n\t */\n\tconst showModule = function (module, speed, callback, options = {}) {\n\t\t// remove lockString if set in options.\n\t\tif (options.lockString) {\n\t\t\tconst index = module.lockStrings.indexOf(options.lockString);\n\t\t\tif (index !== -1) {\n\t\t\t\tmodule.lockStrings.splice(index, 1);\n\t\t\t}\n\t\t}\n\n\t\t// Check if there are no more lockStrings set, or the force option is set.\n\t\t// Otherwise cancel show action.\n\t\tif (module.lockStrings.length !== 0 && options.force !== true) {\n\t\t\tLog.log(`Will not show ${module.name}. LockStrings active: ${module.lockStrings.join(\",\")}`);\n\t\t\tif (typeof options.onError === \"function\") {\n\t\t\t\toptions.onError(new Error(\"LOCK_STRING_ACTIVE\"));\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\t\t// reset all animations if needed\n\t\tif (module.hasAnimateOut) {\n\t\t\tremoveAnimateCSS(module.identifier, module.hasAnimateOut);\n\t\t\tLog.debug(`${module.identifier} Force remove animateOut (in show): ${module.hasAnimateOut}`);\n\t\t\tmodule.hasAnimateOut = false;\n\t\t}\n\t\tif (module.hasAnimateIn) {\n\t\t\tremoveAnimateCSS(module.identifier, module.hasAnimateIn);\n\t\t\tLog.debug(`${module.identifier} Force remove animateIn (in show): ${module.hasAnimateIn}`);\n\t\t\tmodule.hasAnimateIn = false;\n\t\t}\n\n\t\tmodule.hidden = false;\n\n\t\t// If forced show, clean current lockStrings.\n\t\tif (module.lockStrings.length !== 0 && options.force === true) {\n\t\t\tLog.log(`Force show of module: ${module.name}`);\n\t\t\tmodule.lockStrings = [];\n\t\t}\n\n\t\tconst moduleWrapper = document.getElementById(module.identifier);\n\t\tif (moduleWrapper !== null) {\n\t\t\tclearTimeout(module.showHideTimer);\n\n\t\t\t// haveAnimateName for verify if we are using AnimateCSS library\n\t\t\t// we check AnimateCSSIn Array for validate it\n\t\t\t// and finally return the animate name or `null` (for default MM² animation)\n\t\t\tlet haveAnimateName = null;\n\t\t\t// check if have valid animateOut in module definition (module.data.animateIn)\n\t\t\tif (module.data.animateIn && AnimateCSSIn.indexOf(module.data.animateIn) !== -1) haveAnimateName = module.data.animateIn;\n\t\t\t// can't be override with options.animate\n\t\t\telse if (options.animate && AnimateCSSIn.indexOf(options.animate) !== -1) haveAnimateName = options.animate;\n\n\t\t\tif (!haveAnimateName) moduleWrapper.style.transition = `opacity ${speed / 1000}s`;\n\t\t\t// Restore the position. See hideModule() for more info.\n\t\t\tmoduleWrapper.style.position = \"static\";\n\t\t\tmoduleWrapper.classList.remove(\"hidden\");\n\n\t\t\tupdateWrapperStates();\n\n\t\t\t// Waiting for DOM-changes done in updateWrapperStates before we can start the animation.\n\t\t\tconst dummy = moduleWrapper.parentElement.parentElement.offsetHeight;\n\t\t\tmoduleWrapper.style.opacity = 1;\n\n\t\t\tif (haveAnimateName) {\n\t\t\t\t// with AnimateCSS\n\t\t\t\tLog.debug(`${module.identifier} Has animateIn: ${haveAnimateName}`);\n\t\t\t\tmodule.hasAnimateIn = haveAnimateName;\n\t\t\t\taddAnimateCSS(module.identifier, haveAnimateName, speed / 1000);\n\t\t\t\tmodule.showHideTimer = setTimeout(function () {\n\t\t\t\t\tremoveAnimateCSS(module.identifier, haveAnimateName);\n\t\t\t\t\tLog.debug(`${module.identifier} Remove animateIn: ${haveAnimateName}`);\n\t\t\t\t\tmodule.hasAnimateIn = false;\n\t\t\t\t\tif (typeof callback === \"function\") {\n\t\t\t\t\t\tcallback();\n\t\t\t\t\t}\n\t\t\t\t}, speed);\n\t\t\t} else {\n\t\t\t\t// default MM² Animate\n\t\t\t\tmodule.showHideTimer = setTimeout(function () {\n\t\t\t\t\tif (typeof callback === \"function\") {\n\t\t\t\t\t\tcallback();\n\t\t\t\t\t}\n\t\t\t\t}, speed);\n\t\t\t}\n\t\t} else {\n\t\t\t// invoke callback\n\t\t\tif (typeof callback === \"function\") {\n\t\t\t\tcallback();\n\t\t\t}\n\t\t}\n\t};\n\n\t/**\n\t * Checks for all positions if it has visible content.\n\t * If not, if will hide the position to prevent unwanted margins.\n\t * This method should be called by the show and hide methods.\n\t *\n\t * Example:\n\t * If the top_bar only contains the update notification. And no update is available,\n\t * the update notification is hidden. The top bar still occupies space making for\n\t * an ugly top margin. By using this function, the top bar will be hidden if the\n\t * update notification is not visible.\n\t */\n\n\tconst updateWrapperStates = function () {\n\t\tmodulePositions.forEach(function (position) {\n\t\t\tconst wrapper = selectWrapper(position);\n\t\t\tconst moduleWrappers = wrapper.getElementsByClassName(\"module\");\n\n\t\t\tlet showWrapper = false;\n\t\t\tArray.prototype.forEach.call(moduleWrappers, function (moduleWrapper) {\n\t\t\t\tif (moduleWrapper.style.position === \"\" || moduleWrapper.style.position === \"static\") {\n\t\t\t\t\tshowWrapper = true;\n\t\t\t\t}\n\t\t\t});\n\n\t\t\t// move container definitions to main CSS\n\t\t\twrapper.className = showWrapper ? \"container\" : \"container hidden\";\n\t\t});\n\t};\n\n\t/**\n\t * Loads the core config and combines it with the system defaults.\n\t */\n\tconst loadConfig = function () {\n\t\t// FIXME: Think about how to pass config around without breaking tests\n\t\tif (typeof config === \"undefined\") {\n\t\t\tconfig = defaults;\n\t\t\tLog.error(\"Config file is missing! Please create a config file.\");\n\t\t\treturn;\n\t\t}\n\n\t\tconfig = Object.assign({}, defaults, config);\n\t};\n\n\t/**\n\t * Adds special selectors on a collection of modules.\n\t * @param {Module[]} modules Array of modules.\n\t */\n\tconst setSelectionMethodsForModules = function (modules) {\n\n\t\t/**\n\t\t * Filter modules with the specified classes.\n\t\t * @param {string|string[]} className one or multiple classnames (array or space divided).\n\t\t * @returns {Module[]} Filtered collection of modules.\n\t\t */\n\t\tconst withClass = function (className) {\n\t\t\treturn modulesByClass(className, true);\n\t\t};\n\n\t\t/**\n\t\t * Filter modules without the specified classes.\n\t\t * @param {string|string[]} className one or multiple classnames (array or space divided).\n\t\t * @returns {Module[]} Filtered collection of modules.\n\t\t */\n\t\tconst exceptWithClass = function (className) {\n\t\t\treturn modulesByClass(className, false);\n\t\t};\n\n\t\t/**\n\t\t * Filters a collection of modules based on classname(s).\n\t\t * @param {string|string[]} className one or multiple classnames (array or space divided).\n\t\t * @param {boolean} include if the filter should include or exclude the modules with the specific classes.\n\t\t * @returns {Module[]} Filtered collection of modules.\n\t\t */\n\t\tconst modulesByClass = function (className, include) {\n\t\t\tlet searchClasses = className;\n\t\t\tif (typeof className === \"string\") {\n\t\t\t\tsearchClasses = className.split(\" \");\n\t\t\t}\n\n\t\t\tconst newModules = modules.filter(function (module) {\n\t\t\t\tconst classes = module.data.classes.toLowerCase().split(\" \");\n\n\t\t\t\tfor (const searchClass of searchClasses) {\n\t\t\t\t\tif (classes.indexOf(searchClass.toLowerCase()) !== -1) {\n\t\t\t\t\t\treturn include;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\treturn !include;\n\t\t\t});\n\n\t\t\tsetSelectionMethodsForModules(newModules);\n\t\t\treturn newModules;\n\t\t};\n\n\t\t/**\n\t\t * Removes a module instance from the collection.\n\t\t * @param {object} module The module instance to remove from the collection.\n\t\t * @returns {Module[]} Filtered collection of modules.\n\t\t */\n\t\tconst exceptModule = function (module) {\n\t\t\tconst newModules = modules.filter(function (mod) {\n\t\t\t\treturn mod.identifier !== module.identifier;\n\t\t\t});\n\n\t\t\tsetSelectionMethodsForModules(newModules);\n\t\t\treturn newModules;\n\t\t};\n\n\t\t/**\n\t\t * Walks thru a collection of modules and executes the callback with the module as an argument.\n\t\t * @param {module} callback The function to execute with the module as an argument.\n\t\t */\n\t\tconst enumerate = function (callback) {\n\t\t\tmodules.map(function (module) {\n\t\t\t\tcallback(module);\n\t\t\t});\n\t\t};\n\n\t\tif (typeof modules.withClass === \"undefined\") {\n\t\t\tObject.defineProperty(modules, \"withClass\", { value: withClass, enumerable: false });\n\t\t}\n\t\tif (typeof modules.exceptWithClass === \"undefined\") {\n\t\t\tObject.defineProperty(modules, \"exceptWithClass\", { value: exceptWithClass, enumerable: false });\n\t\t}\n\t\tif (typeof modules.exceptModule === \"undefined\") {\n\t\t\tObject.defineProperty(modules, \"exceptModule\", { value: exceptModule, enumerable: false });\n\t\t}\n\t\tif (typeof modules.enumerate === \"undefined\") {\n\t\t\tObject.defineProperty(modules, \"enumerate\", { value: enumerate, enumerable: false });\n\t\t}\n\t};\n\n\treturn {\n\n\t\t/* Public Methods */\n\n\t\t/**\n\t\t * Main init method.\n\t\t */\n\t\tasync init () {\n\t\t\tLog.info(\"Initializing MagicMirror².\");\n\t\t\tloadConfig();\n\n\t\t\tLog.setLogLevel(config.logLevel);\n\n\t\t\tawait Translator.loadCoreTranslations(config.language);\n\t\t\tawait Loader.loadModules();\n\t\t},\n\n\t\t/**\n\t\t * Gets called when all modules are started.\n\t\t * @param {Module[]} moduleObjects All module instances.\n\t\t */\n\t\tmodulesStarted (moduleObjects) {\n\t\t\tmodules = [];\n\t\t\tlet startUp = \"\";\n\n\t\t\tmoduleObjects.forEach((module) => modules.push(module));\n\n\t\t\tLog.info(\"All modules started!\");\n\t\t\tsendNotification(\"ALL_MODULES_STARTED\");\n\n\t\t\tcreateDomObjects();\n\n\t\t\t// Setup global socket listener for RELOAD event (watch mode)\n\t\t\tif (typeof io !== \"undefined\") {\n\t\t\t\tconst socket = io(\"/\", {\n\t\t\t\t\tpath: `${config.basePath || \"/\"}socket.io`\n\t\t\t\t});\n\n\t\t\t\tsocket.on(\"RELOAD\", () => {\n\t\t\t\t\tLog.warn(\"Reload notification received from server\");\n\t\t\t\t\twindow.location.reload(true);\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tif (config.reloadAfterServerRestart) {\n\t\t\t\tsetInterval(async () => {\n\t\t\t\t\t// if server startup time has changed (which means server was restarted)\n\t\t\t\t\t// the client reloads the mm page\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst res = await fetch(`${location.protocol}//${location.host}${config.basePath}startup`);\n\t\t\t\t\t\tconst curr = await res.text();\n\t\t\t\t\t\tif (startUp === \"\") startUp = curr;\n\t\t\t\t\t\tif (startUp !== curr) {\n\t\t\t\t\t\t\tstartUp = \"\";\n\t\t\t\t\t\t\twindow.location.reload(true);\n\t\t\t\t\t\t\tLog.warn(\"Refreshing Website because server was restarted\");\n\t\t\t\t\t\t}\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tLog.error(`MagicMirror not reachable: ${err}`);\n\t\t\t\t\t}\n\t\t\t\t}, config.checkServerInterval);\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Send a notification to all modules.\n\t\t * @param {string} notification The identifier of the notification.\n\t\t * @param {object} payload The payload of the notification.\n\t\t * @param {Module} sender The module that sent the notification.\n\t\t */\n\t\tsendNotification (notification, payload, sender) {\n\t\t\tif (arguments.length < 3) {\n\t\t\t\tLog.error(\"sendNotification: Missing arguments.\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (typeof notification !== \"string\") {\n\t\t\t\tLog.error(\"sendNotification: Notification should be a string.\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (!(sender instanceof Module)) {\n\t\t\t\tLog.error(\"sendNotification: Sender should be a module.\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Further implementation is done in the private method.\n\t\t\tsendNotification(notification, payload, sender);\n\t\t},\n\n\t\t/**\n\t\t * Update the dom for a specific module.\n\t\t * @param {Module} module The module that needs an update.\n\t\t * @param {object|number} [updateOptions] The (optional) number of microseconds for the animation or object with updateOptions (speed/animates)\n\t\t */\n\t\tupdateDom (module, updateOptions) {\n\t\t\tif (!(module instanceof Module)) {\n\t\t\t\tLog.error(\"updateDom: Sender should be a module.\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (!module.data.position) {\n\t\t\t\tLog.warn(\"module tries to update the DOM without being displayed.\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Further implementation is done in the private method.\n\t\t\tupdateDom(module, updateOptions).then(function () {\n\t\t\t\t// Once the update is complete and rendered, send a notification to the module that the DOM has been updated\n\t\t\t\tsendNotification(\"MODULE_DOM_UPDATED\", null, null, module);\n\t\t\t});\n\t\t},\n\n\t\t/**\n\t\t * Returns a collection of all modules currently active.\n\t\t * @returns {Module[]} A collection of all modules currently active.\n\t\t */\n\t\tgetModules () {\n\t\t\tsetSelectionMethodsForModules(modules);\n\t\t\treturn modules;\n\t\t},\n\n\t\t/**\n\t\t * Hide the module.\n\t\t * @param {Module} module The module to hide.\n\t\t * @param {number} speed The speed of the hide animation.\n\t\t * @param {Promise} callback Called when the animation is done.\n\t\t * @param {object} [options] Optional settings for the hide method.\n\t\t */\n\t\thideModule (module, speed, callback, options) {\n\t\t\tmodule.hidden = true;\n\t\t\thideModule(module, speed, callback, options);\n\t\t},\n\n\t\t/**\n\t\t * Show the module.\n\t\t * @param {Module} module The module to show.\n\t\t * @param {number} speed The speed of the show animation.\n\t\t * @param {Promise} callback Called when the animation is done.\n\t\t * @param {object} [options] Optional settings for the show method.\n\t\t */\n\t\tshowModule (module, speed, callback, options) {\n\t\t\t// do not change module.hidden yet, only if we really show it later\n\t\t\tshowModule(module, speed, callback, options);\n\t\t},\n\n\t\t// Return all available module positions.\n\t\tgetAvailableModulePositions: modulePositions\n\t};\n}());\n\n// Add polyfill for Object.assign.\nif (typeof Object.assign !== \"function\") {\n\t(function () {\n\t\tObject.assign = function (target) {\n\t\t\t\"use strict\";\n\t\t\tif (target === undefined || target === null) {\n\t\t\t\tthrow new TypeError(\"Cannot convert undefined or null to object\");\n\t\t\t}\n\t\t\tconst output = Object(target);\n\t\t\tfor (let index = 1; index < arguments.length; index++) {\n\t\t\t\tconst source = arguments[index];\n\t\t\t\tif (source !== undefined && source !== null) {\n\t\t\t\t\tfor (const nextKey in source) {\n\t\t\t\t\t\tif (source.hasOwnProperty(nextKey)) {\n\t\t\t\t\t\t\toutput[nextKey] = source[nextKey];\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn output;\n\t\t};\n\t}());\n}\n\nMM.init();\n"
  },
  {
    "path": "js/module.js",
    "content": "/* global Class, cloneObject, Loader, MMSocket, nunjucks, Translator */\n\n/*\n * Module Blueprint.\n * @typedef {Object} Module\n */\nconst Module = Class.extend({\n\n\t/**\n\t *********************************************************\n\t * All methods (and properties) below can be overridden. *\n\t *********************************************************\n\t */\n\n\t// Set the minimum MagicMirror² module version for this module.\n\trequiresVersion: \"2.0.0\",\n\n\t// Module config defaults.\n\tdefaults: {},\n\n\t// Timer reference used for showHide animation callbacks.\n\tshowHideTimer: null,\n\n\t/*\n\t * Array to store lockStrings. These strings are used to lock\n\t * visibility when hiding and showing module.\n\t */\n\tlockStrings: [],\n\n\t/*\n\t * Storage of the nunjucks Environment,\n\t * This should not be referenced directly.\n\t * Use the nunjucksEnvironment() to get it.\n\t */\n\t_nunjucksEnvironment: null,\n\n\t/**\n\t * Called when the module is instantiated.\n\t */\n\tinit () {\n\t},\n\n\t/**\n\t * Called when the module is started.\n\t */\n\tasync start () {\n\t\tLog.info(`Starting module: ${this.name}`);\n\t},\n\n\t/**\n\t * Returns a list of scripts the module requires to be loaded.\n\t * @returns {string[]} An array with filenames.\n\t */\n\tgetScripts () {\n\t\treturn [];\n\t},\n\n\t/**\n\t * Returns a list of stylesheets the module requires to be loaded.\n\t * @returns {string[]} An array with filenames.\n\t */\n\tgetStyles () {\n\t\treturn [];\n\t},\n\n\t/**\n\t * Returns a map of translation files the module requires to be loaded.\n\t *\n\t * return Map<String, String> -\n\t * @returns {Map} A map with langKeys and filenames.\n\t */\n\tgetTranslations () {\n\t\treturn false;\n\t},\n\n\t/**\n\t * Generates the dom which needs to be displayed. This method is called by the MagicMirror² core.\n\t * This method can to be overridden if the module wants to display info on the mirror.\n\t * Alternatively, the getTemplate method could be overridden.\n\t * @returns {HTMLElement|Promise} The dom or a promise with the dom to display.\n\t */\n\tgetDom () {\n\t\treturn new Promise((resolve) => {\n\t\t\tconst div = document.createElement(\"div\");\n\t\t\tconst template = this.getTemplate();\n\t\t\tconst templateData = this.getTemplateData();\n\n\t\t\t// Check to see if we need to render a template string or a file.\n\t\t\tif ((/^.*((\\.html)|(\\.njk))$/).test(template)) {\n\t\t\t\t// the template is a filename\n\t\t\t\tthis.nunjucksEnvironment().render(template, templateData, function (err, res) {\n\t\t\t\t\tif (err) {\n\t\t\t\t\t\tLog.error(err);\n\t\t\t\t\t}\n\n\t\t\t\t\tdiv.innerHTML = res;\n\n\t\t\t\t\tresolve(div);\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\t// the template is a template string.\n\t\t\t\tdiv.innerHTML = this.nunjucksEnvironment().renderString(template, templateData);\n\n\t\t\t\tresolve(div);\n\t\t\t}\n\t\t});\n\t},\n\n\t/**\n\t * Generates the header string which needs to be displayed if a user has a header configured for this module.\n\t * This method is called by the MagicMirror² core, but only if the user has configured a default header for the module.\n\t * This method needs to be overridden if the module wants to display modified headers on the mirror.\n\t * @returns {string} The header to display above the header.\n\t */\n\tgetHeader () {\n\t\treturn this.data.header;\n\t},\n\n\t/**\n\t * Returns the template for the module which is used by the default getDom implementation.\n\t * This method needs to be overridden if the module wants to use a template.\n\t * It can either return a template string, or a template filename.\n\t * If the string ends with '.html' it's considered a file from within the module's folder.\n\t * @returns {string} The template string of filename.\n\t */\n\tgetTemplate () {\n\t\treturn `<div class=\"normal\">${this.name}</div><div class=\"small dimmed\">${this.identifier}</div>`;\n\t},\n\n\t/**\n\t * Returns the data to be used in the template.\n\t * This method needs to be overridden if the module wants to use a custom data.\n\t * @returns {object} The data for the template\n\t */\n\tgetTemplateData () {\n\t\treturn {};\n\t},\n\n\t/**\n\t * Called by the MagicMirror² core when a notification arrives.\n\t * @param {string} notification The identifier of the notification.\n\t * @param {object} payload The payload of the notification.\n\t * @param {Module} sender The module that sent the notification.\n\t */\n\tnotificationReceived (notification, payload, sender) {\n\t\tif (sender) {\n\t\t\tLog.debug(`${this.name} received a module notification: ${notification} from sender: ${sender.name}`);\n\t\t} else {\n\t\t\tLog.debug(`${this.name} received a system notification: ${notification}`);\n\t\t}\n\t},\n\n\t/**\n\t * Returns the nunjucks environment for the current module.\n\t * The environment is checked in the _nunjucksEnvironment instance variable.\n\t * @returns {object} The Nunjucks Environment\n\t */\n\tnunjucksEnvironment () {\n\t\tif (this._nunjucksEnvironment !== null) {\n\t\t\treturn this._nunjucksEnvironment;\n\t\t}\n\n\t\tthis._nunjucksEnvironment = new nunjucks.Environment(new nunjucks.WebLoader(this.file(\"\"), { async: true }), {\n\t\t\ttrimBlocks: true,\n\t\t\tlstripBlocks: true\n\t\t});\n\n\t\tthis._nunjucksEnvironment.addFilter(\"translate\", (str, variables) => {\n\t\t\treturn nunjucks.runtime.markSafe(this.translate(str, variables));\n\t\t});\n\n\t\treturn this._nunjucksEnvironment;\n\t},\n\n\t/**\n\t * Called when a socket notification arrives.\n\t * @param {string} notification The identifier of the notification.\n\t * @param {object} payload The payload of the notification.\n\t */\n\tsocketNotificationReceived (notification, payload) {\n\t\tLog.log(`${this.name} received a socket notification: ${notification} - Payload: ${payload}`);\n\t},\n\n\t/**\n\t * Called when the module is hidden.\n\t */\n\tsuspend () {\n\t\tLog.log(`${this.name} is suspended.`);\n\t},\n\n\t/**\n\t * Called when the module is shown.\n\t */\n\tresume () {\n\t\tLog.log(`${this.name} is resumed.`);\n\t},\n\n\t/**\n\t ***********************************************\n\t * The methods below should not be overridden. *\n\t ***********************************************\n\t */\n\n\t/**\n\t * Set the module data.\n\t * @param {object} data The module data\n\t */\n\tsetData (data) {\n\t\tthis.data = data;\n\t\tthis.name = data.name;\n\t\tthis.identifier = data.identifier;\n\t\tthis.hidden = false;\n\t\tthis.hasAnimateIn = false;\n\t\tthis.hasAnimateOut = false;\n\n\t\tthis.setConfig(data.config, data.configDeepMerge);\n\t},\n\n\t/**\n\t * Set the module config and combine it with the module defaults.\n\t * @param {object} config The combined module config.\n\t * @param {boolean} deep Merge module config in deep.\n\t */\n\tsetConfig (config, deep) {\n\t\tthis.config = deep ? configMerge({}, this.defaults, config) : Object.assign({}, this.defaults, config);\n\t},\n\n\t/**\n\t * Returns a socket object. If it doesn't exist, it's created.\n\t * It also registers the notification callback.\n\t * @returns {MMSocket} a socket object\n\t */\n\tsocket () {\n\t\tif (typeof this._socket === \"undefined\") {\n\t\t\tthis._socket = new MMSocket(this.name);\n\t\t}\n\n\t\tthis._socket.setNotificationCallback((notification, payload) => {\n\t\t\tthis.socketNotificationReceived(notification, payload);\n\t\t});\n\n\t\treturn this._socket;\n\t},\n\n\t/**\n\t * Retrieve the path to a module file.\n\t * @param {string} file Filename\n\t * @returns {string} the file path\n\t */\n\tfile (file) {\n\t\treturn `${this.data.path}/${file}`.replace(\"//\", \"/\");\n\t},\n\n\t/**\n\t * Load all required stylesheets by requesting the MM object to load the files.\n\t * @returns {Promise<void>}\n\t */\n\tloadStyles () {\n\t\treturn this.loadDependencies(\"getStyles\");\n\t},\n\n\t/**\n\t * Load all required scripts by requesting the MM object to load the files.\n\t * @returns {Promise<void>}\n\t */\n\tloadScripts () {\n\t\treturn this.loadDependencies(\"getScripts\");\n\t},\n\n\t/**\n\t * Helper method to load all dependencies.\n\t * @param {string} funcName Function name to call to get scripts or styles.\n\t * @returns {Promise<void>}\n\t */\n\tasync loadDependencies (funcName) {\n\t\tlet dependencies = this[funcName]();\n\n\t\tconst loadNextDependency = async () => {\n\t\t\tif (dependencies.length > 0) {\n\t\t\t\tconst nextDependency = dependencies[0];\n\t\t\t\tawait Loader.loadFileForModule(nextDependency, this);\n\t\t\t\tdependencies = dependencies.slice(1);\n\t\t\t\tawait loadNextDependency();\n\t\t\t} else {\n\t\t\t\treturn Promise.resolve();\n\t\t\t}\n\t\t};\n\n\t\tawait loadNextDependency();\n\t},\n\n\t/**\n\t * Load all translations.\n\t * @returns {Promise<void>}\n\t */\n\tasync loadTranslations () {\n\t\tconst translations = this.getTranslations() || {};\n\t\tconst language = config.language.toLowerCase();\n\n\t\tconst languages = Object.keys(translations);\n\t\tconst fallbackLanguage = languages[0];\n\n\t\tif (languages.length === 0) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst translationFile = translations[language];\n\t\tconst translationsFallbackFile = translations[fallbackLanguage];\n\n\t\tif (!translationFile) {\n\t\t\treturn Translator.load(this, translationsFallbackFile, true);\n\t\t}\n\n\t\tawait Translator.load(this, translationFile, false);\n\n\t\tif (translationFile !== translationsFallbackFile) {\n\t\t\treturn Translator.load(this, translationsFallbackFile, true);\n\t\t}\n\t},\n\n\t/**\n\t * Request the translation for a given key with optional variables and default value.\n\t * @param {string} key The key of the string to translate\n\t * @param {string|object} [defaultValueOrVariables] The default value or variables for translating.\n\t * @param {string} [defaultValue] The default value with variables.\n\t * @returns {string} the translated key\n\t */\n\ttranslate (key, defaultValueOrVariables, defaultValue) {\n\t\tif (typeof defaultValueOrVariables === \"object\") {\n\t\t\treturn Translator.translate(this, key, defaultValueOrVariables) || defaultValue || \"\";\n\t\t}\n\t\treturn Translator.translate(this, key) || defaultValueOrVariables || \"\";\n\t},\n\n\t/**\n\t * Request an (animated) update of the module.\n\t * @param {number|object} [updateOptions] The speed of the animation or object with for updateOptions (speed/animates)\n\t */\n\tupdateDom (updateOptions) {\n\t\tMM.updateDom(this, updateOptions);\n\t},\n\n\t/**\n\t * Send a notification to all modules.\n\t * @param {string} notification The identifier of the notification.\n\t * @param {object} payload The payload of the notification.\n\t */\n\tsendNotification (notification, payload) {\n\t\tMM.sendNotification(notification, payload, this);\n\t},\n\n\t/**\n\t * Send a socket notification to the node helper.\n\t * @param {string} notification The identifier of the notification.\n\t * @param {object} payload The payload of the notification.\n\t */\n\tsendSocketNotification (notification, payload) {\n\t\tthis.socket().sendNotification(notification, payload);\n\t},\n\n\t/**\n\t * Hide this module.\n\t * @param {number} speed The speed of the hide animation.\n\t * @param {Promise} callback Called when the animation is done.\n\t * @param {object} [options] Optional settings for the hide method.\n\t */\n\thide (speed, callback, options = {}) {\n\t\tlet usedCallback = callback || function () {};\n\t\tlet usedOptions = options;\n\n\t\tif (typeof callback === \"object\") {\n\t\t\tLog.error(\"Parameter mismatch in module.hide: callback is not an optional parameter!\");\n\t\t\tusedOptions = callback;\n\t\t\tusedCallback = function () {};\n\t\t}\n\n\t\tMM.hideModule(\n\t\t\tthis,\n\t\t\tspeed,\n\t\t\t() => {\n\t\t\t\tthis.suspend();\n\t\t\t\tusedCallback();\n\t\t\t},\n\t\t\tusedOptions\n\t\t);\n\t},\n\n\t/**\n\t * Show this module.\n\t * @param {number} speed The speed of the show animation.\n\t * @param {Promise} callback Called when the animation is done.\n\t * @param {object} [options] Optional settings for the show method.\n\t */\n\tshow (speed, callback, options) {\n\t\tlet usedCallback = callback || function () {};\n\t\tlet usedOptions = options;\n\n\t\tif (typeof callback === \"object\") {\n\t\t\tLog.error(\"Parameter mismatch in module.show: callback is not an optional parameter!\");\n\t\t\tusedOptions = callback;\n\t\t\tusedCallback = function () {};\n\t\t}\n\n\t\tMM.showModule(\n\t\t\tthis,\n\t\t\tspeed,\n\t\t\t() => {\n\t\t\t\tthis.resume();\n\t\t\t\tusedCallback();\n\t\t\t},\n\t\t\tusedOptions\n\t\t);\n\t}\n});\n\n/**\n * Merging MagicMirror² (or other) default/config script by `@bugsounet`\n * Merge 2 objects or/with array\n *\n * Usage:\n * -------\n * this.config = configMerge({}, this.defaults, this.config)\n * -------\n * arg1: initial object\n * arg2: config model\n * arg3: config to merge\n * -------\n * why using it ?\n * Object.assign() function don't to all job\n * it don't merge all thing in deep\n * -> object in object and array is not merging\n * -------\n *\n * Todo: idea of Mich determinate what do you want to merge or not\n * @param {object} result the initial object\n * @returns {object} the merged config\n */\nfunction configMerge (result) {\n\tconst stack = Array.prototype.slice.call(arguments, 1);\n\tlet item, key;\n\n\twhile (stack.length) {\n\t\titem = stack.shift();\n\t\tfor (key in item) {\n\t\t\tif (item.hasOwnProperty(key)) {\n\t\t\t\tif (typeof result[key] === \"object\" && result[key] && Object.prototype.toString.call(result[key]) !== \"[object Array]\") {\n\t\t\t\t\tif (typeof item[key] === \"object\" && item[key] !== null) {\n\t\t\t\t\t\tresult[key] = configMerge({}, result[key], item[key]);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tresult[key] = item[key];\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tresult[key] = item[key];\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn result;\n}\n\nModule.definitions = {};\n\nModule.create = function (name) {\n\t// Make sure module definition is available.\n\tif (!Module.definitions[name]) {\n\t\treturn;\n\t}\n\n\tconst moduleDefinition = Module.definitions[name];\n\tconst clonedDefinition = cloneObject(moduleDefinition);\n\n\t// Note that we clone the definition. Otherwise the objects are shared, which gives problems.\n\tconst ModuleClass = Module.extend(clonedDefinition);\n\n\treturn new ModuleClass();\n};\n\nModule.register = function (name, moduleDefinition) {\n\tif (moduleDefinition.requiresVersion) {\n\t\tLog.log(`Check MagicMirror² version for module '${name}' - Minimum version:  ${moduleDefinition.requiresVersion} - Current version: ${window.mmVersion}`);\n\t\tif (cmpVersions(window.mmVersion, moduleDefinition.requiresVersion) >= 0) {\n\t\t\tLog.log(\"Version is ok!\");\n\t\t} else {\n\t\t\tLog.warn(`Version is incorrect. Skip module: '${name}'`);\n\t\t\treturn;\n\t\t}\n\t}\n\tLog.log(`Module registered: ${name}`);\n\tModule.definitions[name] = moduleDefinition;\n};\n\nwindow.Module = Module;\n\n/**\n * Compare two semantic version numbers and return the difference.\n * @param {string} a Version number a.\n * @param {string} b Version number b.\n * @returns {number} A positive number if a is larger than b, a negative\n * number if a is smaller and 0 if they are the same\n */\nfunction cmpVersions (a, b) {\n\tconst regExStrip0 = /(\\.0+)+$/;\n\tconst segmentsA = a.replace(regExStrip0, \"\").split(\".\");\n\tconst segmentsB = b.replace(regExStrip0, \"\").split(\".\");\n\tconst l = Math.min(segmentsA.length, segmentsB.length);\n\n\tfor (let i = 0; i < l; i++) {\n\t\tlet diff = parseInt(segmentsA[i], 10) - parseInt(segmentsB[i], 10);\n\t\tif (diff) {\n\t\t\treturn diff;\n\t\t}\n\t}\n\treturn segmentsA.length - segmentsB.length;\n}\n"
  },
  {
    "path": "js/module_functions.js",
    "content": "/**\n * Schedule the timer for the next update\n * @param {object} timer The timer of the module\n * @param {bigint} intervalMS interval in milliseconds\n * @param {Promise} callback function to call when the timer expires\n */\nconst scheduleTimer = function (timer, intervalMS, callback) {\n\tif (process.env.mmTestMode !== \"true\") {\n\t\t// only set timer when not running in test mode\n\t\tlet tmr = timer;\n\t\tclearTimeout(tmr);\n\t\ttmr = setTimeout(function () {\n\t\t\tcallback();\n\t\t}, intervalMS);\n\t}\n};\n\nmodule.exports = { scheduleTimer };\n"
  },
  {
    "path": "js/node_helper.js",
    "content": "const express = require(\"express\");\nconst Log = require(\"logger\");\nconst Class = require(\"./class\");\n\nconst NodeHelper = Class.extend({\n\tinit () {\n\t\tLog.log(\"Initializing new module helper ...\");\n\t},\n\n\tloaded () {\n\t\tLog.log(`Module helper loaded: ${this.name}`);\n\t},\n\n\tstart () {\n\t\tLog.log(`Starting module helper: ${this.name}`);\n\t},\n\n\t/**\n\t * Called when the MagicMirror² server receives a `SIGINT`\n\t * Close any open connections, stop any sub-processes and\n\t * gracefully exit the module.\n\t */\n\tstop () {\n\t\tLog.log(`Stopping module helper: ${this.name}`);\n\t},\n\n\t/**\n\t * This method is called when a socket notification arrives.\n\t * @param {string} notification The identifier of the notification.\n\t * @param {object}  payload The payload of the notification.\n\t */\n\tsocketNotificationReceived (notification, payload) {\n\t\tLog.log(`${this.name} received a socket notification: ${notification} - Payload: ${payload}`);\n\t},\n\n\t/**\n\t * Set the module name.\n\t * @param {string} name Module name.\n\t */\n\tsetName (name) {\n\t\tthis.name = name;\n\t},\n\n\t/**\n\t * Set the module path.\n\t * @param {string} path Module path.\n\t */\n\tsetPath (path) {\n\t\tthis.path = path;\n\t},\n\n\t/*\n\t * sendSocketNotification(notification, payload)\n\t * Send a socket notification to the node helper.\n\t *\n\t * argument notification string - The identifier of the notification.\n\t * argument payload mixed - The payload of the notification.\n\t */\n\tsendSocketNotification (notification, payload) {\n\t\tthis.io.of(this.name).emit(notification, payload);\n\t},\n\n\t/*\n\t * setExpressApp(app)\n\t * Sets the express app object for this module.\n\t * This allows you to host files from the created webserver.\n\t *\n\t * argument app Express app - The Express app object.\n\t */\n\tsetExpressApp (app) {\n\t\tthis.expressApp = app;\n\n\t\tapp.use(`/${this.name}`, express.static(`${this.path}/public`));\n\t},\n\n\t/*\n\t * setSocketIO(io)\n\t * Sets the socket io object for this module.\n\t * Binds message receiver.\n\t *\n\t * argument io Socket.io - The Socket io object.\n\t */\n\tsetSocketIO (io) {\n\t\tthis.io = io;\n\n\t\tLog.log(`Connecting socket for: ${this.name}`);\n\n\t\tio.of(this.name).on(\"connection\", (socket) => {\n\t\t\t// register catch all.\n\t\t\tsocket.onAny((notification, payload) => {\n\t\t\t\tthis.socketNotificationReceived(notification, payload);\n\t\t\t});\n\t\t});\n\t}\n});\n\nNodeHelper.checkFetchStatus = function (response) {\n\t// response.status >= 200 && response.status < 300\n\tif (response.ok) {\n\t\treturn response;\n\t} else {\n\t\tthrow Error(response.statusText);\n\t}\n};\n\n/**\n * Look at the specified error and return an appropriate error type, that\n * can be translated to a detailed error message\n * @param {Error} error the error from fetching something\n * @returns {string} the string of the detailed error message in the translations\n */\nNodeHelper.checkFetchError = function (error) {\n\tlet error_type = \"MODULE_ERROR_UNSPECIFIED\";\n\tif (error.code === \"EAI_AGAIN\") {\n\t\terror_type = \"MODULE_ERROR_NO_CONNECTION\";\n\t} else {\n\t\tconst message = typeof error.message === \"string\" ? error.message.toLowerCase() : \"\";\n\t\tif (message.includes(\"unauthorized\") || message.includes(\"http 401\") || message.includes(\"http 403\")) {\n\t\t\terror_type = \"MODULE_ERROR_UNAUTHORIZED\";\n\t\t}\n\t}\n\treturn error_type;\n};\n\nNodeHelper.create = function (moduleDefinition) {\n\treturn NodeHelper.extend(moduleDefinition);\n};\n\nmodule.exports = NodeHelper;\n"
  },
  {
    "path": "js/releasenotes.js",
    "content": "/* eslint no-console: \"off\" */\nconst util = require(\"node:util\");\nconst exec = util.promisify(require(\"node:child_process\").exec);\nconst fs = require(\"node:fs\");\n\nconst createReleaseNotes = async () => {\n\tlet repoName = \"MagicMirrorOrg/MagicMirror\";\n\tif (process.env.GITHUB_REPOSITORY) {\n\t\trepoName = process.env.GITHUB_REPOSITORY;\n\t}\n\tconst baseUrl = `https://api.github.com/repos/${repoName}`;\n\n\tconst getOptions = (type) => {\n\t\tif (process.env.GITHUB_TOKEN) {\n\t\t\treturn { method: `${type}`, headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } };\n\t\t} else {\n\t\t\treturn { method: `${type}` };\n\t\t}\n\t};\n\n\tconst execShell = async (command) => {\n\t\tconst { stdout = \"\", stderr = \"\" } = await exec(command);\n\t\tif (stderr) console.error(`Error in execShell executing command ${command}: ${stderr}`);\n\t\treturn stdout;\n\t};\n\n\t// Check Draft Release\n\tconst draftReleases = [];\n\tconst jsonReleases = await fetch(`${baseUrl}/releases`, getOptions(\"GET\")).then((res) => res.json());\n\tfor (const rel of jsonReleases) {\n\t\tif (rel.draft && rel.tag_name === \"\" && rel.published_at === null && rel.name === \"unreleased\") draftReleases.push(rel);\n\t}\n\n\tlet draftReleaseId = 0;\n\tif (draftReleases.length > 1) {\n\t\tthrow new Error(\"More than one draft release found, exiting.\");\n\t} else {\n\t\tif (draftReleases[0]) draftReleaseId = draftReleases[0].id;\n\t}\n\n\t// Get last Git Tag\n\tconst gitTag = await execShell(\"git describe --tags `git rev-list --tags --max-count=1`\");\n\tconst lastTag = gitTag.toString().replaceAll(\"\\n\", \"\");\n\tconsole.info(`latest tag is ${lastTag}`);\n\n\t// Get Git Commits\n\tconst gitOut = await execShell(`git log develop --pretty=format:\"%H --- %s\" --after=\"$(git log -1 --format=%aI ${lastTag})\"`);\n\tconsole.info(gitOut);\n\tconst commits = gitOut.toString().split(\"\\n\");\n\n\t// Get Node engine version from package.json\n\tconst nodeVersion = JSON.parse(fs.readFileSync(\"package.json\")).engines.node;\n\n\t// Search strings\n\tconst labelArr = [\"alert\", \"calendar\", \"clock\", \"compliments\", \"helloworld\", \"newsfeed\", \"updatenotification\", \"weather\", \"envcanada\", \"openmeteo\", \"openweathermap\", \"smhi\", \"ukmetoffice\", \"yr\", \"eslint\", \"bump\", \"dependencies\", \"deps\", \"logg\", \"translation\", \"test\", \"ci\"];\n\n\t// Map search strings to categories\n\tconst getFirstLabel = (text) => {\n\t\tlet res;\n\t\tlabelArr.every((item) => {\n\t\t\tconst labelIncl = text.includes(item);\n\t\t\tif (labelIncl) {\n\t\t\t\tswitch (item) {\n\t\t\t\t\tcase \"ci\":\n\t\t\t\t\tcase \"test\":\n\t\t\t\t\t\tres = \"testing\";\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase \"logg\":\n\t\t\t\t\t\tres = \"logging\";\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase \"eslint\":\n\t\t\t\t\tcase \"bump\":\n\t\t\t\t\tcase \"deps\":\n\t\t\t\t\t\tres = \"dependencies\";\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase \"envcanada\":\n\t\t\t\t\tcase \"openmeteo\":\n\t\t\t\t\tcase \"openweathermap\":\n\t\t\t\t\tcase \"smhi\":\n\t\t\t\t\tcase \"ukmetoffice\":\n\t\t\t\t\tcase \"yr\":\n\t\t\t\t\tcase \"weather\":\n\t\t\t\t\t\tres = \"modules/weather\";\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase \"alert\":\n\t\t\t\t\t\tres = \"modules/alert\";\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase \"calendar\":\n\t\t\t\t\t\tres = \"modules/calendar\";\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase \"clock\":\n\t\t\t\t\t\tres = \"modules/clock\";\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase \"compliments\":\n\t\t\t\t\t\tres = \"modules/compliments\";\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase \"helloworld\":\n\t\t\t\t\t\tres = \"modules/helloworld\";\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase \"newsfeed\":\n\t\t\t\t\t\tres = \"modules/newsfeed\";\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcase \"updatenotification\":\n\t\t\t\t\t\tres = \"modules/updatenotification\";\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tdefault:\n\t\t\t\t\t\tres = item;\n\t\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\treturn false;\n\t\t\t} else {\n\t\t\t\treturn true;\n\t\t\t}\n\t\t});\n\t\tif (!res) res = \"core\";\n\t\treturn res;\n\t};\n\n\tconst grouped = {};\n\tconst contrib = [];\n\tconst sha = [];\n\n\t// Loop through each Commit\n\tfor (const item of commits) {\n\n\t\tconst cm = item.trim();\n\t\t// ignore `prepare release` line\n\t\tif (cm.length > 0 && !cm.match(/^.* --- prepare .*-develop$/gi)) {\n\n\t\t\tconst [ref, title] = cm.split(\" --- \");\n\n\t\t\tconst groupTitle = getFirstLabel(title.toLowerCase());\n\n\t\t\tif (!grouped[groupTitle]) {\n\t\t\t\tgrouped[groupTitle] = [];\n\t\t\t}\n\n\t\t\tgrouped[groupTitle].push(`- ${title}`);\n\n\t\t\tsha.push(ref);\n\t\t}\n\t}\n\n\t// function to remove duplicates\n\tconst sortedArr = (arr) => {\n\t\treturn arr.filter((item,\n\t\t\tindex) => (arr.indexOf(item) === index && item !== \"@dependabot[bot]\")).sort(function (a, b) {\n\t\t\treturn a.toLowerCase().localeCompare(b.toLowerCase());\n\t\t});\n\t};\n\n\t// Get Contributors logins\n\tfor (const ref of sha) {\n\t\tconst jsonRes = await fetch(`${baseUrl}/commits/${ref}`, getOptions(\"GET\")).then((res) => res.json());\n\n\t\tif (jsonRes && jsonRes.author && jsonRes.author.login) contrib.push(`@${jsonRes.author.login}`);\n\t}\n\n\t// Build Markdown content\n\tlet markdown = \"## Release Notes\\n\";\n\n\tmarkdown += `Thanks to: ${sortedArr(contrib).join(\", \")}\\n`;\n\tmarkdown += `> ⚠️ This release needs nodejs version ${nodeVersion}\\n`;\n\tmarkdown += \"\\n\";\n\tmarkdown += `[Compare to previous Release ${lastTag}](https://github.com/${repoName}/compare/${lastTag}...develop)\\n\\n`;\n\n\tconst sorted = Object.keys(grouped)\n\t\t.sort() // Sort the keys alphabetically\n\t\t.reduce((obj, key) => {\n\t\t\tobj[key] = grouped[key]; // Rebuild the object with sorted keys\n\t\t\treturn obj;\n\t\t}, {});\n\n\tfor (const group in sorted) {\n\t\tmarkdown += `\\n### [${group}]\\n`;\n\t\tmarkdown += `${sorted[group].join(\"\\n\")}\\n`;\n\t}\n\n\tconsole.info(markdown);\n\n\t// Create Github Release\n\tif (process.env.GITHUB_TOKEN) {\n\t\tif (draftReleaseId > 0) {\n\t\t\t// delete release\n\t\t\tawait fetch(`${baseUrl}/releases/${draftReleaseId}`, getOptions(\"DELETE\"));\n\t\t\tconsole.info(`Old Release with id ${draftReleaseId} deleted.`);\n\t\t}\n\n\t\tconst relContent = getOptions(\"POST\");\n\t\trelContent.body = JSON.stringify(\n\t\t\t{ tag_name: \"\", name: \"unreleased\", body: `${markdown}`, draft: true }\n\t\t);\n\t\tconst createRelease = await fetch(`${baseUrl}/releases`, relContent).then((res) => res.json());\n\t\tconsole.info(`New release created with id ${createRelease.id}, GitHub-Url: ${createRelease.html_url}`);\n\t}\n};\n\ncreateReleaseNotes();\n"
  },
  {
    "path": "js/server.js",
    "content": "const fs = require(\"node:fs\");\nconst http = require(\"node:http\");\nconst https = require(\"node:https\");\nconst path = require(\"node:path\");\nconst express = require(\"express\");\nconst helmet = require(\"helmet\");\nconst socketio = require(\"socket.io\");\nconst Log = require(\"logger\");\nconst { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars } = require(\"#server_functions\");\n\nconst { ipAccessControl } = require(`${__dirname}/ip_access_control`);\n\nconst vendor = require(`${__dirname}/vendor`);\n\n/**\n * Server\n * @param {object} config The MM config\n * @class\n */\nfunction Server (config) {\n\tconst app = express();\n\tconst port = process.env.MM_PORT || config.port;\n\tconst serverSockets = new Set();\n\tlet server = null;\n\n\t/**\n\t * Opens the server for incoming connections\n\t * @returns {Promise} A promise that is resolved when the server listens to connections\n\t */\n\tthis.open = function () {\n\t\treturn new Promise((resolve) => {\n\t\t\tif (config.useHttps) {\n\t\t\t\tconst options = {\n\t\t\t\t\tkey: fs.readFileSync(config.httpsPrivateKey),\n\t\t\t\t\tcert: fs.readFileSync(config.httpsCertificate)\n\t\t\t\t};\n\t\t\t\tserver = https.Server(options, app);\n\t\t\t} else {\n\t\t\t\tserver = http.Server(app);\n\t\t\t}\n\t\t\tconst io = socketio(server, {\n\t\t\t\tcors: {\n\t\t\t\t\torigin: /.*$/,\n\t\t\t\t\tcredentials: true\n\t\t\t\t},\n\t\t\t\tallowEIO3: true,\n\t\t\t\tpingInterval: 120000, // server → client ping every 2 mins\n\t\t\t\tpingTimeout: 120000 // wait up to 2 mins for client pong\n\t\t\t});\n\n\t\t\tserver.on(\"connection\", (socket) => {\n\t\t\t\tserverSockets.add(socket);\n\t\t\t\tsocket.on(\"close\", () => {\n\t\t\t\t\tserverSockets.delete(socket);\n\t\t\t\t});\n\t\t\t});\n\n\t\t\tLog.log(`Starting server on port ${port} ... `);\n\n\t\t\t// Add explicit error handling BEFORE calling listen so we can give user-friendly feedback\n\t\t\tserver.once(\"error\", (err) => {\n\t\t\t\tif (err && err.code === \"EADDRINUSE\") {\n\t\t\t\t\tconst bindAddr = config.address || \"localhost\";\n\t\t\t\t\tconst portInUseMessage = [\n\t\t\t\t\t\t\"\",\n\t\t\t\t\t\t\"────────────────────────────────────────────────────────────────\",\n\t\t\t\t\t\t` PORT IN USE: ${bindAddr}:${port}`,\n\t\t\t\t\t\t\"\",\n\t\t\t\t\t\t\" Another process (most likely another MagicMirror instance)\",\n\t\t\t\t\t\t\" is already using this port.\",\n\t\t\t\t\t\t\"\",\n\t\t\t\t\t\t\" Stop the other process (free the port) or use a different port.\",\n\t\t\t\t\t\t\"────────────────────────────────────────────────────────────────\"\n\t\t\t\t\t].join(\"\\n\");\n\t\t\t\t\tLog.error(portInUseMessage);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tLog.error(\"Failed to start server:\", err);\n\t\t\t});\n\n\t\t\tserver.listen(port, config.address || \"localhost\");\n\n\t\t\tif (config.ipWhitelist instanceof Array && config.ipWhitelist.length === 0) {\n\t\t\t\tLog.warn(\"You're using a full whitelist configuration to allow for all IPs\");\n\t\t\t}\n\n\t\t\tapp.use(ipAccessControl(config.ipWhitelist));\n\t\t\tapp.use(helmet(config.httpHeaders));\n\t\t\tapp.use(\"/js\", express.static(__dirname));\n\n\t\t\tlet directories = [\"/config\", \"/css\", \"/modules\", \"/node_modules/animate.css\", \"/node_modules/@fontsource\", \"/node_modules/@fortawesome\", \"/translations\", \"/tests/configs\", \"/tests/mocks\"];\n\t\t\tfor (const [key, value] of Object.entries(vendor)) {\n\t\t\t\tconst dirArr = value.split(\"/\");\n\t\t\t\tif (dirArr[0] === \"node_modules\") directories.push(`/${dirArr[0]}/${dirArr[1]}`);\n\t\t\t}\n\t\t\tconst uniqDirs = [...new Set(directories)];\n\t\t\tfor (const directory of uniqDirs) {\n\t\t\t\tapp.use(directory, express.static(path.resolve(global.root_path + directory)));\n\t\t\t}\n\n\t\t\tapp.get(\"/cors\", async (req, res) => await cors(req, res));\n\n\t\t\tapp.get(\"/version\", (req, res) => getVersion(req, res));\n\n\t\t\tapp.get(\"/config\", (req, res) => getConfig(req, res));\n\n\t\t\tapp.get(\"/startup\", (req, res) => getStartup(req, res));\n\n\t\t\tapp.get(\"/env\", (req, res) => getEnvVars(req, res));\n\n\t\t\tapp.get(\"/\", (req, res) => getHtml(req, res));\n\n\t\t\t// Reload endpoint for watch mode - triggers browser reload\n\t\t\tapp.get(\"/reload\", (req, res) => {\n\t\t\t\tLog.info(\"Reload request received, notifying all clients\");\n\t\t\t\tio.emit(\"RELOAD\");\n\t\t\t\tres.status(200).send(\"OK\");\n\t\t\t});\n\n\t\t\tserver.on(\"listening\", () => {\n\t\t\t\tresolve({\n\t\t\t\t\tapp,\n\t\t\t\t\tio\n\t\t\t\t});\n\t\t\t});\n\t\t});\n\t};\n\n\t/**\n\t * Closes the server and destroys all lingering connections to it.\n\t * @returns {Promise} A promise that resolves when server has successfully shut down\n\t */\n\tthis.close = function () {\n\t\treturn new Promise((resolve) => {\n\t\t\tfor (const socket of serverSockets.values()) {\n\t\t\t\tsocket.destroy();\n\t\t\t}\n\t\t\tserver.close(resolve);\n\t\t});\n\t};\n}\n\nmodule.exports = Server;\n"
  },
  {
    "path": "js/server_functions.js",
    "content": "const fs = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst Log = require(\"logger\");\n\nconst startUp = new Date();\n\n/**\n * Gets the config.\n * @param {Request} req - the request\n * @param {Response} res - the result\n */\nfunction getConfig (req, res) {\n\tres.send(config);\n}\n\n/**\n * Gets the startup time.\n * @param {Request} req - the request\n * @param {Response} res - the result\n */\nfunction getStartup (req, res) {\n\tres.send(startUp);\n}\n\n/**\n * A method that forwards HTTP Get-methods to the internet to avoid CORS-errors.\n *\n * Example input request url: /cors?sendheaders=header1:value1,header2:value2&expectedheaders=header1,header2&url=http://www.test.com/path?param1=value1\n *\n * Only the url-param of the input request url is required. It must be the last parameter.\n * @param {Request} req - the request\n * @param {Response} res - the result\n * @returns {Promise<void>} A promise that resolves when the response is sent\n */\nasync function cors (req, res) {\n\ttry {\n\t\tconst urlRegEx = \"url=(.+?)$\";\n\t\tlet url;\n\n\t\tconst match = new RegExp(urlRegEx, \"g\").exec(req.url);\n\t\tif (!match) {\n\t\t\turl = `invalid url: ${req.url}`;\n\t\t\tLog.error(url);\n\t\t\treturn res.status(400).send(url);\n\t\t} else {\n\t\t\turl = match[1];\n\n\t\t\tconst headersToSend = getHeadersToSend(req.url);\n\t\t\tconst expectedReceivedHeaders = geExpectedReceivedHeaders(req.url);\n\t\t\tLog.log(`cors url: ${url}`);\n\n\t\t\tconst response = await fetch(url, { headers: headersToSend });\n\t\t\tif (response.ok) {\n\t\t\t\tfor (const header of expectedReceivedHeaders) {\n\t\t\t\t\tconst headerValue = response.headers.get(header);\n\t\t\t\t\tif (header) res.set(header, headerValue);\n\t\t\t\t}\n\t\t\t\tconst data = await response.text();\n\t\t\t\tres.send(data);\n\t\t\t} else {\n\t\t\t\tthrow new Error(`Response status: ${response.status}`);\n\t\t\t}\n\t\t}\n\t} catch (error) {\n\t\t// Only log errors in non-test environments to keep test output clean\n\t\tif (process.env.mmTestMode !== \"true\") {\n\t\t\tLog.error(`Error in CORS request: ${error}`);\n\t\t}\n\t\tres.status(500).json({ error: error.message });\n\t}\n}\n\n/**\n * Gets headers and values to attach to the web request.\n * @param {string} url - The url containing the headers and values to send.\n * @returns {object} An object specifying name and value of the headers.\n */\nfunction getHeadersToSend (url) {\n\tconst headersToSend = { \"User-Agent\": getUserAgent() };\n\tconst headersToSendMatch = new RegExp(\"sendheaders=(.+?)(&|$)\", \"g\").exec(url);\n\tif (headersToSendMatch) {\n\t\tconst headers = headersToSendMatch[1].split(\",\");\n\t\tfor (const header of headers) {\n\t\t\tconst keyValue = header.split(\":\");\n\t\t\tif (keyValue.length !== 2) {\n\t\t\t\tthrow new Error(`Invalid format for header ${header}`);\n\t\t\t}\n\t\t\theadersToSend[keyValue[0]] = decodeURIComponent(keyValue[1]);\n\t\t}\n\t}\n\treturn headersToSend;\n}\n\n/**\n * Gets the headers expected from the response.\n * @param {string} url - The url containing the expected headers from the response.\n * @returns {string[]} headers - The name of the expected headers.\n */\nfunction geExpectedReceivedHeaders (url) {\n\tconst expectedReceivedHeaders = [\"Content-Type\"];\n\tconst expectedReceivedHeadersMatch = new RegExp(\"expectedheaders=(.+?)(&|$)\", \"g\").exec(url);\n\tif (expectedReceivedHeadersMatch) {\n\t\tconst headers = expectedReceivedHeadersMatch[1].split(\",\");\n\t\tfor (const header of headers) {\n\t\t\texpectedReceivedHeaders.push(header);\n\t\t}\n\t}\n\treturn expectedReceivedHeaders;\n}\n\n/**\n * Gets the HTML to display the magic mirror.\n * @param {Request} req - the request\n * @param {Response} res - the result\n */\nfunction getHtml (req, res) {\n\tlet html = fs.readFileSync(path.resolve(`${global.root_path}/index.html`), { encoding: \"utf8\" });\n\thtml = html.replace(\"#VERSION#\", global.version);\n\thtml = html.replace(\"#TESTMODE#\", global.mmTestMode);\n\n\tlet configFile = \"config/config.js\";\n\tif (typeof global.configuration_file !== \"undefined\") {\n\t\tconfigFile = global.configuration_file;\n\t}\n\thtml = html.replace(\"#CONFIG_FILE#\", configFile);\n\n\tres.send(html);\n}\n\n/**\n * Gets the MagicMirror version.\n * @param {Request} req - the request\n * @param {Response} res - the result\n */\nfunction getVersion (req, res) {\n\tres.send(global.version);\n}\n\n/**\n * Gets the preferred `User-Agent`\n * @returns {string} `User-Agent` to be used\n */\nfunction getUserAgent () {\n\tconst defaultUserAgent = `Mozilla/5.0 (Node.js ${Number(process.version.match(/^v(\\d+\\.\\d+)/)[1])}) MagicMirror/${global.version}`;\n\n\tif (typeof config === \"undefined\") {\n\t\treturn defaultUserAgent;\n\t}\n\n\tswitch (typeof config.userAgent) {\n\t\tcase \"function\":\n\t\t\treturn config.userAgent();\n\t\tcase \"string\":\n\t\t\treturn config.userAgent;\n\t\tdefault:\n\t\t\treturn defaultUserAgent;\n\t}\n}\n\n/**\n * Gets environment variables needed in the browser.\n * @returns {object} environment variables key: values\n */\nfunction getEnvVarsAsObj () {\n\tconst obj = { modulesDir: `${config.foreignModulesDir}`, customCss: `${config.customCss}` };\n\tif (process.env.MM_MODULES_DIR) {\n\t\tobj.modulesDir = process.env.MM_MODULES_DIR.replace(`${global.root_path}/`, \"\");\n\t}\n\tif (process.env.MM_CUSTOMCSS_FILE) {\n\t\tobj.customCss = process.env.MM_CUSTOMCSS_FILE.replace(`${global.root_path}/`, \"\");\n\t}\n\n\treturn obj;\n}\n\n/**\n * Gets environment variables needed in the browser.\n * @param {Request} req - the request\n * @param {Response} res - the result\n */\nfunction getEnvVars (req, res) {\n\tconst obj = getEnvVarsAsObj();\n\tres.send(obj);\n}\n\n/**\n * Get the config file path from environment or default location\n * @returns {string} The absolute config file path\n */\nfunction getConfigFilePath () {\n\t// Ensure root_path is set (for standalone contexts like watcher)\n\tif (!global.root_path) {\n\t\tglobal.root_path = path.resolve(`${__dirname}/../`);\n\t}\n\n\t// Check environment variable if global not set\n\tif (!global.configuration_file && process.env.MM_CONFIG_FILE) {\n\t\tglobal.configuration_file = process.env.MM_CONFIG_FILE;\n\t}\n\n\treturn path.resolve(global.configuration_file || `${global.root_path}/config/config.js`);\n}\n\nmodule.exports = { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars, getEnvVarsAsObj, getUserAgent, getConfigFilePath };\n"
  },
  {
    "path": "js/socketclient.js",
    "content": "/* global io */\n\nconst MMSocket = function (moduleName) {\n\tif (typeof moduleName !== \"string\") {\n\t\tthrow new Error(\"Please set the module name for the MMSocket.\");\n\t}\n\n\tthis.moduleName = moduleName;\n\n\t// Private Methods\n\tlet base = \"/\";\n\tif (typeof config !== \"undefined\" && typeof config.basePath !== \"undefined\") {\n\t\tbase = config.basePath;\n\t}\n\tthis.socket = io(`/${this.moduleName}`, {\n\t\tpath: `${base}socket.io`,\n\t\tpingInterval: 120000, // send pings every 2 mins\n\t\tpingTimeout: 120000 // wait up to 2 mins for a pong\n\t});\n\n\tlet notificationCallback = function () {};\n\n\tconst onevent = this.socket.onevent;\n\tthis.socket.onevent = (packet) => {\n\t\tconst args = packet.data || [];\n\t\tonevent.call(this.socket, packet); // original call\n\t\tpacket.data = [\"*\"].concat(args);\n\t\tonevent.call(this.socket, packet); // additional call to catch-all\n\t};\n\n\t// register catch all.\n\tthis.socket.on(\"*\", (notification, payload) => {\n\t\tif (notification !== \"*\") {\n\t\t\tnotificationCallback(notification, payload);\n\t\t}\n\t});\n\n\t// Public Methods\n\tthis.setNotificationCallback = (callback) => {\n\t\tnotificationCallback = callback;\n\t};\n\n\tthis.sendNotification = (notification, payload = {}) => {\n\t\tthis.socket.emit(notification, payload);\n\t};\n};\n"
  },
  {
    "path": "js/translator.js",
    "content": "/* global translations */\n\nconst Translator = (function () {\n\n\t/**\n\t * Load a JSON file via fetch.\n\t * @param {string} file Path of the file we want to load.\n\t * @returns {Promise<object>} the translations in the specified file\n\t */\n\tasync function loadJSON (file) {\n\t\tconst baseHref = document.baseURI;\n\t\tconst url = new URL(file, baseHref);\n\n\t\ttry {\n\t\t\tconst response = await fetch(url);\n\t\t\tif (!response.ok) {\n\t\t\t\tthrow new Error(`Unexpected response status: ${response.status}`);\n\t\t\t}\n\t\t\treturn await response.json();\n\t\t} catch (exception) {\n\t\t\tLog.error(`Loading json file =${file} failed`);\n\t\t\treturn null;\n\t\t}\n\t}\n\n\treturn {\n\t\tcoreTranslations: {},\n\t\tcoreTranslationsFallback: {},\n\t\ttranslations: {},\n\t\ttranslationsFallback: {},\n\n\t\t/**\n\t\t * Load a translation for a given key for a given module.\n\t\t * @param {Module} module The module to load the translation for.\n\t\t * @param {string} key The key of the text to translate.\n\t\t * @param {object} variables The variables to use within the translation template (optional)\n\t\t * @returns {string} the translated key\n\t\t */\n\t\ttranslate (module, key, variables = {}) {\n\n\t\t\t/**\n\t\t\t * Combines template and variables like:\n\t\t\t * template: \"Please wait for {timeToWait} before continuing with {work}.\"\n\t\t\t * variables: {timeToWait: \"2 hours\", work: \"painting\"}\n\t\t\t * to: \"Please wait for 2 hours before continuing with painting.\"\n\t\t\t * @param {string} template Text with placeholder\n\t\t\t * @param {object} variables Variables for the placeholder\n\t\t\t * @returns {string} the template filled with the variables\n\t\t\t */\n\t\t\tfunction createStringFromTemplate (template, variables) {\n\t\t\t\tif (Object.prototype.toString.call(template) !== \"[object String]\") {\n\t\t\t\t\treturn template;\n\t\t\t\t}\n\t\t\t\tlet templateToUse = template;\n\t\t\t\tif (variables.fallback && !template.match(new RegExp(\"{.+}\"))) {\n\t\t\t\t\ttemplateToUse = variables.fallback;\n\t\t\t\t}\n\t\t\t\treturn templateToUse.replace(new RegExp(\"{([^}]+)}\", \"g\"), function (_unused, varName) {\n\t\t\t\t\treturn varName in variables ? variables[varName] : `{${varName}}`;\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tif (this.translations[module.name] && key in this.translations[module.name]) {\n\t\t\t\treturn createStringFromTemplate(this.translations[module.name][key], variables);\n\t\t\t}\n\n\t\t\tif (key in this.coreTranslations) {\n\t\t\t\treturn createStringFromTemplate(this.coreTranslations[key], variables);\n\t\t\t}\n\n\t\t\tif (this.translationsFallback[module.name] && key in this.translationsFallback[module.name]) {\n\t\t\t\treturn createStringFromTemplate(this.translationsFallback[module.name][key], variables);\n\t\t\t}\n\n\t\t\tif (key in this.coreTranslationsFallback) {\n\t\t\t\treturn createStringFromTemplate(this.coreTranslationsFallback[key], variables);\n\t\t\t}\n\n\t\t\treturn key;\n\t\t},\n\n\t\t/**\n\t\t * Load a translation file (json) and remember the data.\n\t\t * @param {Module} module The module to load the translation file for.\n\t\t * @param {string} file Path of the file we want to load.\n\t\t * @param {boolean} isFallback Flag to indicate fallback translations.\n\t\t */\n\t\tasync load (module, file, isFallback) {\n\t\t\tLog.log(`[translator] ${module.name} - Load translation${isFallback ? \" fallback\" : \"\"}: ${file}`);\n\n\t\t\tif (this.translationsFallback[module.name]) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst json = await loadJSON(module.file(file));\n\t\t\tconst property = isFallback ? \"translationsFallback\" : \"translations\";\n\t\t\tthis[property][module.name] = json;\n\t\t},\n\n\t\t/**\n\t\t * Load the core translations.\n\t\t * @param {string} lang The language identifier of the core language.\n\t\t */\n\t\tasync loadCoreTranslations (lang) {\n\t\t\tif (lang in translations) {\n\t\t\t\tLog.log(`[translator] Loading core translation file: ${translations[lang]}`);\n\t\t\t\tthis.coreTranslations = await loadJSON(translations[lang]);\n\t\t\t} else {\n\t\t\t\tLog.log(\"[translator] Configured language not found in core translations.\");\n\t\t\t}\n\n\t\t\tawait this.loadCoreTranslationsFallback();\n\t\t},\n\n\t\t/**\n\t\t * Load the core translations' fallback.\n\t\t * The first language defined in translations.js will be used.\n\t\t */\n\t\tasync loadCoreTranslationsFallback () {\n\t\t\tlet first = Object.keys(translations)[0];\n\t\t\tif (first) {\n\t\t\t\tLog.log(`[translator] Loading core translation fallback file: ${translations[first]}`);\n\t\t\t\tthis.coreTranslationsFallback = await loadJSON(translations[first]);\n\t\t\t}\n\t\t}\n\t};\n}());\n\nwindow.Translator = Translator;\n"
  },
  {
    "path": "js/utils.js",
    "content": "const os = require(\"node:os\");\nconst fs = require(\"node:fs\");\nconst si = require(\"systeminformation\");\nconst Log = require(\"logger\");\n\nconst modulePositions = []; // will get list from index.html\nconst regionRegEx = /\"region ([^\"]*)/i;\nconst indexFileName = \"index.html\";\nconst discoveredPositionsJSFilename = \"js/positions.js\";\n\nmodule.exports = {\n\n\tasync logSystemInformation (mirrorVersion) {\n\t\ttry {\n\t\t\tconst system = await si.system();\n\t\t\tconst osInfo = await si.osInfo();\n\t\t\tconst versions = await si.versions();\n\n\t\t\tconst usedNodeVersion = process.version.replace(\"v\", \"\");\n\t\t\tconst installedNodeVersion = versions.node;\n\t\t\tconst totalRam = (os.totalmem() / 1024 / 1024).toFixed(2);\n\t\t\tconst freeRam = (os.freemem() / 1024 / 1024).toFixed(2);\n\t\t\tconst usedRam = ((os.totalmem() - os.freemem()) / 1024 / 1024).toFixed(2);\n\n\t\t\tlet systemDataString = [\n\t\t\t\t\"\\n####  System Information  ####\",\n\t\t\t\t`- SYSTEM:   manufacturer: ${system.manufacturer}; model: ${system.model}; virtual: ${system.virtual}; MM: ${mirrorVersion}`,\n\t\t\t\t`- OS:       platform: ${osInfo.platform}; distro: ${osInfo.distro}; release: ${osInfo.release}; arch: ${osInfo.arch}; kernel: ${versions.kernel}`,\n\t\t\t\t`- VERSIONS: electron: ${process.versions.electron}; used node: ${usedNodeVersion}; installed node: ${installedNodeVersion}; npm: ${versions.npm}; pm2: ${versions.pm2}`,\n\t\t\t\t`- ENV:      XDG_SESSION_TYPE: ${process.env.XDG_SESSION_TYPE}; MM_CONFIG_FILE: ${process.env.MM_CONFIG_FILE}`,\n\t\t\t\t`            WAYLAND_DISPLAY:  ${process.env.WAYLAND_DISPLAY}; DISPLAY: ${process.env.DISPLAY}; ELECTRON_ENABLE_GPU: ${process.env.ELECTRON_ENABLE_GPU}`,\n\t\t\t\t`- RAM:      total: ${totalRam} MB; free: ${freeRam} MB; used: ${usedRam} MB`,\n\t\t\t\t`- OTHERS:   uptime: ${Math.floor(os.uptime() / 60)} minutes; timeZone: ${Intl.DateTimeFormat().resolvedOptions().timeZone}`\n\t\t\t].join(\"\\n\");\n\t\t\tLog.info(systemDataString);\n\n\t\t\t// Return is currently only for tests\n\t\t\treturn systemDataString;\n\t\t} catch (error) {\n\t\t\tLog.error(error);\n\t\t}\n\t},\n\n\t// return all available module positions\n\tgetAvailableModulePositions () {\n\t\treturn modulePositions;\n\t},\n\n\t// return if position is on modulePositions Array (true/false)\n\tmoduleHasValidPosition (position) {\n\t\tif (this.getAvailableModulePositions().indexOf(position) === -1) return false;\n\t\treturn true;\n\t},\n\n\tgetModulePositions () {\n\t\t// if not already discovered\n\t\tif (modulePositions.length === 0) {\n\t\t\t// get the lines of the index.html\n\t\t\tconst lines = fs.readFileSync(indexFileName).toString().split(\"\\n\");\n\t\t\t// loop thru the lines\n\t\t\tlines.forEach((line) => {\n\t\t\t\t// run the regex on each line\n\t\t\t\tconst results = regionRegEx.exec(line);\n\t\t\t\t// if the regex returned something\n\t\t\t\tif (results && results.length > 0) {\n\t\t\t\t\t// get the position parts and replace space with underscore\n\t\t\t\t\tconst positionName = results[1].replace(\" \", \"_\");\n\t\t\t\t\t// add it to the list only if not already present (avoid duplicates)\n\t\t\t\t\tif (!modulePositions.includes(positionName)) {\n\t\t\t\t\t\tmodulePositions.push(positionName);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t});\n\t\t\ttry {\n\t\t\t\tfs.writeFileSync(discoveredPositionsJSFilename, `const modulePositions=${JSON.stringify(modulePositions)}`);\n\t\t\t}\n\t\t\tcatch (error) {\n\t\t\t\tLog.error(\"unable to write js/positions.js with the discovered module positions\\nmake the MagicMirror/js folder writeable by the user starting MagicMirror\");\n\t\t\t}\n\t\t}\n\t\t// return the list to the caller\n\t\treturn modulePositions;\n\t}\n};\n"
  },
  {
    "path": "js/vendor.js",
    "content": "const vendor = {\n\t\"moment.js\": \"node_modules/moment/min/moment-with-locales.js\",\n\t\"moment-timezone.js\": \"node_modules/moment-timezone/builds/moment-timezone-with-data.js\",\n\t\"weather-icons.css\": \"node_modules/weathericons/css/weather-icons.css\",\n\t\"weather-icons-wind.css\": \"node_modules/weathericons/css/weather-icons-wind.css\",\n\t\"font-awesome.css\": \"css/font-awesome.css\",\n\t\"nunjucks.js\": \"node_modules/nunjucks/browser/nunjucks.min.js\",\n\t\"suncalc.js\": \"node_modules/suncalc/suncalc.js\",\n\t\"croner.js\": \"node_modules/croner/dist/croner.umd.js\"\n};\n\nif (typeof module !== \"undefined\") {\n\tmodule.exports = vendor;\n}\n"
  },
  {
    "path": "jsconfig.json",
    "content": "{\n\t// See https://go.microsoft.com/fwlink/?LinkId=759670\n\t// for the documentation about the jsconfig.json format\n\t\"compilerOptions\": {\n\t\t\"target\": \"es6\",\n\t\t\"module\": \"commonjs\",\n\t\t\"allowSyntheticDefaultImports\": true\n\t},\n\t\"exclude\": [\"modules\", \"node_modules\"]\n}\n"
  },
  {
    "path": "module-types.ts",
    "content": "type ModuleProperties = {\n  defaults?: object;\n  [key: string]: any;\n  start?(): void;\n  getScripts?(): string[];\n  getStyles?(): string[];\n  getTranslations?(): object;\n  getDom?(): HTMLElement;\n  getHeader?(): string;\n  getTemplate?(): string;\n  getTemplateData?(): object;\n  notificationReceived?(notification: string, payload: any, sender: object): void;\n  nunjucksEnvironment?(): void;\n  socketNotificationReceived?(notification: string, payload: any): void;\n  suspend?(): void;\n  resume?(): void;\n};\n\nexport declare const Module: {\n  register(moduleName: string, moduleProperties: ModuleProperties): void;\n};\n\nexport declare const Log: {\n  info(message?: any, ...optionalParams: any[]): void;\n  log(message?: any, ...optionalParams: any[]): void;\n  error(message?: any, ...optionalParams: any[]): void;\n  warn(message?: any, ...optionalParams: any[]): void;\n  group(groupTitle?: string, ...optionalParams: any[]): void;\n  groupCollapsed(groupTitle?: string, ...optionalParams: any[]): void;\n  groupEnd(): void;\n  time(timerName?: string): void;\n  timeEnd(timerName?: string): void;\n  timeStamp(timerName?: string): void;\n};\n"
  },
  {
    "path": "modules/default/alert/README.md",
    "content": "# Module: Alert\n\nThe alert module is one of the default modules of the MagicMirror². This module displays notifications from other modules.\n\nFor configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/alert.html).\n"
  },
  {
    "path": "modules/default/alert/alert.js",
    "content": "/* global NotificationFx */\n\nModule.register(\"alert\", {\n\talerts: {},\n\n\tdefaults: {\n\t\teffect: \"slide\", // scale|slide|genie|jelly|flip|bouncyflip|exploader\n\t\talert_effect: \"jelly\", // scale|slide|genie|jelly|flip|bouncyflip|exploader\n\t\tdisplay_time: 3500, // time a notification is displayed in seconds\n\t\tposition: \"center\",\n\t\twelcome_message: false // shown at startup\n\t},\n\n\tgetScripts () {\n\t\treturn [\"notificationFx.js\"];\n\t},\n\n\tgetStyles () {\n\t\treturn [\"font-awesome.css\", this.file(\"./styles/notificationFx.css\"), this.file(`./styles/${this.config.position}.css`)];\n\t},\n\n\tgetTranslations () {\n\t\treturn {\n\t\t\tbg: \"translations/bg.json\",\n\t\t\tda: \"translations/da.json\",\n\t\t\tde: \"translations/de.json\",\n\t\t\ten: \"translations/en.json\",\n\t\t\teo: \"translations/eo.json\",\n\t\t\tes: \"translations/es.json\",\n\t\t\tfr: \"translations/fr.json\",\n\t\t\thu: \"translations/hu.json\",\n\t\t\tnl: \"translations/nl.json\",\n\t\t\tpt: \"translations/pt.json\",\n\t\t\t\"pt-br\": \"translations/pt-br.json\",\n\t\t\tru: \"translations/ru.json\",\n\t\t\tth: \"translations/th.json\"\n\t\t};\n\t},\n\n\tgetTemplate (type) {\n\t\treturn `templates/${type}.njk`;\n\t},\n\n\tasync start () {\n\t\tLog.info(`Starting module: ${this.name}`);\n\n\t\tif (this.config.effect === \"slide\") {\n\t\t\tthis.config.effect = `${this.config.effect}-${this.config.position}`;\n\t\t}\n\n\t\tif (this.config.welcome_message) {\n\t\t\tconst message = this.config.welcome_message === true ? this.translate(\"welcome\") : this.config.welcome_message;\n\t\t\tawait this.showNotification({ title: this.translate(\"sysTitle\"), message });\n\t\t}\n\t},\n\n\tnotificationReceived (notification, payload, sender) {\n\t\tif (notification === \"SHOW_ALERT\") {\n\t\t\tif (payload.type === \"notification\") {\n\t\t\t\tthis.showNotification(payload);\n\t\t\t} else {\n\t\t\t\tthis.showAlert(payload, sender);\n\t\t\t}\n\t\t} else if (notification === \"HIDE_ALERT\") {\n\t\t\tthis.hideAlert(sender);\n\t\t}\n\t},\n\n\tasync showNotification (notification) {\n\t\tconst message = await this.renderMessage(notification.templateName || \"notification\", notification);\n\n\t\tnew NotificationFx({\n\t\t\tmessage,\n\t\t\tlayout: \"growl\",\n\t\t\teffect: this.config.effect,\n\t\t\tttl: notification.timer || this.config.display_time\n\t\t}).show();\n\t},\n\n\tasync showAlert (alert, sender) {\n\t\t// If module already has an open alert close it\n\t\tif (this.alerts[sender.name]) {\n\t\t\tthis.hideAlert(sender, false);\n\t\t}\n\n\t\t// Add overlay\n\t\tif (!Object.keys(this.alerts).length) {\n\t\t\tthis.toggleBlur(true);\n\t\t}\n\n\t\tconst message = await this.renderMessage(alert.templateName || \"alert\", alert);\n\n\t\t// Store alert in this.alerts\n\t\tthis.alerts[sender.name] = new NotificationFx({\n\t\t\tmessage,\n\t\t\teffect: this.config.alert_effect,\n\t\t\tttl: alert.timer,\n\t\t\tonClose: () => this.hideAlert(sender),\n\t\t\tal_no: \"ns-alert\"\n\t\t});\n\n\t\t// Show alert\n\t\tthis.alerts[sender.name].show();\n\n\t\t// Add timer to dismiss alert and overlay\n\t\tif (alert.timer) {\n\t\t\tsetTimeout(() => {\n\t\t\t\tthis.hideAlert(sender);\n\t\t\t}, alert.timer);\n\t\t}\n\t},\n\n\thideAlert (sender, close = true) {\n\t\t// Dismiss alert and remove from this.alerts\n\t\tif (this.alerts[sender.name]) {\n\t\t\tthis.alerts[sender.name].dismiss(close);\n\t\t\tdelete this.alerts[sender.name];\n\t\t\t// Remove overlay\n\t\t\tif (!Object.keys(this.alerts).length) {\n\t\t\t\tthis.toggleBlur(false);\n\t\t\t}\n\t\t}\n\t},\n\n\trenderMessage (type, data) {\n\t\treturn new Promise((resolve) => {\n\t\t\tthis.nunjucksEnvironment().render(this.getTemplate(type), data, function (err, res) {\n\t\t\t\tif (err) {\n\t\t\t\t\tLog.error(\"[alert] Failed to render alert\", err);\n\t\t\t\t}\n\n\t\t\t\tresolve(res);\n\t\t\t});\n\t\t});\n\t},\n\n\ttoggleBlur (add = false) {\n\t\tconst method = add ? \"add\" : \"remove\";\n\t\tconst modules = document.querySelectorAll(\".module\");\n\t\tfor (const module of modules) {\n\t\t\tmodule.classList[method](\"alert-blur\");\n\t\t}\n\t}\n});\n"
  },
  {
    "path": "modules/default/alert/notificationFx.js",
    "content": "/**\n * Based on work by\n *\n * notificationFx.js v1.0.0\n * https://tympanus.net/codrops/\n *\n * Licensed under the MIT license.\n * https://opensource.org/licenses/mit-license.php\n *\n * Copyright 2014, Codrops\n * https://tympanus.net/codrops/\n * @param {object} window The window object\n */\n(function (window) {\n\n\t/**\n\t * Extend one object with another one\n\t * @param {object} a The object to extend\n\t * @param {object} b The object which extends the other, overwrites existing keys\n\t * @returns {object} The merged object\n\t */\n\tfunction extend (a, b) {\n\t\tfor (let key in b) {\n\t\t\tif (b.hasOwnProperty(key)) {\n\t\t\t\ta[key] = b[key];\n\t\t\t}\n\t\t}\n\t\treturn a;\n\t}\n\n\t/**\n\t * NotificationFx constructor\n\t * @param {object} options The configuration options\n\t * @class\n\t */\n\tfunction NotificationFx (options) {\n\t\tthis.options = extend({}, this.options);\n\t\textend(this.options, options);\n\t\tthis._init();\n\t}\n\n\t/**\n\t * NotificationFx options\n\t */\n\tNotificationFx.prototype.options = {\n\t\t// element to which the notification will be appended\n\t\t// defaults to the document.body\n\t\twrapper: document.body,\n\t\t// the message\n\t\tmessage: \"yo!\",\n\t\t// layout type: growl|attached|bar|other\n\t\tlayout: \"growl\",\n\t\t// effects for the specified layout:\n\t\t// for growl layout: scale|slide|genie|jelly\n\t\t// for attached layout: flip|bouncyflip\n\t\t// for other layout: boxspinner|cornerexpand|loadingcircle|thumbslider\n\t\t// ...\n\t\teffect: \"slide\",\n\t\t// notice, warning, error, success\n\t\t// will add class ns-type-warning, ns-type-error or ns-type-success\n\t\ttype: \"notice\",\n\t\t// if the user doesn't close the notification then we remove it\n\t\t// after the following time\n\t\tttl: 6000,\n\t\tal_no: \"ns-box\",\n\t\t// callbacks\n\t\tonClose () {\n\t\t\treturn false;\n\t\t},\n\t\tonOpen () {\n\t\t\treturn false;\n\t\t}\n\t};\n\n\t/**\n\t * Initialize and cache some vars\n\t */\n\tNotificationFx.prototype._init = function () {\n\t\t// create HTML structure\n\t\tthis.ntf = document.createElement(\"div\");\n\t\tthis.ntf.className = `${this.options.al_no} ns-${this.options.layout} ns-effect-${this.options.effect} ns-type-${this.options.type}`;\n\t\tlet strinner = \"<div class=\\\"ns-box-inner\\\">\";\n\t\tstrinner += this.options.message;\n\t\tstrinner += \"</div>\";\n\t\tthis.ntf.innerHTML = strinner;\n\n\t\t// append to body or the element specified in options.wrapper\n\t\tthis.options.wrapper.insertBefore(this.ntf, this.options.wrapper.nextSibling);\n\n\t\t// dismiss after [options.ttl]ms\n\t\tif (this.options.ttl) {\n\t\t\tthis.dismissttl = setTimeout(() => {\n\t\t\t\tif (this.active) {\n\t\t\t\t\tthis.dismiss();\n\t\t\t\t}\n\t\t\t}, this.options.ttl);\n\t\t}\n\n\t\t// init events\n\t\tthis._initEvents();\n\t};\n\n\t/**\n\t * Init events\n\t */\n\tNotificationFx.prototype._initEvents = function () {\n\t\t// dismiss notification by tapping on it if someone has a touchscreen\n\t\tthis.ntf.querySelector(\".ns-box-inner\").addEventListener(\"click\", () => {\n\t\t\tthis.dismiss();\n\t\t});\n\t};\n\n\t/**\n\t * Show the notification\n\t */\n\tNotificationFx.prototype.show = function () {\n\t\tthis.active = true;\n\t\tthis.ntf.classList.remove(\"ns-hide\");\n\t\tthis.ntf.classList.add(\"ns-show\");\n\t\tthis.options.onOpen();\n\t};\n\n\t/**\n\t * Dismiss the notification\n\t * @param {boolean} [close] call the onClose callback at the end\n\t */\n\tNotificationFx.prototype.dismiss = function (close = true) {\n\t\tthis.active = false;\n\t\tclearTimeout(this.dismissttl);\n\t\tthis.ntf.classList.remove(\"ns-show\");\n\t\tsetTimeout(() => {\n\t\t\tthis.ntf.classList.add(\"ns-hide\");\n\n\t\t\t// callback\n\t\t\tif (close) this.options.onClose();\n\t\t}, 25);\n\n\t\t// after animation ends remove ntf from the DOM\n\t\tconst onEndAnimationFn = (ev) => {\n\t\t\tif (ev.target !== this.ntf) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tthis.ntf.removeEventListener(\"animationend\", onEndAnimationFn);\n\n\t\t\tif (ev.target.parentNode === this.options.wrapper) {\n\t\t\t\tthis.options.wrapper.removeChild(this.ntf);\n\t\t\t}\n\t\t};\n\n\t\tthis.ntf.addEventListener(\"animationend\", onEndAnimationFn);\n\t};\n\n\t/**\n\t * Add to global namespace\n\t */\n\twindow.NotificationFx = NotificationFx;\n}(window));\n"
  },
  {
    "path": "modules/default/alert/styles/center.css",
    "content": ".ns-box {\n  margin-left: auto;\n  margin-right: auto;\n  text-align: center;\n}\n"
  },
  {
    "path": "modules/default/alert/styles/left.css",
    "content": ".ns-box {\n  margin-right: auto;\n  text-align: left;\n}\n"
  },
  {
    "path": "modules/default/alert/styles/notificationFx.css",
    "content": "/* Based on work by https://tympanus.net/codrops/licensing/ */\n\n.ns-box {\n  background-color: rgb(0 0 0 / 93%);\n  padding: 17px;\n  line-height: 1.4;\n  margin-bottom: 10px;\n  z-index: 1;\n  font-size: 70%;\n  position: relative;\n  display: table;\n  overflow-wrap: break-word;\n  max-width: 100%;\n  border-width: 1px;\n  border-radius: 5px;\n  border-style: solid;\n  border-color: var(--color-text-dimmed);\n}\n\n.ns-alert {\n  border-style: solid;\n  border-color: var(--color-text-bright);\n  padding: 17px;\n  line-height: 1.4;\n  margin-bottom: 10px;\n  z-index: 3;\n  color: var(--color-text-bright);\n  font-size: 70%;\n  position: fixed;\n  text-align: center;\n  right: 0;\n  left: 0;\n  margin-right: auto;\n  margin-left: auto;\n  top: 40%;\n  width: 40%;\n  height: auto;\n  overflow-wrap: break-word;\n  border-radius: 20px;\n}\n\n.alert-blur {\n  filter: blur(2px) brightness(50%);\n}\n\n[class^=\"ns-effect-\"].ns-growl.ns-hide,\n[class*=\" ns-effect-\"].ns-growl.ns-hide {\n  animation-direction: reverse;\n}\n\n.ns-effect-flip {\n  transform-origin: 50% 100%;\n  backface-visibility: hidden;\n}\n\n.ns-effect-flip.ns-show,\n.ns-effect-flip.ns-hide {\n  animation-name: anim-flip-front;\n  animation-duration: 0.3s;\n}\n\n.ns-effect-flip.ns-hide {\n  animation-name: anim-flip-back;\n}\n\n@keyframes anim-flip-front {\n  0% {\n    transform: perspective(1000px) rotate3d(1, 0, 0, -90deg);\n  }\n\n  100% {\n    transform: perspective(1000px);\n  }\n}\n\n@keyframes anim-flip-back {\n  0% {\n    transform: perspective(1000px) rotate3d(1, 0, 0, 90deg);\n  }\n\n  100% {\n    transform: perspective(1000px);\n  }\n}\n\n.ns-effect-bouncyflip.ns-show,\n.ns-effect-bouncyflip.ns-hide {\n  animation-name: flip-in-x;\n  animation-duration: 0.8s;\n}\n\n@keyframes flip-in-x {\n  0% {\n    transform: perspective(400px) rotate3d(1, 0, 0, -90deg);\n    transition-timing-function: ease-in;\n  }\n\n  40% {\n    transform: perspective(400px) rotate3d(1, 0, 0, 20deg);\n    transition-timing-function: ease-out;\n  }\n\n  60% {\n    transform: perspective(400px) rotate3d(1, 0, 0, -10deg);\n    transition-timing-function: ease-in;\n    opacity: 1;\n  }\n\n  80% {\n    transform: perspective(400px) rotate3d(1, 0, 0, 5deg);\n    transition-timing-function: ease-out;\n  }\n\n  100% {\n    transform: perspective(400px);\n  }\n}\n\n.ns-effect-bouncyflip.ns-hide {\n  animation-name: flip-in-x-simple;\n  animation-duration: 0.3s;\n}\n\n@keyframes flip-in-x-simple {\n  0% {\n    transform: perspective(400px) rotate3d(1, 0, 0, -90deg);\n    transition-timing-function: ease-in;\n  }\n\n  100% {\n    transform: perspective(400px);\n  }\n}\n\n.ns-effect-exploader {\n  transform-origin: 0 0;\n}\n\n.ns-effect-exploader p {\n  padding: 0.25em 2em 0.25em 3em;\n}\n\n.ns-effect-exploader.ns-show {\n  animation-name: anim-load;\n  animation-duration: 1s;\n}\n\n@keyframes anim-load {\n  0% {\n    opacity: 1;\n    transform: scale3d(0, 0.3, 1);\n  }\n\n  100% {\n    opacity: 1;\n    transform: scale3d(1, 1, 1);\n  }\n}\n\n.ns-effect-exploader.ns-hide {\n  animation-name: anim-fade;\n  animation-duration: 0.3s;\n}\n\n.ns-effect-exploader.ns-show .ns-box-inner,\n.ns-effect-exploader.ns-show .ns-close {\n  animation-fill-mode: both;\n  animation-duration: 0.3s;\n  animation-delay: 0.6s;\n}\n\n.ns-effect-exploader.ns-show .ns-close {\n  animation-name: anim-fade;\n}\n\n.ns-effect-exploader.ns-show .ns-box-inner {\n  animation-name: anim-fade-move;\n  animation-timing-function: ease-out;\n}\n\n@keyframes anim-fade-move {\n  0% {\n    opacity: 0;\n    transform: translate3d(0, 10px, 0);\n  }\n\n  100% {\n    opacity: 1;\n    transform: translate3d(0, 0, 0);\n  }\n}\n\n@keyframes anim-fade {\n  0% {\n    opacity: 0;\n  }\n\n  100% {\n    opacity: 1;\n  }\n}\n\n.ns-effect-scale.ns-show,\n.ns-effect-scale.ns-hide {\n  animation-name: anim-scale;\n  animation-duration: 0.25s;\n}\n\n@keyframes anim-scale {\n  0% {\n    opacity: 0;\n    transform: translate3d(0, 40px, 0) scale3d(0.1, 0.6, 1);\n  }\n\n  100% {\n    opacity: 1;\n    transform: translate3d(0, 0, 0) scale3d(1, 1, 1);\n  }\n}\n\n.ns-effect-jelly.ns-show {\n  animation-name: anim-jelly;\n  animation-duration: 1s;\n  animation-timing-function: linear;\n}\n\n.ns-effect-jelly.ns-hide {\n  animation-name: anim-fade;\n  animation-duration: 0.3s;\n}\n\n@keyframes anim-fade {\n  0% {\n    opacity: 0;\n  }\n\n  100% {\n    opacity: 1;\n  }\n}\n\n@keyframes anim-jelly {\n  0% {\n    transform: matrix3d(0.7, 0, 0, 0, 0, 0.7, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  2.083333% {\n    transform: matrix3d(0.7527, 0, 0, 0, 0, 0.7634, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  4.166667% {\n    transform: matrix3d(0.8107, 0, 0, 0, 0, 0.8454, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  6.25% {\n    transform: matrix3d(0.8681, 0, 0, 0, 0, 0.929, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  8.333333% {\n    transform: matrix3d(0.9204, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  10.416667% {\n    transform: matrix3d(0.9648, 0, 0, 0, 0, 1.052, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  12.5% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1.082, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  14.583333% {\n    transform: matrix3d(1.0256, 0, 0, 0, 0, 1.0915, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  16.666667% {\n    transform: matrix3d(1.0423, 0, 0, 0, 0, 1.0845, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  18.75% {\n    transform: matrix3d(1.051, 0, 0, 0, 0, 1.0667, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  20.833333% {\n    transform: matrix3d(1.0533, 0, 0, 0, 0, 1.0436, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  22.916667% {\n    transform: matrix3d(1.0508, 0, 0, 0, 0, 1.0201, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  25% {\n    transform: matrix3d(1.0449, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  27.083333% {\n    transform: matrix3d(1.037, 0, 0, 0, 0, 0.9853, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  29.166667% {\n    transform: matrix3d(1.0283, 0, 0, 0, 0, 0.9769, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  31.25% {\n    transform: matrix3d(1.0197, 0, 0, 0, 0, 0.9742, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  33.333333% {\n    transform: matrix3d(1.0119, 0, 0, 0, 0, 0.9762, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  35.416667% {\n    transform: matrix3d(1.0053, 0, 0, 0, 0, 0.9812, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  37.5% {\n    transform: matrix3d(1, 0, 0, 0, 0, 0.9877, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  39.583333% {\n    transform: matrix3d(0.9962, 0, 0, 0, 0, 0.9943, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  41.666667% {\n    transform: matrix3d(0.9937, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  43.75% {\n    transform: matrix3d(0.9924, 0, 0, 0, 0, 1.0041, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  45.833333% {\n    transform: matrix3d(0.992, 0, 0, 0, 0, 1.0065, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  47.916667% {\n    transform: matrix3d(0.9924, 0, 0, 0, 0, 1.0073, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  50% {\n    transform: matrix3d(0.9933, 0, 0, 0, 0, 1.0067, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  52.083333% {\n    transform: matrix3d(0.9945, 0, 0, 0, 0, 1.0053, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  54.166667% {\n    transform: matrix3d(0.9958, 0, 0, 0, 0, 1.0035, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  56.25% {\n    transform: matrix3d(0.997, 0, 0, 0, 0, 1.002, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  58.333333% {\n    transform: matrix3d(0.9982, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  60.416667% {\n    transform: matrix3d(0.9992, 0, 0, 0, 0, 0.9989, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  62.5% {\n    transform: matrix3d(1, 0, 0, 0, 0, 0.9982, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  64.583333% {\n    transform: matrix3d(1.0006, 0, 0, 0, 0, 0.998, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  66.666667% {\n    transform: matrix3d(1.001, 0, 0, 0, 0, 0.9981, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  68.75% {\n    transform: matrix3d(1.0011, 0, 0, 0, 0, 0.9985, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  70.833333% {\n    transform: matrix3d(1.0012, 0, 0, 0, 0, 0.999, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  72.916667% {\n    transform: matrix3d(1.0011, 0, 0, 0, 0, 0.9996, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  75% {\n    transform: matrix3d(1.001, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  77.083333% {\n    transform: matrix3d(1.0008, 0, 0, 0, 0, 1.0003, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  79.166667% {\n    transform: matrix3d(1.0006, 0, 0, 0, 0, 1.0005, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  81.25% {\n    transform: matrix3d(1.0004, 0, 0, 0, 0, 1.0006, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  83.333333% {\n    transform: matrix3d(1.0003, 0, 0, 0, 0, 1.0005, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  85.416667% {\n    transform: matrix3d(1.0001, 0, 0, 0, 0, 1.0004, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  87.5% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1.0003, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  89.583333% {\n    transform: matrix3d(0.9999, 0, 0, 0, 0, 1.0001, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  91.666667% {\n    transform: matrix3d(0.9999, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  93.75% {\n    transform: matrix3d(0.9998, 0, 0, 0, 0, 0.9999, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  95.833333% {\n    transform: matrix3d(0.9998, 0, 0, 0, 0, 0.9999, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  97.916667% {\n    transform: matrix3d(0.9998, 0, 0, 0, 0, 0.9998, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  100% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n}\n\n.ns-effect-slide-left.ns-show {\n  animation-name: anim-slide-elastic-left;\n  animation-duration: 1s;\n  animation-timing-function: linear;\n}\n\n@keyframes anim-slide-elastic-left {\n  0% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -1000, 0, 0, 1);\n  }\n\n  1.666667% {\n    transform: matrix3d(1.9293, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -739.2681, 0, 0, 1);\n  }\n\n  3.333333% {\n    transform: matrix3d(1.9699, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -521.8255, 0, 0, 1);\n  }\n\n  5% {\n    transform: matrix3d(1.709, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -349.2612, 0, 0, 1);\n  }\n\n  6.666667% {\n    transform: matrix3d(1.424, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -218.324, 0, 0, 1);\n  }\n\n  8.333333% {\n    transform: matrix3d(1.2107, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -123.2985, 0, 0, 1);\n  }\n\n  10% {\n    transform: matrix3d(1.0817, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -57.5927, 0, 0, 1);\n  }\n\n  11.666667% {\n    transform: matrix3d(1.017, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -14.7237, 0, 0, 1);\n  }\n\n  13.333333% {\n    transform: matrix3d(0.9906, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 11.1279, 0, 0, 1);\n  }\n\n  15% {\n    transform: matrix3d(0.9848, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 24.8634, 0, 0, 1);\n  }\n\n  16.666667% {\n    transform: matrix3d(0.9872, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 30.405, 0, 0, 1);\n  }\n\n  18.333333% {\n    transform: matrix3d(0.992, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 30.7528, 0, 0, 1);\n  }\n\n  20% {\n    transform: matrix3d(0.9954, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 28.1014, 0, 0, 1);\n  }\n\n  21.666667% {\n    transform: matrix3d(0.998, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 23.9827, 0, 0, 1);\n  }\n\n  23.333333% {\n    transform: matrix3d(0.9994, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 19.4075, 0, 0, 1);\n  }\n\n  25% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 14.9956, 0, 0, 1);\n  }\n\n  26.666667% {\n    transform: matrix3d(1.0002, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 11.0858, 0, 0, 1);\n  }\n\n  28.333333% {\n    transform: matrix3d(1.0002, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 7.8251, 0, 0, 1);\n  }\n\n  30% {\n    transform: matrix3d(1.0002, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 5.2374, 0, 0, 1);\n  }\n\n  31.666667% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 3.2739, 0, 0, 1);\n  }\n\n  33.333333% {\n    transform: matrix3d(1.0001, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1.8489, 0, 0, 1);\n  }\n\n  35% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.8636, 0, 0, 1);\n  }\n\n  36.666667% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.2208, 0, 0, 1);\n  }\n\n  38.333333% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.1669, 0, 0, 1);\n  }\n\n  40% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.3728, 0, 0, 1);\n  }\n\n  41.666667% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.4559, 0, 0, 1);\n  }\n\n  43.333333% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.4612, 0, 0, 1);\n  }\n\n  45% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.421, 0, 0, 1);\n  }\n\n  46.666667% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.3596, 0, 0, 1);\n  }\n\n  48.333333% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.291, 0, 0, 1);\n  }\n\n  50% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.2249, 0, 0, 1);\n  }\n\n  51.666667% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.1662, 0, 0, 1);\n  }\n\n  53.333333% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.1173, 0, 0, 1);\n  }\n\n  55% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0785, 0, 0, 1);\n  }\n\n  56.666667% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0491, 0, 0, 1);\n  }\n\n  58.333333% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0277, 0, 0, 1);\n  }\n\n  60% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.013, 0, 0, 1);\n  }\n\n  61.666667% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0033, 0, 0, 1);\n  }\n\n  63.333333% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0025, 0, 0, 1);\n  }\n\n  65% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0056, 0, 0, 1);\n  }\n\n  66.666667% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0068, 0, 0, 1);\n  }\n\n  68.333333% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0069, 0, 0, 1);\n  }\n\n  70% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0063, 0, 0, 1);\n  }\n\n  71.666667% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0054, 0, 0, 1);\n  }\n\n  73.333333% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0044, 0, 0, 1);\n  }\n\n  75% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0034, 0, 0, 1);\n  }\n\n  76.666667% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0025, 0, 0, 1);\n  }\n\n  78.333333% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0018, 0, 0, 1);\n  }\n\n  80% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0012, 0, 0, 1);\n  }\n\n  81.666667% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0007, 0, 0, 1);\n  }\n\n  83.333333% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0004, 0, 0, 1);\n  }\n\n  85% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0002, 0, 0, 1);\n  }\n\n  86.666667% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0001, 0, 0, 1);\n  }\n\n  88.333333% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1);\n  }\n\n  90% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1);\n  }\n\n  91.666667% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1);\n  }\n\n  93.333333% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1);\n  }\n\n  95% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1);\n  }\n\n  96.666667% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1);\n  }\n\n  98.333333% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1);\n  }\n\n  100% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n}\n\n.ns-effect-slide-left.ns-hide {\n  animation-name: anim-slide-left;\n  animation-duration: 0.25s;\n}\n\n@keyframes anim-slide-left {\n  0% {\n    transform: translate3d(-30px, 0, 0) translate3d(-100%, 0, 0);\n  }\n\n  100% {\n    transform: translate3d(0, 0, 0);\n  }\n}\n\n.ns-effect-slide-right.ns-show {\n  animation: anim-slide-elastic-right 2000ms linear both;\n}\n\n@keyframes anim-slide-elastic-right {\n  0% {\n    transform: matrix3d(2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1000, 0, 0, 1);\n  }\n\n  2.15% {\n    transform: matrix3d(1.486, 0, 0, 0, 0, 0.514, 0, 0, 0, 0, 1, 0, 664.594, 0, 0, 1);\n  }\n\n  4.1% {\n    transform: matrix3d(1.147, 0, 0, 0, 0, 0.853, 0, 0, 0, 0, 1, 0, 419.708, 0, 0, 1);\n  }\n\n  4.3% {\n    transform: matrix3d(1.121, 0, 0, 0, 0, 0.879, 0, 0, 0, 0, 1, 0, 398.136, 0, 0, 1);\n  }\n\n  6.46% {\n    transform: matrix3d(0.948, 0, 0, 0, 0, 1.052, 0, 0, 0, 0, 1, 0, 206.714, 0, 0, 1);\n  }\n\n  8.11% {\n    transform: matrix3d(0.908, 0, 0, 0, 0, 1.092, 0, 0, 0, 0, 1, 0, 105.491, 0, 0, 1);\n  }\n\n  8.61% {\n    transform: matrix3d(0.907, 0, 0, 0, 0, 1.093, 0, 0, 0, 0, 1, 0, 81.572, 0, 0, 1);\n  }\n\n  12.11% {\n    transform: matrix3d(0.95, 0, 0, 0, 0, 1.05, 0, 0, 0, 0, 1, 0, -18.434, 0, 0, 1);\n  }\n\n  14.16% {\n    transform: matrix3d(0.979, 0, 0, 0, 0, 1.021, 0, 0, 0, 0, 1, 0, -38.734, 0, 0, 1);\n  }\n\n  16.12% {\n    transform: matrix3d(0.997, 0, 0, 0, 0, 1.003, 0, 0, 0, 0, 1, 0, -43.356, 0, 0, 1);\n  }\n\n  19.72% {\n    transform: matrix3d(1.006, 0, 0, 0, 0, 0.994, 0, 0, 0, 0, 1, 0, -34.155, 0, 0, 1);\n  }\n\n  27.23% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -7.839, 0, 0, 1);\n  }\n\n  30.83% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -1.951, 0, 0, 1);\n  }\n\n  38.34% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1.037, 0, 0, 1);\n  }\n\n  41.99% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.812, 0, 0, 1);\n  }\n\n  50% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.159, 0, 0, 1);\n  }\n\n  60.56% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.025, 0, 0, 1);\n  }\n\n  82.78% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.001, 0, 0, 1);\n  }\n\n  100% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n}\n\n.ns-effect-slide-right.ns-hide {\n  animation-name: anim-slide-right;\n  animation-duration: 0.25s;\n}\n\n@keyframes anim-slide-right {\n  0% {\n    transform: translate3d(30px, 0, 0) translate3d(100%, 0, 0);\n  }\n\n  100% {\n    transform: translate3d(0, 0, 0);\n  }\n}\n\n.ns-effect-slide-center.ns-show {\n  animation: anim-slide-elastic-center 2000ms linear both;\n}\n\n@keyframes anim-slide-elastic-center {\n  0% {\n    transform: matrix3d(1, 0, 0, 0, 0, 3, 0, 0, 0, 0, 1, 0, 0, -300, 0, 1);\n  }\n\n  2.15% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1.971, 0, 0, 0, 0, 1, 0, 0, -199.378, 0, 1);\n  }\n\n  4.1% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1.294, 0, 0, 0, 0, 1, 0, 0, -125.912, 0, 1);\n  }\n\n  4.3% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1.243, 0, 0, 0, 0, 1, 0, 0, -119.441, 0, 1);\n  }\n\n  6.46% {\n    transform: matrix3d(1, 0, 0, 0, 0, 0.895, 0, 0, 0, 0, 1, 0, 0, -62.014, 0, 1);\n  }\n\n  8.11% {\n    transform: matrix3d(1, 0, 0, 0, 0, 0.817, 0, 0, 0, 0, 1, 0, 0, -31.647, 0, 1);\n  }\n\n  8.61% {\n    transform: matrix3d(1, 0, 0, 0, 0, 0.813, 0, 0, 0, 0, 1, 0, 0, -24.472, 0, 1);\n  }\n\n  12.11% {\n    transform: matrix3d(1, 0, 0, 0, 0, 0.9, 0, 0, 0, 0, 1, 0, 0, 5.53, 0, 1);\n  }\n\n  14.16% {\n    transform: matrix3d(1, 0, 0, 0, 0, 0.959, 0, 0, 0, 0, 1, 0, 0, 11.62, 0, 1);\n  }\n\n  16.12% {\n    transform: matrix3d(1, 0, 0, 0, 0, 0.994, 0, 0, 0, 0, 1, 0, 0, 13.007, 0, 1);\n  }\n\n  19.72% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1.012, 0, 0, 0, 0, 1, 0, 0, 10.247, 0, 1);\n  }\n\n  27.23% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 2.352, 0, 1);\n  }\n\n  30.83% {\n    transform: matrix3d(1, 0, 0, 0, 0, 0.999, 0, 0, 0, 0, 1, 0, 0, 0.585, 0, 1);\n  }\n\n  38.34% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, -0.311, 0, 1);\n  }\n\n  41.99% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, -0.244, 0, 1);\n  }\n\n  50% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, -0.048, 0, 1);\n  }\n\n  60.56% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0.007, 0, 1);\n  }\n\n  82.78% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n\n  100% {\n    transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);\n  }\n}\n\n.ns-effect-slide-center.ns-hide {\n  animation-name: anim-slide-center;\n  animation-duration: 0.25s;\n}\n\n@keyframes anim-slide-center {\n  0% {\n    transform: translate3d(0, -30px, 0) translate3d(0, -100%, 0);\n  }\n\n  100% {\n    transform: translate3d(0, 0, 0);\n  }\n}\n\n.ns-effect-genie.ns-show,\n.ns-effect-genie.ns-hide {\n  animation-name: anim-genie;\n  animation-duration: 0.4s;\n}\n\n@keyframes anim-genie {\n  0% {\n    opacity: 0;\n    transform: translate3d(0, calc(200% + 30px), 0) scale3d(0, 1, 1);\n    animation-timing-function: ease-in;\n  }\n\n  40% {\n    opacity: 0.5;\n    transform: translate3d(0, 0, 0) scale3d(0.02, 1.1, 1);\n    animation-timing-function: ease-out;\n  }\n\n  70% {\n    opacity: 0.6;\n    transform: translate3d(0, -40px, 0) scale3d(0.8, 1.1, 1);\n  }\n\n  100% {\n    opacity: 1;\n    transform: translate3d(0, 0, 0) scale3d(1, 1, 1);\n  }\n}\n"
  },
  {
    "path": "modules/default/alert/styles/right.css",
    "content": ".ns-box {\n  margin-left: auto;\n  text-align: right;\n}\n"
  },
  {
    "path": "modules/default/alert/templates/alert.njk",
    "content": "{% if imageUrl or imageFA %}\n  {% set imageHeight = imageHeight if imageHeight else \"80px\" %}\n  {% if imageUrl %}\n    <img src=\"{{ imageUrl }}\" height=\"{{ imageHeight }}\" style=\"margin-bottom: 10px\" />\n  {% else %}\n    <span\n      class=\"bright fas fa-{{ imageFA }}\"\n      style=\"margin-bottom: 10px;\n                     font-size: {{ imageHeight }}\"\n    ></span>\n  {% endif %}\n  <br />\n{% endif %}\n{% if title %}\n  <span class=\"thin dimmed medium\">{{ title if titleType == 'text' else title | safe }}</span>\n{% endif %}\n{% if message %}\n  {% if title %}<br />{% endif %}\n  <span class=\"light bright small\">{{ message if messageType == 'text' else message | safe }}</span>\n{% endif %}\n"
  },
  {
    "path": "modules/default/alert/templates/notification.njk",
    "content": "{% if title %}\n  <span class=\"thin dimmed medium\">{{ title if titleType == 'text' else title | safe }}</span>\n{% endif %}\n{% if message %}\n  {% if title %}<br />{% endif %}\n  <span class=\"light bright small\">{{ message if messageType == 'text' else message | safe }}</span>\n{% endif %}\n"
  },
  {
    "path": "modules/default/alert/translations/bg.json",
    "content": "{\n\t\"sysTitle\": \"MagicMirror² нотификация\",\n\t\"welcome\": \"Добре дошли, стартирането беше успешно\"\n}\n"
  },
  {
    "path": "modules/default/alert/translations/da.json",
    "content": "{\n\t\"sysTitle\": \"MagicMirror² Notifikation\",\n\t\"welcome\": \"Velkommen, modulet er succesfuldt startet!\"\n}\n"
  },
  {
    "path": "modules/default/alert/translations/de.json",
    "content": "{\n\t\"sysTitle\": \"MagicMirror² Benachrichtigung\",\n\t\"welcome\": \"Willkommen, Start war erfolgreich!\"\n}\n"
  },
  {
    "path": "modules/default/alert/translations/el.json",
    "content": "{\n\t\"sysTitle\": \"MagicMirror² Ειδοποίηση\",\n\t\"welcome\": \"Καλώς ήρθατε, η εκκίνηση ήταν επιτυχής!\"\n}\n"
  },
  {
    "path": "modules/default/alert/translations/en.json",
    "content": "{\n\t\"sysTitle\": \"MagicMirror² Notification\",\n\t\"welcome\": \"Welcome, start was successful!\"\n}\n"
  },
  {
    "path": "modules/default/alert/translations/eo.json",
    "content": "{\n\t\"sysTitle\": \"MagicMirror²-sciigo\",\n\t\"welcome\": \"Bonvenon, lanĉo sukcesis!\"\n}\n"
  },
  {
    "path": "modules/default/alert/translations/es.json",
    "content": "{\n\t\"sysTitle\": \"MagicMirror² Notificaciones\",\n\t\"welcome\": \"Bienvenido, ¡se iniciado correctamente!\"\n}\n"
  },
  {
    "path": "modules/default/alert/translations/fr.json",
    "content": "{\n\t\"sysTitle\": \"MagicMirror² Notification\",\n\t\"welcome\": \"Bienvenue, le démarrage a été un succès!\"\n}\n"
  },
  {
    "path": "modules/default/alert/translations/hu.json",
    "content": "{\n\t\"sysTitle\": \"MagicMirror² értesítés\",\n\t\"welcome\": \"Üdvözöljük, indulás sikeres!\"\n}\n"
  },
  {
    "path": "modules/default/alert/translations/nl.json",
    "content": "{\n\t\"sysTitle\": \"MagicMirror² Notificatie\",\n\t\"welcome\": \"Welkom, Succesvol gestart!\"\n}\n"
  },
  {
    "path": "modules/default/alert/translations/pt-br.json",
    "content": "{\n\t\"sysTitle\": \"Notificação do MagicMirror²\",\n\t\"welcome\": \"Bem-vindo, o sistema iniciou com sucesso!\"\n}\n"
  },
  {
    "path": "modules/default/alert/translations/pt.json",
    "content": "{\n\t\"sysTitle\": \"Notificação do MagicMirror²\",\n\t\"welcome\": \"Bem-vindo, o sistema iniciou com sucesso!\"\n}\n"
  },
  {
    "path": "modules/default/alert/translations/ru.json",
    "content": "{\n\t\"sysTitle\": \"MagicMirror² Уведомление\",\n\t\"welcome\": \"Добро пожаловать, старт был успешным!\"\n}\n"
  },
  {
    "path": "modules/default/alert/translations/th.json",
    "content": "{\n\t\"sysTitle\": \"การแจ้งเตือน MagicMirror²\",\n\t\"welcome\": \"ยินดีต้อนรับ การเริ่มต้นสำเร็จแล้ว!\"\n}\n"
  },
  {
    "path": "modules/default/calendar/README.md",
    "content": "# Module: Calendar\n\nThe `calendar` module is one of the default modules of the MagicMirror².\nThis module displays events from a public .ical calendar. It can combine multiple calendars.\n\nFor configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/calendar.html).\n"
  },
  {
    "path": "modules/default/calendar/calendar.css",
    "content": ".calendar .symbol {\n  display: flex;\n  flex-direction: row;\n  justify-content: flex-end;\n  gap: 5px;\n}\n\n.calendar .title {\n  padding: 0 10px;\n}\n\n.calendar .time {\n  padding-left: 20px;\n  text-align: right;\n}\n"
  },
  {
    "path": "modules/default/calendar/calendar.js",
    "content": "/* global CalendarUtils */\n\nModule.register(\"calendar\", {\n\t// Define module defaults\n\tdefaults: {\n\t\tmaximumEntries: 10, // Total Maximum Entries\n\t\tmaximumNumberOfDays: 365,\n\t\tlimitDays: 0, // Limit the number of days shown, 0 = no limit\n\t\tpastDaysCount: 0,\n\t\tdisplaySymbol: true,\n\t\tdefaultSymbol: \"calendar-days\", // Fontawesome Symbol see https://fontawesome.com/search?ic=free&o=r\n\t\tdefaultSymbolClassName: \"fas fa-fw fa-\",\n\t\tshowLocation: false,\n\t\tdisplayRepeatingCountTitle: false,\n\t\tdefaultRepeatingCountTitle: \"\",\n\t\tmaxTitleLength: 25,\n\t\tmaxLocationTitleLength: 25,\n\t\twrapEvents: false, // Wrap events to multiple lines breaking at maxTitleLength\n\t\twrapLocationEvents: false,\n\t\tmaxTitleLines: 3,\n\t\tmaxEventTitleLines: 3,\n\t\tfetchInterval: 60 * 60 * 1000, // Update every hour\n\t\tanimationSpeed: 2000,\n\t\tfade: true,\n\t\tfadePoint: 0.25, // Start on 1/4th of the list.\n\t\turgency: 7,\n\t\ttimeFormat: \"relative\",\n\t\tdateFormat: \"MMM Do\",\n\t\tdateEndFormat: \"LT\",\n\t\tfullDayEventDateFormat: \"MMM Do\",\n\t\tshowEnd: false,\n\t\tshowEndsOnlyWithDuration: false,\n\t\tgetRelative: 6,\n\t\thidePrivate: false,\n\t\thideOngoing: false,\n\t\thideTime: false,\n\t\thideDuplicates: true,\n\t\tshowTimeToday: false,\n\t\tcolored: false,\n\t\tforceUseCurrentTime: false,\n\t\ttableClass: \"small\",\n\t\tcalendars: [\n\t\t\t{\n\t\t\t\tsymbol: \"calendar-alt\",\n\t\t\t\turl: \"https://www.calendarlabs.com/templates/ical/US-Holidays.ics\"\n\t\t\t}\n\t\t],\n\t\tcustomEvents: [\n\t\t\t// Array of {keyword: \"\", symbol: \"\", color: \"\", eventClass: \"\"} where Keyword is a regexp and symbol/color/eventClass are to be applied for matched\n\t\t\t{ keyword: \".*\", transform: { search: \"De verjaardag van \", replace: \"\" } },\n\t\t\t{ keyword: \".*\", transform: { search: \"'s birthday\", replace: \"\" } }\n\t\t],\n\t\tlocationTitleReplace: {\n\t\t\t\"street \": \"\"\n\t\t},\n\t\tbroadcastEvents: true,\n\t\texcludedEvents: [],\n\t\tsliceMultiDayEvents: false,\n\t\tbroadcastPastEvents: false,\n\t\tnextDaysRelative: false,\n\t\tselfSignedCert: false,\n\t\tcoloredText: false,\n\t\tcoloredBorder: false,\n\t\tcoloredSymbol: false,\n\t\tcoloredBackground: false,\n\t\tlimitDaysNeverSkip: false,\n\t\tflipDateHeaderTitle: false,\n\t\tupdateOnFetch: true\n\t},\n\n\t// Define required scripts.\n\tgetStyles () {\n\t\treturn [\"calendar.css\", \"font-awesome.css\"];\n\t},\n\n\t// Define required scripts.\n\tgetScripts () {\n\t\treturn [\"calendarutils.js\", \"moment.js\", \"moment-timezone.js\"];\n\t},\n\n\t// Define required translations.\n\tgetTranslations () {\n\n\t\t/*\n\t\t * The translations for the default modules are defined in the core translation files.\n\t\t * Therefore we can just return false. Otherwise we should have returned a dictionary.\n\t\t * If you're trying to build your own module including translations, check out the documentation.\n\t\t */\n\t\treturn false;\n\t},\n\n\t// Override start method.\n\tstart () {\n\t\tLog.info(`Starting module: ${this.name}`);\n\n\t\tif (this.config.colored) {\n\t\t\tLog.warn(\"[calendar] Your are using the deprecated config values 'colored'. Please switch to 'coloredSymbol' & 'coloredText'!\");\n\t\t\tthis.config.coloredText = true;\n\t\t\tthis.config.coloredSymbol = true;\n\t\t}\n\t\tif (this.config.coloredSymbolOnly) {\n\t\t\tLog.warn(\"[calendar] Your are using the deprecated config values 'coloredSymbolOnly'. Please switch to 'coloredSymbol' & 'coloredText'!\");\n\t\t\tthis.config.coloredText = false;\n\t\t\tthis.config.coloredSymbol = true;\n\t\t}\n\n\t\t// Set locale.\n\t\tmoment.updateLocale(config.language, CalendarUtils.getLocaleSpecification(config.timeFormat));\n\n\t\t// clear data holder before start\n\t\tthis.calendarData = {};\n\n\t\t// indicate no data available yet\n\t\tthis.loaded = false;\n\n\t\t// data holder of calendar url. Avoid fade out/in on updateDom (one for each calendar update)\n\t\tthis.calendarDisplayer = {};\n\n\t\tthis.config.calendars.forEach((calendar) => {\n\t\t\tcalendar.url = calendar.url.replace(\"webcal://\", \"http://\");\n\n\t\t\tconst calendarConfig = {\n\t\t\t\tmaximumEntries: calendar.maximumEntries,\n\t\t\t\tmaximumNumberOfDays: calendar.maximumNumberOfDays,\n\t\t\t\tpastDaysCount: calendar.pastDaysCount,\n\t\t\t\tbroadcastPastEvents: calendar.broadcastPastEvents,\n\t\t\t\tselfSignedCert: calendar.selfSignedCert,\n\t\t\t\texcludedEvents: calendar.excludedEvents,\n\t\t\t\tfetchInterval: calendar.fetchInterval\n\t\t\t};\n\n\t\t\tif (typeof calendar.symbolClass === \"undefined\" || calendar.symbolClass === null) {\n\t\t\t\tcalendarConfig.symbolClass = \"\";\n\t\t\t}\n\t\t\tif (typeof calendar.titleClass === \"undefined\" || calendar.titleClass === null) {\n\t\t\t\tcalendarConfig.titleClass = \"\";\n\t\t\t}\n\t\t\tif (typeof calendar.timeClass === \"undefined\" || calendar.timeClass === null) {\n\t\t\t\tcalendarConfig.timeClass = \"\";\n\t\t\t}\n\n\t\t\t// we check user and password here for backwards compatibility with old configs\n\t\t\tif (calendar.user && calendar.pass) {\n\t\t\t\tLog.warn(\"[calendar] Deprecation warning: Please update your calendar authentication configuration.\");\n\t\t\t\tLog.warn(\"https://docs.magicmirror.builders/modules/calendar.html#configuration-options\");\n\t\t\t\tcalendar.auth = {\n\t\t\t\t\tuser: calendar.user,\n\t\t\t\t\tpass: calendar.pass\n\t\t\t\t};\n\t\t\t}\n\n\t\t\t/*\n\t\t\t * tell helper to start a fetcher for this calendar\n\t\t\t * fetcher till cycle\n\t\t\t */\n\t\t\tthis.addCalendar(calendar.url, calendar.auth, calendarConfig);\n\t\t});\n\n\t\t// for backward compatibility titleReplace\n\t\tif (typeof this.config.titleReplace !== \"undefined\") {\n\t\t\tLog.warn(\"[calendar] Deprecation warning: Please consider upgrading your calendar titleReplace configuration to customEvents.\");\n\t\t\tfor (const [titlesearchstr, titlereplacestr] of Object.entries(this.config.titleReplace)) {\n\t\t\t\tthis.config.customEvents.push({ keyword: \".*\", transform: { search: titlesearchstr, replace: titlereplacestr } });\n\t\t\t}\n\t\t}\n\n\t\tthis.selfUpdate();\n\t},\n\n\tnotificationReceived (notification, payload, sender) {\n\t\tif (notification === \"FETCH_CALENDAR\") {\n\t\t\tif (this.hasCalendarURL(payload.url)) {\n\t\t\t\tthis.sendSocketNotification(notification, { url: payload.url, id: this.identifier });\n\t\t\t}\n\t\t}\n\t},\n\n\t// Override socket notification handler.\n\tsocketNotificationReceived (notification, payload) {\n\n\t\tif (this.identifier !== payload.id) {\n\t\t\treturn;\n\t\t}\n\n\t\tif (notification === \"CALENDAR_EVENTS\") {\n\t\t\tif (this.hasCalendarURL(payload.url)) {\n\t\t\t\t// have we received events for this url\n\t\t\t\tif (!this.calendarData[payload.url]) {\n\t\t\t\t\t// no, setup the structure to hold the info\n\t\t\t\t\tthis.calendarData[payload.url] = { events: null, checksum: null };\n\t\t\t\t}\n\t\t\t\t// save the event list\n\t\t\t\tthis.calendarData[payload.url].events = payload.events;\n\n\t\t\t\tthis.error = null;\n\t\t\t\tthis.loaded = true;\n\n\t\t\t\tif (this.config.broadcastEvents) {\n\t\t\t\t\tthis.broadcastEvents();\n\t\t\t\t}\n\t\t\t\t// if the checksum is the same\n\t\t\t\tif (this.calendarData[payload.url].checksum === payload.checksum) {\n\t\t\t\t\t// then don't update the UI\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\t// haven't seen or the checksum is different\n\t\t\t\tthis.calendarData[payload.url].checksum = payload.checksum;\n\n\t\t\t\tif (!this.config.updateOnFetch) {\n\t\t\t\t\tif (this.calendarDisplayer[payload.url] === undefined) {\n\t\t\t\t\t\t// calendar will never displayed, so display it\n\t\t\t\t\t\tthis.updateDom(this.config.animationSpeed);\n\t\t\t\t\t\t// set this calendar as displayed\n\t\t\t\t\t\tthis.calendarDisplayer[payload.url] = true;\n\t\t\t\t\t} else {\n\t\t\t\t\t\tLog.debug(\"[calendar] DOM not updated waiting self update()\");\n\t\t\t\t\t}\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\t\t} else if (notification === \"CALENDAR_ERROR\") {\n\t\t\tlet error_message = this.translate(payload.error_type);\n\t\t\tthis.error = this.translate(\"MODULE_CONFIG_ERROR\", { MODULE_NAME: this.name, ERROR: error_message });\n\t\t\tthis.loaded = true;\n\t\t}\n\n\t\tthis.updateDom(this.config.animationSpeed);\n\t},\n\n\t// Override dom generator.\n\tgetDom () {\n\t\tconst events = this.createEventList(true);\n\t\tconst wrapper = document.createElement(\"table\");\n\t\twrapper.className = this.config.tableClass;\n\n\t\tif (this.error) {\n\t\t\twrapper.innerHTML = this.error;\n\t\t\twrapper.className = `${this.config.tableClass} dimmed`;\n\t\t\treturn wrapper;\n\t\t}\n\n\t\tif (events.length === 0) {\n\t\t\twrapper.innerHTML = this.loaded ? this.translate(\"EMPTY\") : this.translate(\"LOADING\");\n\t\t\twrapper.className = `${this.config.tableClass} dimmed`;\n\t\t\treturn wrapper;\n\t\t}\n\n\t\tlet currentFadeStep = 0;\n\t\tlet startFade;\n\t\tlet fadeSteps;\n\n\t\tif (this.config.fade && this.config.fadePoint < 1) {\n\t\t\tif (this.config.fadePoint < 0) {\n\t\t\t\tthis.config.fadePoint = 0;\n\t\t\t}\n\t\t\tstartFade = events.length * this.config.fadePoint;\n\t\t\tfadeSteps = events.length - startFade;\n\t\t}\n\n\t\tlet lastSeenDate = \"\";\n\n\t\tevents.forEach((event, index) => {\n\t\t\tconst eventStartDateMoment = this.timestampToMoment(event.startDate);\n\t\t\tconst eventEndDateMoment = this.timestampToMoment(event.endDate);\n\t\t\tconst dateAsString = eventStartDateMoment.format(this.config.dateFormat);\n\t\t\tif (this.config.timeFormat === \"dateheaders\") {\n\t\t\t\tif (lastSeenDate !== dateAsString) {\n\t\t\t\t\tconst dateRow = document.createElement(\"tr\");\n\t\t\t\t\tdateRow.className = \"dateheader normal\";\n\t\t\t\t\tif (event.today) dateRow.className += \" today\";\n\t\t\t\t\telse if (event.dayBeforeYesterday) dateRow.className += \" dayBeforeYesterday\";\n\t\t\t\t\telse if (event.yesterday) dateRow.className += \" yesterday\";\n\t\t\t\t\telse if (event.tomorrow) dateRow.className += \" tomorrow\";\n\t\t\t\t\telse if (event.dayAfterTomorrow) dateRow.className += \" dayAfterTomorrow\";\n\n\t\t\t\t\tconst dateCell = document.createElement(\"td\");\n\t\t\t\t\tdateCell.colSpan = \"3\";\n\t\t\t\t\tdateCell.innerHTML = dateAsString;\n\t\t\t\t\tdateCell.style.paddingTop = \"10px\";\n\t\t\t\t\tdateRow.appendChild(dateCell);\n\t\t\t\t\twrapper.appendChild(dateRow);\n\n\t\t\t\t\tif (this.config.fade && index >= startFade) {\n\t\t\t\t\t\t//fading\n\t\t\t\t\t\tcurrentFadeStep = index - startFade;\n\t\t\t\t\t\tdateRow.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep;\n\t\t\t\t\t}\n\n\t\t\t\t\tlastSeenDate = dateAsString;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst eventWrapper = document.createElement(\"tr\");\n\n\t\t\tif (this.config.coloredText) {\n\t\t\t\teventWrapper.style.cssText = `color:${this.colorForUrl(event.url, false)}`;\n\t\t\t}\n\n\t\t\tif (this.config.coloredBackground) {\n\t\t\t\teventWrapper.style.backgroundColor = this.colorForUrl(event.url, true);\n\t\t\t}\n\n\t\t\tif (this.config.coloredBorder) {\n\t\t\t\teventWrapper.style.borderColor = this.colorForUrl(event.url, false);\n\t\t\t}\n\n\t\t\teventWrapper.className = \"event-wrapper normal event\";\n\t\t\tif (event.today) eventWrapper.className += \" today\";\n\t\t\telse if (event.dayBeforeYesterday) eventWrapper.className += \" dayBeforeYesterday\";\n\t\t\telse if (event.yesterday) eventWrapper.className += \" yesterday\";\n\t\t\telse if (event.tomorrow) eventWrapper.className += \" tomorrow\";\n\t\t\telse if (event.dayAfterTomorrow) eventWrapper.className += \" dayAfterTomorrow\";\n\n\t\t\tconst symbolWrapper = document.createElement(\"td\");\n\n\t\t\tif (this.config.displaySymbol) {\n\t\t\t\tif (this.config.coloredSymbol) {\n\t\t\t\t\tsymbolWrapper.style.cssText = `color:${this.colorForUrl(event.url, false)}`;\n\t\t\t\t}\n\n\t\t\t\tconst symbolClass = this.symbolClassForUrl(event.url);\n\t\t\t\tsymbolWrapper.className = `symbol ${symbolClass}`;\n\n\t\t\t\tconst symbols = this.symbolsForEvent(event);\n\t\t\t\tsymbols.forEach((s) => {\n\t\t\t\t\tconst symbol = document.createElement(\"span\");\n\t\t\t\t\tsymbol.className = s;\n\t\t\t\t\tsymbolWrapper.appendChild(symbol);\n\t\t\t\t});\n\t\t\t\teventWrapper.appendChild(symbolWrapper);\n\t\t\t} else if (this.config.timeFormat === \"dateheaders\") {\n\t\t\t\tconst blankCell = document.createElement(\"td\");\n\t\t\t\tblankCell.innerHTML = \"&nbsp;&nbsp;&nbsp;\";\n\t\t\t\teventWrapper.appendChild(blankCell);\n\t\t\t}\n\n\t\t\tconst titleWrapper = document.createElement(\"td\");\n\t\t\tlet repeatingCountTitle = \"\";\n\n\t\t\tif (this.config.displayRepeatingCountTitle && event.firstYear !== undefined) {\n\t\t\t\trepeatingCountTitle = this.countTitleForUrl(event.url);\n\n\t\t\t\tif (repeatingCountTitle !== \"\") {\n\t\t\t\t\tconst thisYear = eventStartDateMoment.year(),\n\t\t\t\t\t\tyearDiff = thisYear - event.firstYear;\n\n\t\t\t\t\tif (yearDiff > 0) {\n\t\t\t\t\t\trepeatingCountTitle = `, ${yearDiff} ${repeatingCountTitle}`;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tvar transformedTitle = event.title;\n\n\t\t\t// Color events if custom color or eventClass are specified, transform title if required\n\t\t\tif (this.config.customEvents.length > 0) {\n\t\t\t\tfor (let ev in this.config.customEvents) {\n\t\t\t\t\tlet needle = new RegExp(this.config.customEvents[ev].keyword, \"gi\");\n\t\t\t\t\tif (needle.test(event.title)) {\n\t\t\t\t\t\tif (typeof this.config.customEvents[ev].transform === \"object\") {\n\t\t\t\t\t\t\ttransformedTitle = CalendarUtils.titleTransform(transformedTitle, [this.config.customEvents[ev].transform]);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (typeof this.config.customEvents[ev].color !== \"undefined\" && this.config.customEvents[ev].color !== \"\") {\n\t\t\t\t\t\t\t// Respect parameter ColoredSymbolOnly also for custom events\n\t\t\t\t\t\t\tif (this.config.coloredText) {\n\t\t\t\t\t\t\t\teventWrapper.style.cssText = `color:${this.config.customEvents[ev].color}`;\n\t\t\t\t\t\t\t\ttitleWrapper.style.cssText = `color:${this.config.customEvents[ev].color}`;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif (this.config.displaySymbol && this.config.coloredSymbol) {\n\t\t\t\t\t\t\t\tsymbolWrapper.style.cssText = `color:${this.config.customEvents[ev].color}`;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (typeof this.config.customEvents[ev].eventClass !== \"undefined\" && this.config.customEvents[ev].eventClass !== \"\") {\n\t\t\t\t\t\t\teventWrapper.className += ` ${this.config.customEvents[ev].eventClass}`;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\ttitleWrapper.innerHTML = CalendarUtils.shorten(transformedTitle, this.config.maxTitleLength, this.config.wrapEvents, this.config.maxTitleLines) + repeatingCountTitle;\n\n\t\t\tconst titleClass = this.titleClassForUrl(event.url);\n\n\t\t\tif (!this.config.coloredText) {\n\t\t\t\ttitleWrapper.className = `title bright ${titleClass}`;\n\t\t\t} else {\n\t\t\t\ttitleWrapper.className = `title ${titleClass}`;\n\t\t\t}\n\n\t\t\tif (this.config.timeFormat === \"dateheaders\") {\n\t\t\t\tif (this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper);\n\n\t\t\t\tif (event.fullDayEvent) {\n\t\t\t\t\ttitleWrapper.colSpan = \"2\";\n\t\t\t\t\ttitleWrapper.classList.add(\"align-left\");\n\t\t\t\t} else {\n\t\t\t\t\tconst timeWrapper = document.createElement(\"td\");\n\t\t\t\t\ttimeWrapper.className = `time light ${this.config.flipDateHeaderTitle ? \"align-right \" : \"align-left \"}${this.timeClassForUrl(event.url)}`;\n\t\t\t\t\ttimeWrapper.style.paddingLeft = \"2px\";\n\t\t\t\t\ttimeWrapper.style.textAlign = this.config.flipDateHeaderTitle ? \"right\" : \"left\";\n\t\t\t\t\ttimeWrapper.innerHTML = eventStartDateMoment.format(\"LT\");\n\n\t\t\t\t\t// Add endDate to dataheaders if showEnd is enabled\n\t\t\t\t\tif (this.config.showEnd) {\n\t\t\t\t\t\tif (this.config.showEndsOnlyWithDuration && event.startDate === event.endDate) {\n\t\t\t\t\t\t\t// no duration here, don't display end\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\ttimeWrapper.innerHTML += ` - ${CalendarUtils.capFirst(eventEndDateMoment.format(\"LT\"))}`;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\teventWrapper.appendChild(timeWrapper);\n\n\t\t\t\t\tif (!this.config.flipDateHeaderTitle) titleWrapper.classList.add(\"align-right\");\n\t\t\t\t}\n\t\t\t\tif (!this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper);\n\t\t\t} else {\n\t\t\t\tconst timeWrapper = document.createElement(\"td\");\n\n\t\t\t\teventWrapper.appendChild(titleWrapper);\n\t\t\t\tconst now = moment();\n\n\t\t\t\tif (this.config.timeFormat === \"absolute\") {\n\t\t\t\t\t// Use dateFormat\n\t\t\t\t\ttimeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.dateFormat));\n\t\t\t\t\t// Add end time if showEnd\n\t\t\t\t\tif (this.config.showEnd) {\n\t\t\t\t\t\t// and has a duration\n\t\t\t\t\t\tif (event.startDate !== event.endDate) {\n\t\t\t\t\t\t\ttimeWrapper.innerHTML += \"-\";\n\t\t\t\t\t\t\ttimeWrapper.innerHTML += CalendarUtils.capFirst(eventEndDateMoment.format(this.config.dateEndFormat));\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\t// For full day events we use the fullDayEventDateFormat\n\t\t\t\t\tif (event.fullDayEvent) {\n\t\t\t\t\t\t//subtract one second so that fullDayEvents end at 23:59:59, and not at 0:00:00 one the next day\n\t\t\t\t\t\teventEndDateMoment.subtract(1, \"second\");\n\t\t\t\t\t\ttimeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.fullDayEventDateFormat));\n\t\t\t\t\t\t// only show end if requested and allowed and the dates are different\n\t\t\t\t\t\tif (this.config.showEnd && !this.config.showEndsOnlyWithDuration && !eventStartDateMoment.isSame(eventEndDateMoment, \"d\")) {\n\t\t\t\t\t\t\ttimeWrapper.innerHTML += \"-\";\n\t\t\t\t\t\t\ttimeWrapper.innerHTML += CalendarUtils.capFirst(eventEndDateMoment.format(this.config.fullDayEventDateFormat));\n\t\t\t\t\t\t} else if (!eventStartDateMoment.isSame(eventEndDateMoment, \"d\") && eventStartDateMoment.isBefore(now)) {\n\t\t\t\t\t\t\ttimeWrapper.innerHTML = CalendarUtils.capFirst(now.format(this.config.fullDayEventDateFormat));\n\t\t\t\t\t\t}\n\t\t\t\t\t} else if (this.config.getRelative > 0 && eventStartDateMoment.isBefore(now)) {\n\t\t\t\t\t\t// Ongoing and getRelative is set\n\t\t\t\t\t\ttimeWrapper.innerHTML = CalendarUtils.capFirst(\n\t\t\t\t\t\t\tthis.translate(\"RUNNING\", {\n\t\t\t\t\t\t\t\tfallback: `${this.translate(\"RUNNING\")} {timeUntilEnd}`,\n\t\t\t\t\t\t\t\ttimeUntilEnd: eventEndDateMoment.fromNow(true)\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t);\n\t\t\t\t\t} else if (this.config.urgency > 0 && eventStartDateMoment.diff(now, \"d\") < this.config.urgency) {\n\t\t\t\t\t\t// Within urgency days\n\t\t\t\t\t\ttimeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.fromNow());\n\t\t\t\t\t}\n\t\t\t\t\tif (event.fullDayEvent && this.config.nextDaysRelative) {\n\t\t\t\t\t\t// Full days events within the next two days\n\t\t\t\t\t\tif (event.today) {\n\t\t\t\t\t\t\ttimeWrapper.innerHTML = CalendarUtils.capFirst(this.translate(\"TODAY\"));\n\t\t\t\t\t\t} else if (event.yesterday) {\n\t\t\t\t\t\t\ttimeWrapper.innerHTML = CalendarUtils.capFirst(this.translate(\"YESTERDAY\"));\n\t\t\t\t\t\t} else if (event.tomorrow) {\n\t\t\t\t\t\t\ttimeWrapper.innerHTML = CalendarUtils.capFirst(this.translate(\"TOMORROW\"));\n\t\t\t\t\t\t} else if (event.dayAfterTomorrow) {\n\t\t\t\t\t\t\tif (this.translate(\"DAYAFTERTOMORROW\") !== \"DAYAFTERTOMORROW\") {\n\t\t\t\t\t\t\t\ttimeWrapper.innerHTML = CalendarUtils.capFirst(this.translate(\"DAYAFTERTOMORROW\"));\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Show relative times\n\t\t\t\t\tif (eventStartDateMoment.isSameOrAfter(now) || (event.fullDayEvent && eventEndDateMoment.diff(now, \"days\") === 0)) {\n\t\t\t\t\t\t// Use relative time\n\t\t\t\t\t\tif (!this.config.hideTime && !event.fullDayEvent) {\n\t\t\t\t\t\t\tLog.debug(\"[calendar] event not hidden and not fullday\");\n\t\t\t\t\t\t\ttimeWrapper.innerHTML = `${CalendarUtils.capFirst(eventStartDateMoment.calendar(null, { sameElse: this.config.dateFormat }))}`;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tLog.debug(\"[calendar] event full day or hidden\");\n\t\t\t\t\t\t\ttimeWrapper.innerHTML = `${CalendarUtils.capFirst(\n\t\t\t\t\t\t\t\teventStartDateMoment.calendar(null, {\n\t\t\t\t\t\t\t\t\tsameDay: this.config.showTimeToday ? \"LT\" : `[${this.translate(\"TODAY\")}]`,\n\t\t\t\t\t\t\t\t\tnextDay: `[${this.translate(\"TOMORROW\")}]`,\n\t\t\t\t\t\t\t\t\tnextWeek: \"dddd\",\n\t\t\t\t\t\t\t\t\tsameElse: event.fullDayEvent ? this.config.fullDayEventDateFormat : this.config.dateFormat\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t)}`;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (event.fullDayEvent) {\n\t\t\t\t\t\t\t// Full days events within the next two days\n\t\t\t\t\t\t\tif (event.today || (event.fullDayEvent && eventEndDateMoment.diff(now, \"days\") === 0)) {\n\t\t\t\t\t\t\t\ttimeWrapper.innerHTML = CalendarUtils.capFirst(this.translate(\"TODAY\"));\n\t\t\t\t\t\t\t} else if (event.dayBeforeYesterday) {\n\t\t\t\t\t\t\t\tif (this.translate(\"DAYBEFOREYESTERDAY\") !== \"DAYBEFOREYESTERDAY\") {\n\t\t\t\t\t\t\t\t\ttimeWrapper.innerHTML = CalendarUtils.capFirst(this.translate(\"DAYBEFOREYESTERDAY\"));\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t} else if (event.yesterday) {\n\t\t\t\t\t\t\t\ttimeWrapper.innerHTML = CalendarUtils.capFirst(this.translate(\"YESTERDAY\"));\n\t\t\t\t\t\t\t} else if (event.tomorrow) {\n\t\t\t\t\t\t\t\ttimeWrapper.innerHTML = CalendarUtils.capFirst(this.translate(\"TOMORROW\"));\n\t\t\t\t\t\t\t} else if (event.dayAfterTomorrow) {\n\t\t\t\t\t\t\t\tif (this.translate(\"DAYAFTERTOMORROW\") !== \"DAYAFTERTOMORROW\") {\n\t\t\t\t\t\t\t\t\ttimeWrapper.innerHTML = CalendarUtils.capFirst(this.translate(\"DAYAFTERTOMORROW\"));\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tLog.info(\"[calendar] event fullday\");\n\t\t\t\t\t\t} else if (eventStartDateMoment.diff(now, \"h\") < this.config.getRelative) {\n\t\t\t\t\t\t\tLog.info(\"[calendar] not full day but within getRelative size\");\n\t\t\t\t\t\t\t// If event is within getRelative hours, display 'in xxx' time format or moment.fromNow()\n\t\t\t\t\t\t\ttimeWrapper.innerHTML = `${CalendarUtils.capFirst(eventStartDateMoment.fromNow())}`;\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// Ongoing event\n\t\t\t\t\t\ttimeWrapper.innerHTML = CalendarUtils.capFirst(\n\t\t\t\t\t\t\tthis.translate(\"RUNNING\", {\n\t\t\t\t\t\t\t\tfallback: `${this.translate(\"RUNNING\")} {timeUntilEnd}`,\n\t\t\t\t\t\t\t\ttimeUntilEnd: eventEndDateMoment.fromNow(true)\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\ttimeWrapper.className = `time light ${this.timeClassForUrl(event.url)}`;\n\t\t\t\teventWrapper.appendChild(timeWrapper);\n\t\t\t}\n\n\t\t\t// Create fade effect.\n\t\t\tif (index >= startFade) {\n\t\t\t\tcurrentFadeStep = index - startFade;\n\t\t\t\teventWrapper.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep;\n\t\t\t}\n\t\t\twrapper.appendChild(eventWrapper);\n\n\t\t\tif (this.config.showLocation) {\n\t\t\t\tif (event.location !== false) {\n\t\t\t\t\tconst locationRow = document.createElement(\"tr\");\n\t\t\t\t\tlocationRow.className = \"event-wrapper-location normal xsmall light\";\n\t\t\t\t\tif (event.today) locationRow.className += \" today\";\n\t\t\t\t\telse if (event.dayBeforeYesterday) locationRow.className += \" dayBeforeYesterday\";\n\t\t\t\t\telse if (event.yesterday) locationRow.className += \" yesterday\";\n\t\t\t\t\telse if (event.tomorrow) locationRow.className += \" tomorrow\";\n\t\t\t\t\telse if (event.dayAfterTomorrow) locationRow.className += \" dayAfterTomorrow\";\n\n\t\t\t\t\tif (this.config.displaySymbol) {\n\t\t\t\t\t\tconst symbolCell = document.createElement(\"td\");\n\t\t\t\t\t\tlocationRow.appendChild(symbolCell);\n\t\t\t\t\t}\n\n\t\t\t\t\tif (this.config.coloredText) {\n\t\t\t\t\t\tlocationRow.style.cssText = `color:${this.colorForUrl(event.url, false)}`;\n\t\t\t\t\t}\n\n\t\t\t\t\tif (this.config.coloredBackground) {\n\t\t\t\t\t\tlocationRow.style.backgroundColor = this.colorForUrl(event.url, true);\n\t\t\t\t\t}\n\n\t\t\t\t\tif (this.config.coloredBorder) {\n\t\t\t\t\t\tlocationRow.style.borderColor = this.colorForUrl(event.url, false);\n\t\t\t\t\t}\n\n\t\t\t\t\tconst descCell = document.createElement(\"td\");\n\t\t\t\t\tdescCell.className = \"location\";\n\t\t\t\t\tdescCell.colSpan = \"2\";\n\n\t\t\t\t\tconst transformedTitle = CalendarUtils.titleTransform(event.location, this.config.locationTitleReplace);\n\t\t\t\t\tdescCell.innerHTML = CalendarUtils.shorten(transformedTitle, this.config.maxLocationTitleLength, this.config.wrapLocationEvents, this.config.maxEventTitleLines);\n\t\t\t\t\tlocationRow.appendChild(descCell);\n\n\t\t\t\t\twrapper.appendChild(locationRow);\n\n\t\t\t\t\tif (index >= startFade) {\n\t\t\t\t\t\tcurrentFadeStep = index - startFade;\n\t\t\t\t\t\tlocationRow.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\n\t\treturn wrapper;\n\t},\n\n\t/**\n\t * Checks if this config contains the calendar url.\n\t * @param {string} url The calendar url\n\t * @returns {boolean} True if the calendar config contains the url, False otherwise\n\t */\n\thasCalendarURL (url) {\n\t\tfor (const calendar of this.config.calendars) {\n\t\t\tif (calendar.url === url) {\n\t\t\t\treturn true;\n\t\t\t}\n\t\t}\n\n\t\treturn false;\n\t},\n\n\t/**\n\t * converts the given timestamp to a moment with a timezone\n\t * @param {number} timestamp timestamp from an event\n\t * @returns {moment.Moment} moment with a timezone\n\t */\n\ttimestampToMoment (timestamp) {\n\t\treturn moment(timestamp, \"x\").tz(moment.tz.guess());\n\t},\n\n\t/**\n\t * Creates the sorted list of all events.\n\t * @param {boolean} limitNumberOfEntries Whether to filter returned events for display.\n\t * @returns {object[]} Array with events.\n\t */\n\tcreateEventList (limitNumberOfEntries) {\n\t\tlet now = moment();\n\t\tlet future = now.clone().startOf(\"day\").add(this.config.maximumNumberOfDays, \"days\");\n\n\t\tlet events = [];\n\n\t\tfor (const calendarUrl in this.calendarData) {\n\t\t\tconst calendar = this.calendarData[calendarUrl].events;\n\t\t\tlet remainingEntries = this.maximumEntriesForUrl(calendarUrl);\n\t\t\tlet maxPastDaysCompare = now.clone().subtract(this.maximumPastDaysForUrl(calendarUrl), \"days\");\n\t\t\tlet by_url_calevents = [];\n\t\t\tfor (const e in calendar) {\n\t\t\t\tconst event = JSON.parse(JSON.stringify(calendar[e])); // clone object\n\t\t\t\tconst eventStartDateMoment = this.timestampToMoment(event.startDate);\n\t\t\t\tconst eventEndDateMoment = this.timestampToMoment(event.endDate);\n\n\t\t\t\tif (this.config.hidePrivate && event.class === \"PRIVATE\") {\n\t\t\t\t\t// do not add the current event, skip it\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif (limitNumberOfEntries) {\n\t\t\t\t\tif (eventEndDateMoment.isBefore(maxPastDaysCompare)) {\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t\tif (this.config.hideOngoing && eventStartDateMoment.isBefore(now)) {\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t\tif (this.config.hideDuplicates && this.listContainsEvent(events, event)) {\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tevent.url = calendarUrl;\n\t\t\t\tevent.today = eventStartDateMoment.isSame(now, \"d\");\n\t\t\t\tevent.dayBeforeYesterday = eventStartDateMoment.isSame(now.clone().subtract(2, \"days\"), \"d\");\n\t\t\t\tevent.yesterday = eventStartDateMoment.isSame(now.clone().subtract(1, \"days\"), \"d\");\n\t\t\t\tevent.tomorrow = eventStartDateMoment.isSame(now.clone().add(1, \"days\"), \"d\");\n\t\t\t\tevent.dayAfterTomorrow = eventStartDateMoment.isSame(now.clone().add(2, \"days\"), \"d\");\n\n\t\t\t\t/*\n\t\t\t\t * if sliceMultiDayEvents is set to true, multiday events (events exceeding at least one midnight) are sliced into days,\n\t\t\t\t * otherwise, esp. in dateheaders mode it is not clear how long these events are.\n\t\t\t\t */\n\t\t\t\tconst maxCount = eventEndDateMoment.diff(eventStartDateMoment, \"days\");\n\t\t\t\tif (this.config.sliceMultiDayEvents && maxCount > 1) {\n\t\t\t\t\tconst splitEvents = [];\n\t\t\t\t\tlet midnight\n\t\t\t\t\t\t= eventStartDateMoment\n\t\t\t\t\t\t\t.clone()\n\t\t\t\t\t\t\t.startOf(\"day\")\n\t\t\t\t\t\t\t.add(1, \"day\")\n\t\t\t\t\t\t\t.endOf(\"day\");\n\t\t\t\t\tlet count = 1;\n\t\t\t\t\twhile (eventEndDateMoment.isAfter(midnight)) {\n\t\t\t\t\t\tconst thisEvent = JSON.parse(JSON.stringify(event)); // clone object\n\t\t\t\t\t\tthisEvent.today = this.timestampToMoment(thisEvent.startDate).isSame(now, \"d\");\n\t\t\t\t\t\tthisEvent.tomorrow = this.timestampToMoment(thisEvent.startDate).isSame(now.clone().add(1, \"days\"), \"d\");\n\t\t\t\t\t\tthisEvent.endDate = midnight.clone().subtract(1, \"day\").format(\"x\");\n\t\t\t\t\t\tthisEvent.title += ` (${count}/${maxCount})`;\n\t\t\t\t\t\tsplitEvents.push(thisEvent);\n\n\t\t\t\t\t\tevent.startDate = midnight.format(\"x\");\n\t\t\t\t\t\tcount += 1;\n\t\t\t\t\t\tmidnight = midnight.clone().add(1, \"day\").endOf(\"day\"); // next day\n\t\t\t\t\t}\n\t\t\t\t\t// Last day\n\t\t\t\t\tevent.title += ` (${count}/${maxCount})`;\n\t\t\t\t\tevent.today += this.timestampToMoment(event.startDate).isSame(now, \"d\");\n\t\t\t\t\tevent.tomorrow = this.timestampToMoment(event.startDate).isSame(now.clone().add(1, \"days\"), \"d\");\n\t\t\t\t\tsplitEvents.push(event);\n\n\t\t\t\t\tfor (let splitEvent of splitEvents) {\n\t\t\t\t\t\tif (this.timestampToMoment(splitEvent.endDate).isAfter(now) && this.timestampToMoment(splitEvent.endDate).isSameOrBefore(future)) {\n\t\t\t\t\t\t\tby_url_calevents.push(splitEvent);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tby_url_calevents.push(event);\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (limitNumberOfEntries) {\n\t\t\t\t// sort entries before clipping\n\t\t\t\tby_url_calevents.sort(function (a, b) {\n\t\t\t\t\treturn a.startDate - b.startDate;\n\t\t\t\t});\n\t\t\t\tLog.debug(`[calendar] pushing ${by_url_calevents.length} events to total with room for ${remainingEntries}`);\n\t\t\t\tevents = events.concat(by_url_calevents.slice(0, remainingEntries));\n\t\t\t\tLog.debug(`[calendar] events for calendar=${events.length}`);\n\t\t\t} else {\n\t\t\t\tevents = events.concat(by_url_calevents);\n\t\t\t}\n\t\t}\n\t\tLog.info(`[calendar] sorting events count=${events.length}`);\n\t\tevents.sort(function (a, b) {\n\t\t\treturn a.startDate - b.startDate;\n\t\t});\n\n\t\tif (!limitNumberOfEntries) {\n\t\t\treturn events;\n\t\t}\n\n\t\t/*\n\t\t * Limit the number of days displayed\n\t\t * If limitDays is set > 0, limit display to that number of days\n\t\t */\n\t\tif (this.config.limitDays > 0 && events.length > 0) { // watch out for initial display before events arrive from helper\n\t\t\t// Group all events by date, events on the same date will be in a list with the key being the date.\n\t\t\tconst eventsByDate = Object.groupBy(events, (ev) => this.timestampToMoment(ev.startDate).format(\"YYYY-MM-DD\"));\n\t\t\tconst newEvents = [];\n\t\t\tlet currentDate = moment();\n\t\t\tlet daysCollected = 0;\n\n\t\t\twhile (daysCollected < this.config.limitDays) {\n\t\t\t\tconst dateStr = currentDate.format(\"YYYY-MM-DD\");\n\t\t\t\t// Check if there are events on the currentDate\n\t\t\t\tif (eventsByDate[dateStr] && eventsByDate[dateStr].length > 0) {\n\t\t\t\t\t// If there are any events today then get all those events and select the currently active events and the events that are starting later in the day.\n\t\t\t\t\tnewEvents.push(...eventsByDate[dateStr].filter((ev) => this.timestampToMoment(ev.endDate).isAfter(moment())));\n\t\t\t\t\t// Since we found a day with events, increase the daysCollected by 1\n\t\t\t\t\tdaysCollected++;\n\t\t\t\t}\n\t\t\t\t// Search for the next day\n\t\t\t\tcurrentDate.add(1, \"day\");\n\t\t\t}\n\t\t\tevents = newEvents;\n\t\t}\n\t\tLog.info(`[calendar] slicing events total maxCount=${this.config.maximumEntries}`);\n\t\treturn events.slice(0, this.config.maximumEntries);\n\t},\n\n\tlistContainsEvent (eventList, event) {\n\t\tfor (const evt of eventList) {\n\t\t\tif (evt.title === event.title && parseInt(evt.startDate) === parseInt(event.startDate) && parseInt(evt.endDate) === parseInt(event.endDate)) {\n\t\t\t\treturn true;\n\t\t\t}\n\t\t}\n\t\treturn false;\n\t},\n\n\t/**\n\t * Requests node helper to add calendar url.\n\t * @param {string} url The calendar url to add\n\t * @param {object} auth The authentication method and credentials\n\t * @param {object} calendarConfig The config of the specific calendar\n\t */\n\taddCalendar (url, auth, calendarConfig) {\n\t\tthis.sendSocketNotification(\"ADD_CALENDAR\", {\n\t\t\tid: this.identifier,\n\t\t\turl: url,\n\t\t\texcludedEvents: calendarConfig.excludedEvents || this.config.excludedEvents,\n\t\t\tmaximumEntries: calendarConfig.maximumEntries || this.config.maximumEntries,\n\t\t\tmaximumNumberOfDays: calendarConfig.maximumNumberOfDays || this.config.maximumNumberOfDays,\n\t\t\tpastDaysCount: calendarConfig.pastDaysCount || this.config.pastDaysCount,\n\t\t\tfetchInterval: calendarConfig.fetchInterval || this.config.fetchInterval,\n\t\t\tsymbolClass: calendarConfig.symbolClass,\n\t\t\ttitleClass: calendarConfig.titleClass,\n\t\t\ttimeClass: calendarConfig.timeClass,\n\t\t\tauth: auth,\n\t\t\tbroadcastPastEvents: calendarConfig.broadcastPastEvents || this.config.broadcastPastEvents,\n\t\t\tselfSignedCert: calendarConfig.selfSignedCert || this.config.selfSignedCert\n\t\t});\n\t},\n\n\t/**\n\t * Retrieves the symbols for a specific event.\n\t * @param {object} event Event to look for.\n\t * @returns {string[]} The symbols\n\t */\n\tsymbolsForEvent (event) {\n\t\tlet symbols = this.getCalendarPropertyAsArray(event.url, \"symbol\", this.config.defaultSymbol);\n\n\t\tif (event.recurringEvent === true && this.hasCalendarProperty(event.url, \"recurringSymbol\")) {\n\t\t\tsymbols = this.mergeUnique(this.getCalendarPropertyAsArray(event.url, \"recurringSymbol\", this.config.defaultSymbol), symbols);\n\t\t}\n\n\t\tif (event.fullDayEvent === true && this.hasCalendarProperty(event.url, \"fullDaySymbol\")) {\n\t\t\tsymbols = this.mergeUnique(this.getCalendarPropertyAsArray(event.url, \"fullDaySymbol\", this.config.defaultSymbol), symbols);\n\t\t}\n\n\t\t// If custom symbol is set, replace event symbol\n\t\tfor (let ev of this.config.customEvents) {\n\t\t\tif (typeof ev.symbol !== \"undefined\" && ev.symbol !== \"\") {\n\t\t\t\tlet needle = new RegExp(ev.keyword, \"gi\");\n\t\t\t\tif (needle.test(event.title)) {\n\t\t\t\t\t// Get the default prefix for this class name and add to the custom symbol provided\n\t\t\t\t\tconst className = this.getCalendarProperty(event.url, \"symbolClassName\", this.config.defaultSymbolClassName);\n\t\t\t\t\tsymbols[0] = className + ev.symbol;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn symbols;\n\t},\n\n\tmergeUnique (arr1, arr2) {\n\t\treturn arr1.concat(\n\t\t\tarr2.filter(function (item) {\n\t\t\t\treturn arr1.indexOf(item) === -1;\n\t\t\t})\n\t\t);\n\t},\n\n\t/**\n\t * Retrieves the symbolClass for a specific calendar url.\n\t * @param {string} url The calendar url\n\t * @returns {string} The class to be used for the symbols of the calendar\n\t */\n\tsymbolClassForUrl (url) {\n\t\treturn this.getCalendarProperty(url, \"symbolClass\", \"\");\n\t},\n\n\t/**\n\t * Retrieves the titleClass for a specific calendar url.\n\t * @param {string} url The calendar url\n\t * @returns {string} The class to be used for the title of the calendar\n\t */\n\ttitleClassForUrl (url) {\n\t\treturn this.getCalendarProperty(url, \"titleClass\", \"\");\n\t},\n\n\t/**\n\t * Retrieves the timeClass for a specific calendar url.\n\t * @param {string} url The calendar url\n\t * @returns {string} The class to be used for the time of the calendar\n\t */\n\ttimeClassForUrl (url) {\n\t\treturn this.getCalendarProperty(url, \"timeClass\", \"\");\n\t},\n\n\t/**\n\t * Retrieves the calendar name for a specific calendar url.\n\t * @param {string} url The calendar url\n\t * @returns {string} The name of the calendar\n\t */\n\tcalendarNameForUrl (url) {\n\t\treturn this.getCalendarProperty(url, \"name\", \"\");\n\t},\n\n\t/**\n\t * Retrieves the color for a specific calendar url.\n\t * @param {string} url The calendar url\n\t * @param {boolean} isBg Determines if we fetch the bgColor or not\n\t * @returns {string} The color\n\t */\n\tcolorForUrl (url, isBg) {\n\t\treturn this.getCalendarProperty(url, isBg ? \"bgColor\" : \"color\", \"#fff\");\n\t},\n\n\t/**\n\t * Retrieves the count title for a specific calendar url.\n\t * @param {string} url The calendar url\n\t * @returns {string} The title\n\t */\n\tcountTitleForUrl (url) {\n\t\treturn this.getCalendarProperty(url, \"repeatingCountTitle\", this.config.defaultRepeatingCountTitle);\n\t},\n\n\t/**\n\t * Retrieves the maximum entry count for a specific calendar url.\n\t * @param {string} url The calendar url\n\t * @returns {number} The maximum entry count\n\t */\n\tmaximumEntriesForUrl (url) {\n\t\treturn this.getCalendarProperty(url, \"maximumEntries\", this.config.maximumEntries);\n\t},\n\n\t/**\n\t * Retrieves the maximum count of past days which events of should be displayed for a specific calendar url.\n\t * @param {string} url The calendar url\n\t * @returns {number} The maximum past days count\n\t */\n\tmaximumPastDaysForUrl (url) {\n\t\treturn this.getCalendarProperty(url, \"pastDaysCount\", this.config.pastDaysCount);\n\t},\n\n\t/**\n\t * Helper method to retrieve the property for a specific calendar url.\n\t * @param {string} url The calendar url\n\t * @param {string} property The property to look for\n\t * @param {string} defaultValue The value if the property is not found\n\t * @returns {string} The property\n\t */\n\tgetCalendarProperty (url, property, defaultValue) {\n\t\tfor (const calendar of this.config.calendars) {\n\t\t\tif (calendar.url === url && calendar.hasOwnProperty(property)) {\n\t\t\t\treturn calendar[property];\n\t\t\t}\n\t\t}\n\n\t\treturn defaultValue;\n\t},\n\n\tgetCalendarPropertyAsArray (url, property, defaultValue) {\n\t\tlet p = this.getCalendarProperty(url, property, defaultValue);\n\t\tif (property === \"symbol\" || property === \"recurringSymbol\" || property === \"fullDaySymbol\") {\n\t\t\tconst className = this.getCalendarProperty(url, \"symbolClassName\", this.config.defaultSymbolClassName);\n\t\t\tif (p instanceof Array) {\n\t\t\t\tlet t = [];\n\t\t\t\tp.forEach((n) => { t.push(className + n); });\n\t\t\t\tp = t;\n\t\t\t}\n\t\t\telse p = className + p;\n\t\t}\n\t\tif (!(p instanceof Array)) p = [p];\n\t\treturn p;\n\t},\n\n\thasCalendarProperty (url, property) {\n\t\treturn !!this.getCalendarProperty(url, property, undefined);\n\t},\n\n\t/**\n\t * Broadcasts the events to all other modules for reuse.\n\t * The all events available in one array, sorted on startDate.\n\t */\n\tbroadcastEvents () {\n\t\tconst eventList = this.createEventList(false);\n\t\tfor (const event of eventList) {\n\t\t\tevent.symbol = this.symbolsForEvent(event);\n\t\t\tevent.calendarName = this.calendarNameForUrl(event.url);\n\t\t\tevent.color = this.colorForUrl(event.url, false);\n\t\t\tdelete event.url;\n\t\t}\n\n\t\tthis.sendNotification(\"CALENDAR_EVENTS\", eventList);\n\t},\n\n\t/**\n\t * Refresh the DOM every minute if needed: When using relative date format for events that start\n\t * or end in less than an hour, the date shows minute granularity and we want to keep that accurate.\n\t * --\n\t * When updateOnFetch is not set, it will Avoid fade out/in on updateDom when many calendars are used\n\t * and it's allow to refresh The DOM every minute with animation speed too\n\t * (because updateDom is not set in CALENDAR_EVENTS for this case)\n\t */\n\tselfUpdate () {\n\t\tconst ONE_MINUTE = 60 * 1000;\n\t\tsetTimeout(\n\t\t\t() => {\n\t\t\t\tsetInterval(() => {\n\t\t\t\t\tLog.debug(\"[calendar] self update\");\n\t\t\t\t\tif (this.config.updateOnFetch) {\n\t\t\t\t\t\tthis.updateDom(1);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis.updateDom(this.config.animationSpeed);\n\t\t\t\t\t}\n\t\t\t\t}, ONE_MINUTE);\n\t\t\t},\n\t\t\tONE_MINUTE - (new Date() % ONE_MINUTE)\n\t\t);\n\t}\n});\n"
  },
  {
    "path": "modules/default/calendar/calendarfetcher.js",
    "content": "const https = require(\"node:https\");\nconst ical = require(\"node-ical\");\nconst Log = require(\"logger\");\nconst CalendarFetcherUtils = require(\"./calendarfetcherutils\");\nconst { getUserAgent } = require(\"#server_functions\");\n\nconst FIFTEEN_MINUTES = 15 * 60 * 1000;\nconst THIRTY_MINUTES = 30 * 60 * 1000;\nconst MAX_SERVER_BACKOFF = 3;\n\n/**\n * CalendarFetcher - Fetches and parses iCal calendar data with MagicMirror-focused error handling\n * @class\n */\nclass CalendarFetcher {\n\n\t/**\n\t * Creates a new CalendarFetcher instance\n\t * @param {string} url - The URL of the calendar to fetch\n\t * @param {number} reloadInterval - Time in ms between fetches\n\t * @param {string[]} excludedEvents - Event titles to exclude\n\t * @param {number} maximumEntries - Maximum number of events to return\n\t * @param {number} maximumNumberOfDays - Maximum days in the future to fetch\n\t * @param {object} auth - Authentication options {method: 'basic'|'bearer', user, pass}\n\t * @param {boolean} includePastEvents - Whether to include past events\n\t * @param {boolean} selfSignedCert - Whether to accept self-signed certificates\n\t */\n\tconstructor (url, reloadInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, includePastEvents, selfSignedCert) {\n\t\tthis.url = url;\n\t\tthis.reloadInterval = reloadInterval;\n\t\tthis.excludedEvents = excludedEvents;\n\t\tthis.maximumEntries = maximumEntries;\n\t\tthis.maximumNumberOfDays = maximumNumberOfDays;\n\t\tthis.auth = auth;\n\t\tthis.includePastEvents = includePastEvents;\n\t\tthis.selfSignedCert = selfSignedCert;\n\n\t\tthis.events = [];\n\t\tthis.reloadTimer = null;\n\t\tthis.serverErrorCount = 0;\n\t\tthis.lastFetch = null;\n\t\tthis.fetchFailedCallback = () => {};\n\t\tthis.eventsReceivedCallback = () => {};\n\t}\n\n\t/**\n\t * Clears any pending reload timer\n\t */\n\tclearReloadTimer () {\n\t\tif (this.reloadTimer) {\n\t\t\tclearTimeout(this.reloadTimer);\n\t\t\tthis.reloadTimer = null;\n\t\t}\n\t}\n\n\t/**\n\t * Schedules the next fetch respecting MagicMirror test mode\n\t * @param {number} delay - Delay in milliseconds\n\t */\n\tscheduleNextFetch (delay) {\n\t\tconst nextDelay = Math.max(delay || this.reloadInterval, this.reloadInterval);\n\t\tif (process.env.mmTestMode === \"true\") {\n\t\t\treturn;\n\t\t}\n\t\tthis.reloadTimer = setTimeout(() => this.fetchCalendar(), nextDelay);\n\t}\n\n\t/**\n\t * Builds the options object for fetch\n\t * @returns {object} Options object containing headers (and agent if needed)\n\t */\n\tgetRequestOptions () {\n\t\tconst headers = { \"User-Agent\": getUserAgent() };\n\t\tconst options = { headers };\n\n\t\tif (this.selfSignedCert) {\n\t\t\toptions.agent = new https.Agent({ rejectUnauthorized: false });\n\t\t}\n\n\t\tif (this.auth) {\n\t\t\tif (this.auth.method === \"bearer\") {\n\t\t\t\theaders.Authorization = `Bearer ${this.auth.pass}`;\n\t\t\t} else {\n\t\t\t\theaders.Authorization = `Basic ${Buffer.from(`${this.auth.user}:${this.auth.pass}`).toString(\"base64\")}`;\n\t\t\t}\n\t\t}\n\n\t\treturn options;\n\t}\n\n\t/**\n\t * Parses the Retry-After header value\n\t * @param {string} retryAfter - The Retry-After header value\n\t * @returns {number|null} Milliseconds to wait or null if parsing failed\n\t */\n\tparseRetryAfter (retryAfter) {\n\t\tconst seconds = Number(retryAfter);\n\t\tif (!Number.isNaN(seconds) && seconds >= 0) {\n\t\t\treturn seconds * 1000;\n\t\t}\n\n\t\tconst retryDate = Date.parse(retryAfter);\n\t\tif (!Number.isNaN(retryDate)) {\n\t\t\treturn Math.max(0, retryDate - Date.now());\n\t\t}\n\n\t\treturn null;\n\t}\n\n\t/**\n\t * Determines the retry delay for a non-ok response\n\t * @param {Response} response - The fetch Response object\n\t * @returns {{delay: number, error: Error}} Error describing the issue and computed retry delay\n\t */\n\tgetDelayForResponse (response) {\n\t\tconst { status, statusText = \"\" } = response;\n\t\tlet delay = this.reloadInterval;\n\n\t\tif (status === 401 || status === 403) {\n\t\t\tdelay = Math.max(this.reloadInterval * 5, THIRTY_MINUTES);\n\t\t\tLog.error(`${this.url} - Authentication failed (${status}). Waiting ${Math.round(delay / 60000)} minutes before retry.`);\n\t\t} else if (status === 429) {\n\t\t\tconst retryAfter = response.headers.get(\"retry-after\");\n\t\t\tconst parsed = retryAfter ? this.parseRetryAfter(retryAfter) : null;\n\t\t\tdelay = parsed !== null ? Math.max(parsed, this.reloadInterval) : Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES);\n\t\t\tLog.warn(`${this.url} - Rate limited (429). Retrying in ${Math.round(delay / 60000)} minutes.`);\n\t\t} else if (status >= 500) {\n\t\t\tthis.serverErrorCount = Math.min(this.serverErrorCount + 1, MAX_SERVER_BACKOFF);\n\t\t\tdelay = this.reloadInterval * Math.pow(2, this.serverErrorCount);\n\t\t\tLog.error(`${this.url} - Server error (${status}). Retry #${this.serverErrorCount} in ${Math.round(delay / 60000)} minutes.`);\n\t\t} else if (status >= 400) {\n\t\t\tdelay = Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES);\n\t\t\tLog.error(`${this.url} - Client error (${status}). Retrying in ${Math.round(delay / 60000)} minutes.`);\n\t\t} else {\n\t\t\tLog.error(`${this.url} - Unexpected HTTP status ${status}.`);\n\t\t}\n\n\t\treturn {\n\t\t\tdelay,\n\t\t\terror: new Error(`HTTP ${status} ${statusText}`.trim())\n\t\t};\n\t}\n\n\t/**\n\t * Fetches and processes calendar data\n\t */\n\tasync fetchCalendar () {\n\t\tthis.clearReloadTimer();\n\n\t\tlet nextDelay = this.reloadInterval;\n\t\ttry {\n\t\t\tconst response = await fetch(this.url, this.getRequestOptions());\n\t\t\tif (!response.ok) {\n\t\t\t\tconst { delay, error } = this.getDelayForResponse(response);\n\t\t\t\tnextDelay = delay;\n\t\t\t\tthis.fetchFailedCallback(this, error);\n\t\t\t} else {\n\t\t\t\tthis.serverErrorCount = 0;\n\t\t\t\tconst responseData = await response.text();\n\t\t\t\ttry {\n\t\t\t\t\tconst parsed = ical.parseICS(responseData);\n\t\t\t\t\tLog.debug(`Parsed iCal data from ${this.url} with ${Object.keys(parsed).length} entries.`);\n\t\t\t\t\tthis.events = CalendarFetcherUtils.filterEvents(parsed, {\n\t\t\t\t\t\texcludedEvents: this.excludedEvents,\n\t\t\t\t\t\tincludePastEvents: this.includePastEvents,\n\t\t\t\t\t\tmaximumEntries: this.maximumEntries,\n\t\t\t\t\t\tmaximumNumberOfDays: this.maximumNumberOfDays\n\t\t\t\t\t});\n\t\t\t\t\tthis.lastFetch = Date.now();\n\t\t\t\t\tthis.broadcastEvents();\n\t\t\t\t} catch (error) {\n\t\t\t\t\tLog.error(`${this.url} - iCal parsing failed: ${error.message}`);\n\t\t\t\t\tthis.fetchFailedCallback(this, error);\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tLog.error(`${this.url} - Fetch failed: ${error.message}`);\n\t\t\tthis.fetchFailedCallback(this, error);\n\t\t}\n\n\t\tthis.scheduleNextFetch(nextDelay);\n\t}\n\n\t/**\n\t * Check if enough time has passed since the last fetch to warrant a new one.\n\t * Uses reloadInterval as the threshold to respect user's configured fetchInterval.\n\t * @returns {boolean} True if a new fetch should be performed\n\t */\n\tshouldRefetch () {\n\t\tif (!this.lastFetch) {\n\t\t\treturn true;\n\t\t}\n\t\tconst timeSinceLastFetch = Date.now() - this.lastFetch;\n\t\treturn timeSinceLastFetch >= this.reloadInterval;\n\t}\n\n\t/**\n\t * Broadcasts the current events to listeners\n\t */\n\tbroadcastEvents () {\n\t\tLog.info(`Broadcasting ${this.events.length} events from ${this.url}.`);\n\t\tthis.eventsReceivedCallback(this);\n\t}\n\n\t/**\n\t * Sets the callback for successful event fetches\n\t * @param {(fetcher: CalendarFetcher) => void} callback - Called when events are received\n\t */\n\tonReceive (callback) {\n\t\tthis.eventsReceivedCallback = callback;\n\t}\n\n\t/**\n\t * Sets the callback for fetch failures\n\t * @param {(fetcher: CalendarFetcher, error: Error) => void} callback - Called when a fetch fails\n\t */\n\tonError (callback) {\n\t\tthis.fetchFailedCallback = callback;\n\t}\n}\n\nmodule.exports = CalendarFetcher;\n"
  },
  {
    "path": "modules/default/calendar/calendarfetcherutils.js",
    "content": "/**\n * @external Moment\n */\nconst moment = require(\"moment-timezone\");\n\nconst Log = require(\"logger\");\n\nconst CalendarFetcherUtils = {\n\n\t/**\n\t * Determine based on the title of an event if it should be excluded from the list of events\n\t * @param {object} config the global config\n\t * @param {string} title the title of the event\n\t * @returns {object} excluded: true if the event should be excluded, false otherwise\n\t * until: the date until the event should be excluded.\n\t */\n\tshouldEventBeExcluded (config, title) {\n\t\tfor (const filterConfig of config.excludedEvents) {\n\t\t\tconst match = CalendarFetcherUtils.checkEventAgainstFilter(title, filterConfig);\n\t\t\tif (match) {\n\t\t\t\treturn {\n\t\t\t\t\texcluded: !match.until,\n\t\t\t\t\tuntil: match.until\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\treturn {\n\t\t\texcluded: false,\n\t\t\tuntil: null\n\t\t};\n\t},\n\n\t/**\n\t * Get local timezone.\n\t * This method makes it easier to test if different timezones cause problems by changing this implementation.\n\t * @returns {string} timezone\n\t */\n\tgetLocalTimezone () {\n\t\treturn moment.tz.guess();\n\t},\n\n\t/**\n\t * This function returns a list of moments for a recurring event.\n\t * @param {object} event the current event which is a recurring event\n\t * @param {moment.Moment} pastLocalMoment The past date to search for recurring events\n\t * @param {moment.Moment} futureLocalMoment The future date to search for recurring events\n\t * @param {number} durationInMs the duration of the event, this is used to take into account currently running events\n\t * @returns {moment.Moment[]} All moments for the recurring event\n\t */\n\tgetMomentsFromRecurringEvent (event, pastLocalMoment, futureLocalMoment, durationInMs) {\n\t\tconst rule = event.rrule;\n\t\tconst isFullDayEvent = CalendarFetcherUtils.isFullDayEvent(event);\n\t\tconst eventTimezone = event.start.tz || CalendarFetcherUtils.getLocalTimezone();\n\n\t\t// rrule.js interprets years < 1900 as offsets from 1900, causing issues with some birthday calendars\n\t\tif (rule.origOptions?.dtstart?.getFullYear() < 1900) {\n\t\t\trule.origOptions.dtstart.setFullYear(1900);\n\t\t}\n\t\tif (rule.options?.dtstart?.getFullYear() < 1900) {\n\t\t\trule.options.dtstart.setFullYear(1900);\n\t\t}\n\n\t\t// Expand search window to include ongoing events\n\t\tconst oneDayInMs = 24 * 60 * 60 * 1000;\n\t\tconst searchFromDate = pastLocalMoment.clone().subtract(Math.max(durationInMs, oneDayInMs), \"milliseconds\").toDate();\n\t\tconst searchToDate = futureLocalMoment.clone().add(1, \"days\").toDate();\n\n\t\t// For all-day events, extend \"until\" to end of day to include the final occurrence\n\t\tif (isFullDayEvent && rule.options?.until) {\n\t\t\trule.options.until = moment(rule.options.until).endOf(\"day\").toDate();\n\t\t}\n\n\t\t// Clear tzid to prevent rrule.js from double-adjusting times\n\t\tif (rule.options) {\n\t\t\trule.options.tzid = null;\n\t\t}\n\n\t\tconst dates = rule.between(searchFromDate, searchToDate, true) || [];\n\n\t\t// Convert dates to moments in the appropriate timezone\n\t\t// rrule.js returns UTC dates with tzid cleared, so we interpret them in the event's original timezone\n\t\treturn dates.map((date) => {\n\t\t\tif (isFullDayEvent) {\n\t\t\t\t// For all-day events, anchor to calendar day in event's timezone\n\t\t\t\treturn moment.tz(date, eventTimezone).startOf(\"day\");\n\t\t\t}\n\t\t\t// For timed events, preserve the time in the event's original timezone\n\t\t\treturn moment.tz(date, \"UTC\").tz(eventTimezone, true);\n\t\t});\n\t},\n\n\t/**\n\t * Filter the events from ical according to the given config\n\t * @param {object} data the calendar data from ical\n\t * @param {object} config The configuration object\n\t * @returns {string[]} the filtered events\n\t */\n\tfilterEvents (data, config) {\n\t\tconst newEvents = [];\n\n\t\tconst eventDate = function (event, time) {\n\t\t\tconst startMoment = event[time].tz ? moment.tz(event[time], event[time].tz) : moment.tz(event[time], CalendarFetcherUtils.getLocalTimezone());\n\t\t\treturn CalendarFetcherUtils.isFullDayEvent(event) ? startMoment.startOf(\"day\") : startMoment;\n\t\t};\n\n\t\tLog.debug(`There are ${Object.entries(data).length} calendar entries.`);\n\n\t\tconst now = moment();\n\t\tconst pastLocalMoment = config.includePastEvents ? now.clone().startOf(\"day\").subtract(config.maximumNumberOfDays, \"days\") : now;\n\t\tconst futureLocalMoment\n\t\t\t= now\n\t\t\t\t.clone()\n\t\t\t\t.startOf(\"day\")\n\t\t\t\t.add(config.maximumNumberOfDays, \"days\")\n\t\t\t\t// Subtract 1 second so that events that start on the middle of the night will not repeat.\n\t\t\t\t.subtract(1, \"seconds\");\n\n\t\tObject.entries(data).forEach(([key, event]) => {\n\t\t\tLog.debug(\"Processing entry...\");\n\n\t\t\tconst title = CalendarFetcherUtils.getTitleFromEvent(event);\n\t\t\tLog.debug(`title: ${title}`);\n\n\t\t\t// Return quickly if event should be excluded.\n\t\t\tlet { excluded, eventFilterUntil } = this.shouldEventBeExcluded(config, title);\n\t\t\tif (excluded) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// FIXME: Ugly fix to solve the facebook birthday issue.\n\t\t\t// Otherwise, the recurring events only show the birthday for next year.\n\t\t\tlet isFacebookBirthday = false;\n\t\t\tif (typeof event.uid !== \"undefined\") {\n\t\t\t\tif (event.uid.indexOf(\"@facebook.com\") !== -1) {\n\t\t\t\t\tisFacebookBirthday = true;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (event.type === \"VEVENT\") {\n\t\t\t\tLog.debug(`Event:\\n${JSON.stringify(event, null, 2)}`);\n\t\t\t\tlet eventStartMoment = eventDate(event, \"start\");\n\t\t\t\tlet eventEndMoment;\n\n\t\t\t\tif (typeof event.end !== \"undefined\") {\n\t\t\t\t\teventEndMoment = eventDate(event, \"end\");\n\t\t\t\t} else if (typeof event.duration !== \"undefined\") {\n\t\t\t\t\teventEndMoment = eventStartMoment.clone().add(moment.duration(event.duration));\n\t\t\t\t} else {\n\t\t\t\t\tif (!isFacebookBirthday) {\n\t\t\t\t\t\t// make copy of start date, separate storage area\n\t\t\t\t\t\teventEndMoment = eventStartMoment.clone();\n\t\t\t\t\t} else {\n\t\t\t\t\t\teventEndMoment = eventStartMoment.clone().add(1, \"days\");\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tLog.debug(`start: ${eventStartMoment.toDate()}`);\n\t\t\t\tLog.debug(`end:   ${eventEndMoment.toDate()}`);\n\n\t\t\t\t// Calculate the duration of the event for use with recurring events.\n\t\t\t\tconst durationMs = eventEndMoment.valueOf() - eventStartMoment.valueOf();\n\t\t\t\tLog.debug(`duration: ${durationMs}`);\n\n\t\t\t\tconst location = event.location || false;\n\t\t\t\tconst geo = event.geo || false;\n\t\t\t\tconst description = event.description || false;\n\n\t\t\t\tlet instances = [];\n\t\t\t\tif (event.rrule && typeof event.rrule !== \"undefined\" && !isFacebookBirthday) {\n\t\t\t\t\tinstances = CalendarFetcherUtils.expandRecurringEvent(event, pastLocalMoment, futureLocalMoment, durationMs);\n\t\t\t\t} else {\n\t\t\t\t\tconst fullDayEvent = isFacebookBirthday ? true : CalendarFetcherUtils.isFullDayEvent(event);\n\t\t\t\t\tlet end = eventEndMoment;\n\t\t\t\t\tif (fullDayEvent && eventStartMoment.valueOf() === end.valueOf()) {\n\t\t\t\t\t\tend = end.endOf(\"day\");\n\t\t\t\t\t}\n\n\t\t\t\t\tinstances.push({\n\t\t\t\t\t\tevent: event,\n\t\t\t\t\t\tstartMoment: eventStartMoment,\n\t\t\t\t\t\tendMoment: end,\n\t\t\t\t\t\tisRecurring: false\n\t\t\t\t\t});\n\t\t\t\t}\n\n\t\t\t\tfor (const instance of instances) {\n\t\t\t\t\tconst { event: instanceEvent, startMoment, endMoment, isRecurring } = instance;\n\n\t\t\t\t\t// Filter logic\n\t\t\t\t\tif (endMoment.isBefore(pastLocalMoment) || startMoment.isAfter(futureLocalMoment)) {\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\n\t\t\t\t\tif (CalendarFetcherUtils.timeFilterApplies(now, endMoment, eventFilterUntil)) {\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\n\t\t\t\t\tconst title = CalendarFetcherUtils.getTitleFromEvent(instanceEvent);\n\t\t\t\t\tconst fullDay = isFacebookBirthday ? true : CalendarFetcherUtils.isFullDayEvent(event);\n\n\t\t\t\t\tLog.debug(`saving event: ${title}`);\n\t\t\t\t\tnewEvents.push({\n\t\t\t\t\t\ttitle: title,\n\t\t\t\t\t\tstartDate: startMoment.format(\"x\"),\n\t\t\t\t\t\tendDate: endMoment.format(\"x\"),\n\t\t\t\t\t\tfullDayEvent: fullDay,\n\t\t\t\t\t\trecurringEvent: isRecurring,\n\t\t\t\t\t\tclass: event.class,\n\t\t\t\t\t\tfirstYear: event.start.getFullYear(),\n\t\t\t\t\t\tlocation: instanceEvent.location || location,\n\t\t\t\t\t\tgeo: instanceEvent.geo || geo,\n\t\t\t\t\t\tdescription: instanceEvent.description || description\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\n\t\tnewEvents.sort(function (a, b) {\n\t\t\treturn a.startDate - b.startDate;\n\t\t});\n\n\t\treturn newEvents;\n\t},\n\n\t/**\n\t * Gets the title from the event.\n\t * @param {object} event The event object to check.\n\t * @returns {string} The title of the event, or \"Event\" if no title is found.\n\t */\n\tgetTitleFromEvent (event) {\n\t\tlet title = \"Event\";\n\t\tif (event.summary) {\n\t\t\ttitle = typeof event.summary.val !== \"undefined\" ? event.summary.val : event.summary;\n\t\t} else if (event.description) {\n\t\t\ttitle = event.description;\n\t\t}\n\n\t\treturn title;\n\t},\n\n\t/**\n\t * Checks if an event is a fullday event.\n\t * @param {object} event The event object to check.\n\t * @returns {boolean} True if the event is a fullday event, false otherwise\n\t */\n\tisFullDayEvent (event) {\n\t\tif (event.start.length === 8 || event.start.dateOnly || event.datetype === \"date\") {\n\t\t\treturn true;\n\t\t}\n\n\t\tconst start = event.start || 0;\n\t\tconst startDate = new Date(start);\n\t\tconst end = event.end || 0;\n\t\tif ((end - start) % (24 * 60 * 60 * 1000) === 0 && startDate.getHours() === 0 && startDate.getMinutes() === 0) {\n\t\t\t// Is 24 hours, and starts on the middle of the night.\n\t\t\treturn true;\n\t\t}\n\n\t\treturn false;\n\t},\n\n\t/**\n\t * Determines if the user defined time filter should apply\n\t * @param {moment.Moment} now Date object using previously created object for consistency\n\t * @param {moment.Moment} endDate Moment object representing the event end date\n\t * @param {string} filter The time to subtract from the end date to determine if an event should be shown\n\t * @returns {boolean} True if the event should be filtered out, false otherwise\n\t */\n\ttimeFilterApplies (now, endDate, filter) {\n\t\tif (filter) {\n\t\t\tconst until = filter.split(\" \"),\n\t\t\t\tvalue = parseInt(until[0]),\n\t\t\t\tincrement = until[1].slice(-1) === \"s\" ? until[1] : `${until[1]}s`, // Massage the data for moment js\n\t\t\t\tfilterUntil = moment(endDate.format()).subtract(value, increment);\n\n\t\t\treturn now < filterUntil;\n\t\t}\n\n\t\treturn false;\n\t},\n\n\t/**\n\t * Determines if the user defined title filter should apply\n\t * @param {string} title the title of the event\n\t * @param {string} filter the string to look for, can be a regex also\n\t * @param {boolean} useRegex true if a regex should be used, otherwise it just looks for the filter as a string\n\t * @param {string} regexFlags flags that should be applied to the regex\n\t * @returns {boolean} True if the title should be filtered out, false otherwise\n\t */\n\ttitleFilterApplies (title, filter, useRegex, regexFlags) {\n\t\tif (useRegex) {\n\t\t\tlet regexFilter = filter;\n\t\t\t// Assume if leading slash, there is also trailing slash\n\t\t\tif (filter[0] === \"/\") {\n\t\t\t\t// Strip leading and trailing slashes\n\t\t\t\tregexFilter = filter.substr(1).slice(0, -1);\n\t\t\t}\n\t\t\treturn new RegExp(regexFilter, regexFlags).test(title);\n\t\t} else {\n\t\t\treturn title.includes(filter);\n\t\t}\n\t},\n\n\t/**\n\t * Expands a recurring event into individual event instances.\n\t * @param {object} event The recurring event object\n\t * @param {moment.Moment} pastLocalMoment The past date limit\n\t * @param {moment.Moment} futureLocalMoment The future date limit\n\t * @param {number} durationMs The duration of the event in milliseconds\n\t * @returns {object[]} Array of event instances\n\t */\n\texpandRecurringEvent (event, pastLocalMoment, futureLocalMoment, durationMs) {\n\t\tconst moments = CalendarFetcherUtils.getMomentsFromRecurringEvent(event, pastLocalMoment, futureLocalMoment, durationMs);\n\t\tconst instances = [];\n\n\t\tfor (const startMoment of moments) {\n\t\t\tlet curEvent = event;\n\t\t\tlet showRecurrence = true;\n\t\t\tlet recurringEventStartMoment = startMoment.clone().tz(CalendarFetcherUtils.getLocalTimezone());\n\t\t\tlet recurringEventEndMoment = recurringEventStartMoment.clone().add(durationMs, \"ms\");\n\n\t\t\tconst dateKey = recurringEventStartMoment.tz(\"UTC\").format(\"YYYY-MM-DD\");\n\n\t\t\t// Check for overrides\n\t\t\tif (curEvent.recurrences !== undefined) {\n\t\t\t\tif (curEvent.recurrences[dateKey] !== undefined) {\n\t\t\t\t\tcurEvent = curEvent.recurrences[dateKey];\n\t\t\t\t\t// Re-calculate start/end based on override\n\t\t\t\t\tconst start = curEvent.start;\n\t\t\t\t\tconst end = curEvent.end;\n\t\t\t\t\tconst localTimezone = CalendarFetcherUtils.getLocalTimezone();\n\n\t\t\t\t\trecurringEventStartMoment = (start.tz ? moment(start).tz(start.tz) : moment(start)).tz(localTimezone);\n\t\t\t\t\trecurringEventEndMoment = (end.tz ? moment(end).tz(end.tz) : moment(end)).tz(localTimezone);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Check for exceptions\n\t\t\tif (curEvent.exdate !== undefined) {\n\t\t\t\tif (curEvent.exdate[dateKey] !== undefined) {\n\t\t\t\t\tshowRecurrence = false;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (recurringEventStartMoment.valueOf() === recurringEventEndMoment.valueOf()) {\n\t\t\t\trecurringEventEndMoment = recurringEventEndMoment.endOf(\"day\");\n\t\t\t}\n\n\t\t\tif (showRecurrence) {\n\t\t\t\tinstances.push({\n\t\t\t\t\tevent: curEvent,\n\t\t\t\t\tstartMoment: recurringEventStartMoment,\n\t\t\t\t\tendMoment: recurringEventEndMoment,\n\t\t\t\t\tisRecurring: true\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t\treturn instances;\n\t},\n\n\t/**\n\t * Checks if an event title matches a specific filter configuration.\n\t * @param {string} title The event title to check\n\t * @param {string|object} filterConfig The filter configuration (string or object)\n\t * @returns {object|null} Object with {until: string|null} if matched, null otherwise\n\t */\n\tcheckEventAgainstFilter (title, filterConfig) {\n\t\tlet filter = filterConfig;\n\t\tlet testTitle = title.toLowerCase();\n\t\tlet until = null;\n\t\tlet useRegex = false;\n\t\tlet regexFlags = \"g\";\n\n\t\tif (filter instanceof Object) {\n\t\t\tif (typeof filter.until !== \"undefined\") {\n\t\t\t\tuntil = filter.until;\n\t\t\t}\n\n\t\t\tif (typeof filter.regex !== \"undefined\") {\n\t\t\t\tuseRegex = filter.regex;\n\t\t\t}\n\n\t\t\tif (filter.caseSensitive) {\n\t\t\t\tfilter = filter.filterBy;\n\t\t\t\ttestTitle = title;\n\t\t\t} else if (useRegex) {\n\t\t\t\tfilter = filter.filterBy;\n\t\t\t\ttestTitle = title;\n\t\t\t\tregexFlags += \"i\";\n\t\t\t} else {\n\t\t\t\tfilter = filter.filterBy.toLowerCase();\n\t\t\t}\n\t\t} else {\n\t\t\tfilter = filter.toLowerCase();\n\t\t}\n\n\t\tif (CalendarFetcherUtils.titleFilterApplies(testTitle, filter, useRegex, regexFlags)) {\n\t\t\treturn { until };\n\t\t}\n\n\t\treturn null;\n\t}\n};\n\nif (typeof module !== \"undefined\") {\n\tmodule.exports = CalendarFetcherUtils;\n}\n"
  },
  {
    "path": "modules/default/calendar/calendarutils.js",
    "content": "const CalendarUtils = {\n\n\t/**\n\t * Capitalize the first letter of a string\n\t * @param {string} string The string to capitalize\n\t * @returns {string} The capitalized string\n\t */\n\tcapFirst (string) {\n\t\treturn string.charAt(0).toUpperCase() + string.slice(1);\n\t},\n\n\t/**\n\t * This function accepts a number (either 12 or 24) and returns a moment.js LocaleSpecification with the\n\t * corresponding time-format to be used in the calendar display. If no number is given (or otherwise invalid input)\n\t * it will a localeSpecification object with the system locale time format.\n\t * @param {number} timeFormat Specifies either 12 or 24-hour time format\n\t * @returns {moment.LocaleSpecification} formatted time\n\t */\n\tgetLocaleSpecification (timeFormat) {\n\t\tswitch (timeFormat) {\n\t\t\tcase 12: {\n\t\t\t\treturn { longDateFormat: { LT: \"h:mm A\" } };\n\t\t\t}\n\t\t\tcase 24: {\n\t\t\t\treturn { longDateFormat: { LT: \"HH:mm\" } };\n\t\t\t}\n\t\t\tdefault: {\n\t\t\t\treturn { longDateFormat: { LT: moment.localeData().longDateFormat(\"LT\") } };\n\t\t\t}\n\t\t}\n\t},\n\n\t/**\n\t * Shortens a string if it's longer than maxLength and add an ellipsis to the end\n\t * @param {string} string Text string to shorten\n\t * @param {number} maxLength The max length of the string\n\t * @param {boolean} wrapEvents Wrap the text after the line has reached maxLength\n\t * @param {number} maxTitleLines The max number of vertical lines before cutting event title\n\t * @returns {string} The shortened string\n\t */\n\tshorten (string, maxLength, wrapEvents, maxTitleLines) {\n\t\tif (typeof string !== \"string\") {\n\t\t\treturn \"\";\n\t\t}\n\n\t\tif (wrapEvents === true) {\n\t\t\tconst words = string.split(\" \");\n\t\t\tlet temp = \"\";\n\t\t\tlet currentLine = \"\";\n\t\t\tlet line = 0;\n\n\t\t\tfor (let i = 0; i < words.length; i++) {\n\t\t\t\tconst word = words[i];\n\t\t\t\tif (currentLine.length + word.length < (typeof maxLength === \"number\" ? maxLength : 25) - 1) {\n\t\t\t\t\t// max - 1 to account for a space\n\t\t\t\t\tcurrentLine += `${word} `;\n\t\t\t\t} else {\n\t\t\t\t\tline++;\n\t\t\t\t\tif (line > maxTitleLines - 1) {\n\t\t\t\t\t\tif (i < words.length) {\n\t\t\t\t\t\t\tcurrentLine += \"…\";\n\t\t\t\t\t\t}\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\n\t\t\t\t\tif (currentLine.length > 0) {\n\t\t\t\t\t\ttemp += `${currentLine}<br>${word} `;\n\t\t\t\t\t} else {\n\t\t\t\t\t\ttemp += `${word}<br>`;\n\t\t\t\t\t}\n\t\t\t\t\tcurrentLine = \"\";\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn (temp + currentLine).trim();\n\t\t} else {\n\t\t\tif (maxLength && typeof maxLength === \"number\" && string.length > maxLength) {\n\t\t\t\treturn `${string.trim().slice(0, maxLength)}…`;\n\t\t\t} else {\n\t\t\t\treturn string.trim();\n\t\t\t}\n\t\t}\n\t},\n\n\t/**\n\t * Transforms the title of an event for usage.\n\t * Replaces parts of the text as defined in config.titleReplace.\n\t * @param {string} title The title to transform.\n\t * @param {object} titleReplace object definition of parts to be replaced in the title\n\t *                 object definition:\n\t *                    search: {string,required} RegEx in format //x or simple string to be searched. For (birthday) year calculation, the element matching the year must be in a RegEx group\n\t *                    replace: {string,required} Replacement string, may contain match group references (latter is required for year calculation)\n\t *                    yearmatchgroup: {number,optional} match group for year element\n\t * @returns {string} The transformed title.\n\t */\n\ttitleTransform (title, titleReplace) {\n\t\tlet transformedTitle = title;\n\t\tfor (let tr in titleReplace) {\n\t\t\tlet transform = titleReplace[tr];\n\t\t\tif (typeof transform === \"object\") {\n\t\t\t\tif (typeof transform.search !== \"undefined\" && transform.search !== \"\" && typeof transform.replace !== \"undefined\") {\n\t\t\t\t\tlet regParts = transform.search.match(/^\\/(.+)\\/([gim]*)$/);\n\t\t\t\t\tlet needle = new RegExp(transform.search, \"g\");\n\t\t\t\t\tif (regParts) {\n\t\t\t\t\t\t// the parsed pattern is a regexp with flags.\n\t\t\t\t\t\tneedle = new RegExp(regParts[1], regParts[2]);\n\t\t\t\t\t}\n\n\t\t\t\t\tlet replacement = transform.replace;\n\t\t\t\t\tif (typeof transform.yearmatchgroup !== \"undefined\" && transform.yearmatchgroup !== \"\") {\n\t\t\t\t\t\tconst yearmatch = [...title.matchAll(needle)];\n\t\t\t\t\t\tif (yearmatch[0].length >= transform.yearmatchgroup + 1 && yearmatch[0][transform.yearmatchgroup] * 1 >= 1900) {\n\t\t\t\t\t\t\tlet calcage = new Date().getFullYear() - yearmatch[0][transform.yearmatchgroup] * 1;\n\t\t\t\t\t\t\tlet searchstr = `$${transform.yearmatchgroup}`;\n\t\t\t\t\t\t\treplacement = replacement.replace(searchstr, calcage);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\ttransformedTitle = transformedTitle.replace(needle, replacement);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn transformedTitle;\n\t}\n};\n\nif (typeof module !== \"undefined\") {\n\tmodule.exports = CalendarUtils;\n}\n"
  },
  {
    "path": "modules/default/calendar/debug.js",
    "content": "/*\n * CalendarFetcher Tester\n * use this script with `node debug.js` to test the fetcher without the need\n * of starting the MagicMirror² core. Adjust the values below to your desire.\n */\n// Load internal alias resolver\nrequire(\"../../../js/alias-resolver\");\nconst Log = require(\"logger\");\n\nconst CalendarFetcher = require(\"./calendarfetcher\");\n\nconst url = \"https://calendar.google.com/calendar/ical/pkm1t2uedjbp0uvq1o7oj1jouo%40group.calendar.google.com/private-08ba559f89eec70dd74bbd887d0a3598/basic.ics\"; // Standard test URL\n//const url = \"https://www.googleapis.com/calendar/v3/calendars/primary/events/\"; // URL for Bearer auth (must be configured  in Google OAuth2 first)\nconst fetchInterval = 60 * 60 * 1000;\nconst maximumEntries = 10;\nconst maximumNumberOfDays = 365;\nconst user = \"magicmirror\";\nconst pass = \"MyStrongPass\";\nconst auth = {\n\tuser: user,\n\tpass: pass\n};\n\nLog.log(\"Create fetcher ...\");\n\nconst fetcher = new CalendarFetcher(url, fetchInterval, [], maximumEntries, maximumNumberOfDays, auth);\n\nfetcher.onReceive(function (fetcher) {\n\tLog.log(fetcher.events);\n\tprocess.exit(0);\n});\n\nfetcher.onError(function (fetcher, error) {\n\tLog.log(\"Fetcher error:\", error);\n\tprocess.exit(1);\n});\n\nfetcher.startFetch();\n\nLog.log(\"Create fetcher done! \");\n"
  },
  {
    "path": "modules/default/calendar/node_helper.js",
    "content": "const zlib = require(\"node:zlib\");\nconst NodeHelper = require(\"node_helper\");\nconst Log = require(\"logger\");\nconst CalendarFetcher = require(\"./calendarfetcher\");\n\nmodule.exports = NodeHelper.create({\n\t// Override start method.\n\tstart () {\n\t\tLog.log(`Starting node helper for: ${this.name}`);\n\t\tthis.fetchers = [];\n\t},\n\n\t// Override socketNotificationReceived method.\n\tsocketNotificationReceived (notification, payload) {\n\t\tif (notification === \"ADD_CALENDAR\") {\n\t\t\tthis.createFetcher(payload.url, payload.fetchInterval, payload.excludedEvents, payload.maximumEntries, payload.maximumNumberOfDays, payload.auth, payload.broadcastPastEvents, payload.selfSignedCert, payload.id);\n\t\t} else if (notification === \"FETCH_CALENDAR\") {\n\t\t\tconst key = payload.id + payload.url;\n\t\t\tif (typeof this.fetchers[key] === \"undefined\") {\n\t\t\t\tLog.error(\"No fetcher exists with key: \", key);\n\t\t\t\tthis.sendSocketNotification(\"CALENDAR_ERROR\", { error_type: \"MODULE_ERROR_UNSPECIFIED\" });\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.fetchers[key].fetchCalendar();\n\t\t}\n\t},\n\n\t/**\n\t * Creates a fetcher for a new url if it doesn't exist yet.\n\t * Otherwise it reuses the existing one.\n\t * @param {string} url The url of the calendar\n\t * @param {number} fetchInterval How often does the calendar needs to be fetched in ms\n\t * @param {string[]} excludedEvents An array of words / phrases from event titles that will be excluded from being shown.\n\t * @param {number} maximumEntries The maximum number of events fetched.\n\t * @param {number} maximumNumberOfDays The maximum number of days an event should be in the future.\n\t * @param {object} auth The object containing options for authentication against the calendar.\n\t * @param {boolean} broadcastPastEvents If true events from the past maximumNumberOfDays will be included in event broadcasts\n\t * @param {boolean} selfSignedCert If true, the server certificate is not verified against the list of supplied CAs.\n\t * @param {string} identifier ID of the module\n\t */\n\tcreateFetcher (url, fetchInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, broadcastPastEvents, selfSignedCert, identifier) {\n\t\ttry {\n\t\t\tnew URL(url);\n\t\t} catch (error) {\n\t\t\tLog.error(\"Malformed calendar url: \", url, error);\n\t\t\tthis.sendSocketNotification(\"CALENDAR_ERROR\", { error_type: \"MODULE_ERROR_MALFORMED_URL\" });\n\t\t\treturn;\n\t\t}\n\n\t\tlet fetcher;\n\t\tlet fetchIntervalCorrected;\n\t\tif (typeof this.fetchers[identifier + url] === \"undefined\") {\n\t\t\tif (fetchInterval < 60000) {\n\t\t\t\tLog.warn(`fetchInterval for url ${url} must be >= 60000`);\n\t\t\t\tfetchIntervalCorrected = 60000;\n\t\t\t}\n\t\t\tLog.log(`Create new calendarfetcher for url: ${url} - Interval: ${fetchIntervalCorrected || fetchInterval}`);\n\t\t\tfetcher = new CalendarFetcher(url, fetchIntervalCorrected || fetchInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, broadcastPastEvents, selfSignedCert);\n\n\t\t\tfetcher.onReceive((fetcher) => {\n\t\t\t\tthis.broadcastEvents(fetcher, identifier);\n\t\t\t});\n\n\t\t\tfetcher.onError((fetcher, error) => {\n\t\t\t\tLog.error(\"Calendar Error. Could not fetch calendar: \", fetcher.url, error);\n\t\t\t\tlet error_type = NodeHelper.checkFetchError(error);\n\t\t\t\tthis.sendSocketNotification(\"CALENDAR_ERROR\", {\n\t\t\t\t\tid: identifier,\n\t\t\t\t\terror_type\n\t\t\t\t});\n\t\t\t});\n\n\t\t\tthis.fetchers[identifier + url] = fetcher;\n\t\t\tfetcher.fetchCalendar();\n\t\t} else {\n\t\t\tLog.log(`Use existing calendarfetcher for url: ${url}`);\n\t\t\tfetcher = this.fetchers[identifier + url];\n\t\t\t// Check if calendar data is stale and needs refresh\n\t\t\tif (fetcher.shouldRefetch()) {\n\t\t\t\tLog.log(`Calendar data is stale, fetching fresh data for url: ${url}`);\n\t\t\t\tfetcher.fetchCalendar();\n\t\t\t} else {\n\t\t\t\tfetcher.broadcastEvents();\n\t\t\t}\n\t\t}\n\t},\n\n\t/**\n\t *\n\t * @param {object} fetcher the fetcher associated with the calendar\n\t * @param {string} identifier the identifier of the calendar\n\t */\n\tbroadcastEvents (fetcher, identifier) {\n\t\tconst checksum = zlib.crc32(Buffer.from(JSON.stringify(fetcher.events), \"utf8\"));\n\t\tthis.sendSocketNotification(\"CALENDAR_EVENTS\", {\n\t\t\tid: identifier,\n\t\t\turl: fetcher.url,\n\t\t\tevents: fetcher.events,\n\t\t\tchecksum: checksum\n\t\t});\n\t}\n});\n"
  },
  {
    "path": "modules/default/calendar/windowsZones.json",
    "content": "{\n\t\"Dateline Standard Time\": { \"iana\": [\"Etc/GMT+12\"] },\n\t\"UTC-11\": { \"iana\": [\"Etc/GMT+11\"] },\n\t\"Aleutian Standard Time\": { \"iana\": [\"America/Adak\"] },\n\t\"Hawaiian Standard Time\": { \"iana\": [\"Pacific/Honolulu\"] },\n\t\"Marquesas Standard Time\": { \"iana\": [\"Pacific/Marquesas\"] },\n\t\"Alaskan Standard Time\": { \"iana\": [\"America/Anchorage\"] },\n\t\"UTC-09\": { \"iana\": [\"Etc/GMT+9\"] },\n\t\"Pacific Standard Time (Mexico)\": { \"iana\": [\"America/Tijuana\"] },\n\t\"UTC-08\": { \"iana\": [\"Etc/GMT+8\"] },\n\t\"Pacific Standard Time\": { \"iana\": [\"America/Los_Angeles\"] },\n\t\"US Mountain Standard Time\": { \"iana\": [\"America/Phoenix\"] },\n\t\"Mountain Standard Time (Mexico)\": { \"iana\": [\"America/Chihuahua\"] },\n\t\"Mountain Standard Time\": { \"iana\": [\"America/Denver\"] },\n\t\"Central America Standard Time\": { \"iana\": [\"America/Guatemala\"] },\n\t\"Central Standard Time\": { \"iana\": [\"America/Chicago\"] },\n\t\"Easter Island Standard Time\": { \"iana\": [\"Pacific/Easter\"] },\n\t\"Central Standard Time (Mexico)\": { \"iana\": [\"America/Mexico_City\"] },\n\t\"Canada Central Standard Time\": { \"iana\": [\"America/Regina\"] },\n\t\"SA Pacific Standard Time\": { \"iana\": [\"America/Bogota\"] },\n\t\"Eastern Standard Time (Mexico)\": { \"iana\": [\"America/Cancun\"] },\n\t\"Eastern Standard Time\": { \"iana\": [\"America/New_York\"] },\n\t\"Haiti Standard Time\": { \"iana\": [\"America/Port-au-Prince\"] },\n\t\"Cuba Standard Time\": { \"iana\": [\"America/Havana\"] },\n\t\"US Eastern Standard Time\": { \"iana\": [\"America/Indianapolis\"] },\n\t\"Turks And Caicos Standard Time\": { \"iana\": [\"America/Grand_Turk\"] },\n\t\"Paraguay Standard Time\": { \"iana\": [\"America/Asuncion\"] },\n\t\"Atlantic Standard Time\": { \"iana\": [\"America/Halifax\"] },\n\t\"Venezuela Standard Time\": { \"iana\": [\"America/Caracas\"] },\n\t\"Central Brazilian Standard Time\": { \"iana\": [\"America/Cuiaba\"] },\n\t\"SA Western Standard Time\": { \"iana\": [\"America/La_Paz\"] },\n\t\"Pacific SA Standard Time\": { \"iana\": [\"America/Santiago\"] },\n\t\"Newfoundland Standard Time\": { \"iana\": [\"America/St_Johns\"] },\n\t\"Tocantins Standard Time\": { \"iana\": [\"America/Araguaina\"] },\n\t\"E. South America Standard Time\": { \"iana\": [\"America/Sao_Paulo\"] },\n\t\"SA Eastern Standard Time\": { \"iana\": [\"America/Cayenne\"] },\n\t\"Argentina Standard Time\": { \"iana\": [\"America/Buenos_Aires\"] },\n\t\"Greenland Standard Time\": { \"iana\": [\"America/Godthab\"] },\n\t\"Montevideo Standard Time\": { \"iana\": [\"America/Montevideo\"] },\n\t\"Magallanes Standard Time\": { \"iana\": [\"America/Punta_Arenas\"] },\n\t\"Saint Pierre Standard Time\": { \"iana\": [\"America/Miquelon\"] },\n\t\"Bahia Standard Time\": { \"iana\": [\"America/Bahia\"] },\n\t\"UTC-02\": { \"iana\": [\"Etc/GMT+2\"] },\n\t\"Azores Standard Time\": { \"iana\": [\"Atlantic/Azores\"] },\n\t\"Cape Verde Standard Time\": { \"iana\": [\"Atlantic/Cape_Verde\"] },\n\t\"UTC\": { \"iana\": [\"Etc/GMT\"] },\n\t\"GMT Standard Time\": { \"iana\": [\"Europe/London\"] },\n\t\"Greenwich Standard Time\": { \"iana\": [\"Atlantic/Reykjavik\"] },\n\t\"Sao Tome Standard Time\": { \"iana\": [\"Africa/Sao_Tome\"] },\n\t\"Morocco Standard Time\": { \"iana\": [\"Africa/Casablanca\"] },\n\t\"W. Europe Standard Time\": { \"iana\": [\"Europe/Berlin\"] },\n\t\"Central Europe Standard Time\": { \"iana\": [\"Europe/Budapest\"] },\n\t\"Romance Standard Time\": { \"iana\": [\"Europe/Paris\"] },\n\t\"Central European Standard Time\": { \"iana\": [\"Europe/Warsaw\"] },\n\t\"W. Central Africa Standard Time\": { \"iana\": [\"Africa/Lagos\"] },\n\t\"Jordan Standard Time\": { \"iana\": [\"Asia/Amman\"] },\n\t\"GTB Standard Time\": { \"iana\": [\"Europe/Bucharest\"] },\n\t\"Middle East Standard Time\": { \"iana\": [\"Asia/Beirut\"] },\n\t\"Egypt Standard Time\": { \"iana\": [\"Africa/Cairo\"] },\n\t\"E. Europe Standard Time\": { \"iana\": [\"Europe/Chisinau\"] },\n\t\"Syria Standard Time\": { \"iana\": [\"Asia/Damascus\"] },\n\t\"West Bank Standard Time\": { \"iana\": [\"Asia/Hebron\"] },\n\t\"South Africa Standard Time\": { \"iana\": [\"Africa/Johannesburg\"] },\n\t\"FLE Standard Time\": { \"iana\": [\"Europe/Kiev\"] },\n\t\"Israel Standard Time\": { \"iana\": [\"Asia/Jerusalem\"] },\n\t\"Kaliningrad Standard Time\": { \"iana\": [\"Europe/Kaliningrad\"] },\n\t\"Sudan Standard Time\": { \"iana\": [\"Africa/Khartoum\"] },\n\t\"Libya Standard Time\": { \"iana\": [\"Africa/Tripoli\"] },\n\t\"Namibia Standard Time\": { \"iana\": [\"Africa/Windhoek\"] },\n\t\"Arabic Standard Time\": { \"iana\": [\"Asia/Baghdad\"] },\n\t\"Turkey Standard Time\": { \"iana\": [\"Europe/Istanbul\"] },\n\t\"Arab Standard Time\": { \"iana\": [\"Asia/Riyadh\"] },\n\t\"Belarus Standard Time\": { \"iana\": [\"Europe/Minsk\"] },\n\t\"Russian Standard Time\": { \"iana\": [\"Europe/Moscow\"] },\n\t\"E. Africa Standard Time\": { \"iana\": [\"Africa/Nairobi\"] },\n\t\"Iran Standard Time\": { \"iana\": [\"Asia/Tehran\"] },\n\t\"Arabian Standard Time\": { \"iana\": [\"Asia/Dubai\"] },\n\t\"Astrakhan Standard Time\": { \"iana\": [\"Europe/Astrakhan\"] },\n\t\"Azerbaijan Standard Time\": { \"iana\": [\"Asia/Baku\"] },\n\t\"Russia Time Zone 3\": { \"iana\": [\"Europe/Samara\"] },\n\t\"Mauritius Standard Time\": { \"iana\": [\"Indian/Mauritius\"] },\n\t\"Saratov Standard Time\": { \"iana\": [\"Europe/Saratov\"] },\n\t\"Georgian Standard Time\": { \"iana\": [\"Asia/Tbilisi\"] },\n\t\"Volgograd Standard Time\": { \"iana\": [\"Europe/Volgograd\"] },\n\t\"Caucasus Standard Time\": { \"iana\": [\"Asia/Yerevan\"] },\n\t\"Afghanistan Standard Time\": { \"iana\": [\"Asia/Kabul\"] },\n\t\"West Asia Standard Time\": { \"iana\": [\"Asia/Tashkent\"] },\n\t\"Ekaterinburg Standard Time\": { \"iana\": [\"Asia/Yekaterinburg\"] },\n\t\"Pakistan Standard Time\": { \"iana\": [\"Asia/Karachi\"] },\n\t\"Qyzylorda Standard Time\": { \"iana\": [\"Asia/Qyzylorda\"] },\n\t\"India Standard Time\": { \"iana\": [\"Asia/Calcutta\"] },\n\t\"Sri Lanka Standard Time\": { \"iana\": [\"Asia/Colombo\"] },\n\t\"Nepal Standard Time\": { \"iana\": [\"Asia/Katmandu\"] },\n\t\"Central Asia Standard Time\": { \"iana\": [\"Asia/Almaty\"] },\n\t\"Bangladesh Standard Time\": { \"iana\": [\"Asia/Dhaka\"] },\n\t\"Omsk Standard Time\": { \"iana\": [\"Asia/Omsk\"] },\n\t\"Myanmar Standard Time\": { \"iana\": [\"Asia/Rangoon\"] },\n\t\"SE Asia Standard Time\": { \"iana\": [\"Asia/Bangkok\"] },\n\t\"Altai Standard Time\": { \"iana\": [\"Asia/Barnaul\"] },\n\t\"W. Mongolia Standard Time\": { \"iana\": [\"Asia/Hovd\"] },\n\t\"North Asia Standard Time\": { \"iana\": [\"Asia/Krasnoyarsk\"] },\n\t\"N. Central Asia Standard Time\": { \"iana\": [\"Asia/Novosibirsk\"] },\n\t\"Tomsk Standard Time\": { \"iana\": [\"Asia/Tomsk\"] },\n\t\"China Standard Time\": { \"iana\": [\"Asia/Shanghai\"] },\n\t\"North Asia East Standard Time\": { \"iana\": [\"Asia/Irkutsk\"] },\n\t\"Singapore Standard Time\": { \"iana\": [\"Asia/Singapore\"] },\n\t\"W. Australia Standard Time\": { \"iana\": [\"Australia/Perth\"] },\n\t\"Taipei Standard Time\": { \"iana\": [\"Asia/Taipei\"] },\n\t\"Ulaanbaatar Standard Time\": { \"iana\": [\"Asia/Ulaanbaatar\"] },\n\t\"Aus Central W. Standard Time\": { \"iana\": [\"Australia/Eucla\"] },\n\t\"Transbaikal Standard Time\": { \"iana\": [\"Asia/Chita\"] },\n\t\"Tokyo Standard Time\": { \"iana\": [\"Asia/Tokyo\"] },\n\t\"North Korea Standard Time\": { \"iana\": [\"Asia/Pyongyang\"] },\n\t\"Korea Standard Time\": { \"iana\": [\"Asia/Seoul\"] },\n\t\"Yakutsk Standard Time\": { \"iana\": [\"Asia/Yakutsk\"] },\n\t\"Cen. Australia Standard Time\": { \"iana\": [\"Australia/Adelaide\"] },\n\t\"AUS Central Standard Time\": { \"iana\": [\"Australia/Darwin\"] },\n\t\"E. Australia Standard Time\": { \"iana\": [\"Australia/Brisbane\"] },\n\t\"AUS Eastern Standard Time\": { \"iana\": [\"Australia/Sydney\"] },\n\t\"West Pacific Standard Time\": { \"iana\": [\"Pacific/Port_Moresby\"] },\n\t\"Tasmania Standard Time\": { \"iana\": [\"Australia/Hobart\"] },\n\t\"Vladivostok Standard Time\": { \"iana\": [\"Asia/Vladivostok\"] },\n\t\"Lord Howe Standard Time\": { \"iana\": [\"Australia/Lord_Howe\"] },\n\t\"Bougainville Standard Time\": { \"iana\": [\"Pacific/Bougainville\"] },\n\t\"Russia Time Zone 10\": { \"iana\": [\"Asia/Srednekolymsk\"] },\n\t\"Magadan Standard Time\": { \"iana\": [\"Asia/Magadan\"] },\n\t\"Norfolk Standard Time\": { \"iana\": [\"Pacific/Norfolk\"] },\n\t\"Sakhalin Standard Time\": { \"iana\": [\"Asia/Sakhalin\"] },\n\t\"Central Pacific Standard Time\": { \"iana\": [\"Pacific/Guadalcanal\"] },\n\t\"Russia Time Zone 11\": { \"iana\": [\"Asia/Kamchatka\"] },\n\t\"New Zealand Standard Time\": { \"iana\": [\"Pacific/Auckland\"] },\n\t\"UTC+12\": { \"iana\": [\"Etc/GMT-12\"] },\n\t\"Fiji Standard Time\": { \"iana\": [\"Pacific/Fiji\"] },\n\t\"Chatham Islands Standard Time\": { \"iana\": [\"Pacific/Chatham\"] },\n\t\"UTC+13\": { \"iana\": [\"Etc/GMT-13\"] },\n\t\"Tonga Standard Time\": { \"iana\": [\"Pacific/Tongatapu\"] },\n\t\"Samoa Standard Time\": { \"iana\": [\"Pacific/Apia\"] },\n\t\"Line Islands Standard Time\": { \"iana\": [\"Pacific/Kiritimati\"] },\n\t\"(UTC-12:00) International Date Line West\": { \"iana\": [\"Etc/GMT+12\"] },\n\t\"(UTC-11:00) Midway Island, Samoa\": { \"iana\": [\"Pacific/Apia\"] },\n\t\"(UTC-10:00) Hawaii\": { \"iana\": [\"Pacific/Honolulu\"] },\n\t\"(UTC-09:00) Alaska\": { \"iana\": [\"America/Anchorage\"] },\n\t\"(UTC-08:00) Pacific Time (US & Canada); Tijuana\": { \"iana\": [\"America/Los_Angeles\"] },\n\t\"(UTC-08:00) Pacific Time (US and Canada); Tijuana\": { \"iana\": [\"America/Los_Angeles\"] },\n\t\"(UTC-07:00) Mountain Time (US & Canada)\": { \"iana\": [\"America/Denver\"] },\n\t\"(UTC-07:00) Mountain Time (US and Canada)\": { \"iana\": [\"America/Denver\"] },\n\t\"(UTC-07:00) Chihuahua, La Paz, Mazatlan\": { \"iana\": [null] },\n\t\"(UTC-07:00) Arizona\": { \"iana\": [\"America/Phoenix\"] },\n\t\"(UTC-06:00) Central Time (US & Canada)\": { \"iana\": [\"America/Chicago\"] },\n\t\"(UTC-06:00) Central Time (US and Canada)\": { \"iana\": [\"America/Chicago\"] },\n\t\"(UTC-06:00) Saskatchewan\": { \"iana\": [\"America/Regina\"] },\n\t\"(UTC-06:00) Guadalajara, Mexico City, Monterrey\": { \"iana\": [null] },\n\t\"(UTC-06:00) Central America\": { \"iana\": [\"America/Guatemala\"] },\n\t\"(UTC-05:00) Eastern Time (US & Canada)\": { \"iana\": [\"America/New_York\"] },\n\t\"(UTC-05:00) Eastern Time (US and Canada)\": { \"iana\": [\"America/New_York\"] },\n\t\"(UTC-05:00) Indiana (East)\": { \"iana\": [\"America/Indianapolis\"] },\n\t\"(UTC-05:00) Bogota, Lima, Quito\": { \"iana\": [\"America/Bogota\"] },\n\t\"(UTC-04:00) Atlantic Time (Canada)\": { \"iana\": [\"America/Halifax\"] },\n\t\"(UTC-04:00) Georgetown, La Paz, San Juan\": { \"iana\": [\"America/La_Paz\"] },\n\t\"(UTC-04:00) Santiago\": { \"iana\": [\"America/Santiago\"] },\n\t\"(UTC-03:30) Newfoundland\": { \"iana\": [null] },\n\t\"(UTC-03:00) Brasilia\": { \"iana\": [\"America/Sao_Paulo\"] },\n\t\"(UTC-03:00) Georgetown\": { \"iana\": [\"America/Cayenne\"] },\n\t\"(UTC-03:00) Greenland\": { \"iana\": [\"America/Godthab\"] },\n\t\"(UTC-02:00) Mid-Atlantic\": { \"iana\": [null] },\n\t\"(UTC-01:00) Azores\": { \"iana\": [\"Atlantic/Azores\"] },\n\t\"(UTC-01:00) Cape Verde Islands\": { \"iana\": [\"Atlantic/Cape_Verde\"] },\n\t\"(UTC) Greenwich Mean Time: Dublin, Edinburgh, Lisbon, London\": { \"iana\": [null] },\n\t\"(UTC) Monrovia, Reykjavik\": { \"iana\": [\"Atlantic/Reykjavik\"] },\n\t\"(UTC+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague\": { \"iana\": [\"Europe/Budapest\"] },\n\t\"(UTC+01:00) Sarajevo, Skopje, Warsaw, Zagreb\": { \"iana\": [\"Europe/Warsaw\"] },\n\t\"(UTC+01:00) Brussels, Copenhagen, Madrid, Paris\": { \"iana\": [\"Europe/Paris\"] },\n\t\"(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna\": { \"iana\": [\"Europe/Berlin\"] },\n\t\"(UTC+01:00) West Central Africa\": { \"iana\": [\"Africa/Lagos\"] },\n\t\"(UTC+02:00) Minsk\": { \"iana\": [\"Europe/Chisinau\"] },\n\t\"(UTC+02:00) Cairo\": { \"iana\": [\"Africa/Cairo\"] },\n\t\"(UTC+02:00) Helsinki, Kiev, Riga, Sofia, Tallinn, Vilnius\": { \"iana\": [\"Europe/Kiev\"] },\n\t\"(UTC+02:00) Athens, Bucharest, Istanbul\": { \"iana\": [\"Europe/Bucharest\"] },\n\t\"(UTC+02:00) Jerusalem\": { \"iana\": [\"Asia/Jerusalem\"] },\n\t\"(UTC+02:00) Harare, Pretoria\": { \"iana\": [\"Africa/Johannesburg\"] },\n\t\"(UTC+03:00) Moscow, St. Petersburg, Volgograd\": { \"iana\": [\"Europe/Moscow\"] },\n\t\"(UTC+03:00) Kuwait, Riyadh\": { \"iana\": [\"Asia/Riyadh\"] },\n\t\"(UTC+03:00) Nairobi\": { \"iana\": [\"Africa/Nairobi\"] },\n\t\"(UTC+03:00) Baghdad\": { \"iana\": [\"Asia/Baghdad\"] },\n\t\"(UTC+03:30) Tehran\": { \"iana\": [\"Asia/Tehran\"] },\n\t\"(UTC+04:00) Abu Dhabi, Muscat\": { \"iana\": [\"Asia/Dubai\"] },\n\t\"(UTC+04:00) Baku, Tbilisi, Yerevan\": { \"iana\": [\"Asia/Yerevan\"] },\n\t\"(UTC+04:30) Kabul\": { \"iana\": [null] },\n\t\"(UTC+05:00) Ekaterinburg\": { \"iana\": [\"Asia/Yekaterinburg\"] },\n\t\"(UTC+05:00) Tashkent\": { \"iana\": [\"Asia/Tashkent\"] },\n\t\"(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi\": { \"iana\": [\"Asia/Calcutta\"] },\n\t\"(UTC+05:45) Kathmandu\": { \"iana\": [\"Asia/Katmandu\"] },\n\t\"(UTC+06:00) Astana, Dhaka\": { \"iana\": [\"Asia/Almaty\"] },\n\t\"(UTC+06:00) Sri Jayawardenepura\": { \"iana\": [\"Asia/Colombo\"] },\n\t\"(UTC+06:00) Almaty, Novosibirsk\": { \"iana\": [\"Asia/Novosibirsk\"] },\n\t\"(UTC+06:30) Yangon (Rangoon)\": { \"iana\": [\"Asia/Rangoon\"] },\n\t\"(UTC+07:00) Bangkok, Hanoi, Jakarta\": { \"iana\": [\"Asia/Bangkok\"] },\n\t\"(UTC+07:00) Krasnoyarsk\": { \"iana\": [\"Asia/Krasnoyarsk\"] },\n\t\"(UTC+08:00) Beijing, Chongqing, Hong Kong, Urumqi\": { \"iana\": [\"Asia/Shanghai\"] },\n\t\"(UTC+08:00) Kuala Lumpur, Singapore\": { \"iana\": [\"Asia/Singapore\"] },\n\t\"(UTC+08:00) Taipei\": { \"iana\": [\"Asia/Taipei\"] },\n\t\"(UTC+08:00) Perth\": { \"iana\": [\"Australia/Perth\"] },\n\t\"(UTC+08:00) Irkutsk, Ulaanbaatar\": { \"iana\": [\"Asia/Irkutsk\"] },\n\t\"(UTC+09:00) Seoul\": { \"iana\": [\"Asia/Seoul\"] },\n\t\"(UTC+09:00) Osaka, Sapporo, Tokyo\": { \"iana\": [\"Asia/Tokyo\"] },\n\t\"(UTC+09:00) Yakutsk\": { \"iana\": [\"Asia/Yakutsk\"] },\n\t\"(UTC+09:30) Darwin\": { \"iana\": [\"Australia/Darwin\"] },\n\t\"(UTC+09:30) Adelaide\": { \"iana\": [\"Australia/Adelaide\"] },\n\t\"(UTC+10:00) Canberra, Melbourne, Sydney\": { \"iana\": [\"Australia/Sydney\"] },\n\t\"(GMT+10:00) Canberra, Melbourne, Sydney\": { \"iana\": [\"Australia/Sydney\"] },\n\t\"(UTC+10:00) Brisbane\": { \"iana\": [\"Australia/Brisbane\"] },\n\t\"(UTC+10:00) Hobart\": { \"iana\": [\"Australia/Hobart\"] },\n\t\"(UTC+10:00) Vladivostok\": { \"iana\": [\"Asia/Vladivostok\"] },\n\t\"(UTC+10:00) Guam, Port Moresby\": { \"iana\": [\"Pacific/Port_Moresby\"] },\n\t\"(UTC+11:00) Magadan, Solomon Islands, New Caledonia\": { \"iana\": [\"Pacific/Guadalcanal\"] },\n\t\"(UTC+12:00) Fiji, Kamchatka, Marshall Is.\": { \"iana\": [null] },\n\t\"(UTC+12:00) Auckland, Wellington\": { \"iana\": [\"Pacific/Auckland\"] },\n\t\"(UTC+13:00) Nuku'alofa\": { \"iana\": [\"Pacific/Tongatapu\"] },\n\t\"(UTC-03:00) Buenos Aires\": { \"iana\": [\"America/Buenos_Aires\"] },\n\t\"(UTC+02:00) Beirut\": { \"iana\": [\"Asia/Beirut\"] },\n\t\"(UTC+02:00) Amman\": { \"iana\": [\"Asia/Amman\"] },\n\t\"(UTC-06:00) Guadalajara, Mexico City, Monterrey - New\": { \"iana\": [\"America/Mexico_City\"] },\n\t\"(UTC-07:00) Chihuahua, La Paz, Mazatlan - New\": { \"iana\": [\"America/Chihuahua\"] },\n\t\"(UTC-08:00) Tijuana, Baja California\": { \"iana\": [\"America/Tijuana\"] },\n\t\"(UTC+02:00) Windhoek\": { \"iana\": [\"Africa/Windhoek\"] },\n\t\"(UTC+03:00) Tbilisi\": { \"iana\": [\"Asia/Tbilisi\"] },\n\t\"(UTC-04:00) Manaus\": { \"iana\": [\"America/Cuiaba\"] },\n\t\"(UTC-03:00) Montevideo\": { \"iana\": [\"America/Montevideo\"] },\n\t\"(UTC+04:00) Yerevan\": { \"iana\": [null] },\n\t\"(UTC-04:30) Caracas\": { \"iana\": [\"America/Caracas\"] },\n\t\"(UTC) Casablanca\": { \"iana\": [\"Africa/Casablanca\"] },\n\t\"(UTC+05:00) Islamabad, Karachi\": { \"iana\": [\"Asia/Karachi\"] },\n\t\"(UTC+04:00) Port Louis\": { \"iana\": [\"Indian/Mauritius\"] },\n\t\"(UTC) Coordinated Universal Time\": { \"iana\": [\"Etc/GMT\"] },\n\t\"(UTC-04:00) Asuncion\": { \"iana\": [\"America/Asuncion\"] },\n\t\"(UTC+12:00) Petropavlovsk-Kamchatsky\": { \"iana\": [null] }\n}\n"
  },
  {
    "path": "modules/default/clock/README.md",
    "content": "# Module: Clock\n\nThe `clock` module is one of the default modules of the MagicMirror².\nThis module displays the current date and time. The information will be updated realtime.\n\nFor configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/clock.html).\n"
  },
  {
    "path": "modules/default/clock/clock.js",
    "content": "/* global SunCalc, formatTime */\n\nModule.register(\"clock\", {\n\t// Module config defaults.\n\tdefaults: {\n\t\tdisplayType: \"digital\", // options: digital, analog, both\n\n\t\ttimeFormat: config.timeFormat,\n\t\ttimezone: null,\n\n\t\tdisplaySeconds: true,\n\t\tshowPeriod: true,\n\t\tshowPeriodUpper: false,\n\t\tclockBold: false,\n\t\tshowDate: true,\n\t\tshowTime: true,\n\t\tshowWeek: false, // options: true, false, 'short'\n\t\tdateFormat: \"dddd, LL\",\n\t\tsendNotifications: false,\n\n\t\t/* specific to the analog clock */\n\t\tanalogSize: \"200px\",\n\t\tanalogFace: \"simple\", // options: 'none', 'simple', 'face-###' (where ### is 001 to 012 inclusive)\n\t\tanalogPlacement: \"bottom\", // options: 'top', 'bottom', 'left', 'right'\n\t\tanalogShowDate: \"top\", // OBSOLETE, can be replaced with analogPlacement and showTime, options: false, 'top', or 'bottom'\n\t\tsecondsColor: \"#888888\", // DEPRECATED, use CSS instead. Class \"clock-second-digital\" for digital clock, \"clock-second\" for analog clock.\n\n\t\tshowSunTimes: false, // options: true, false, 'disableNextEvent'\n\t\tshowMoonTimes: false, // options: false, 'times' (rise/set), 'percent' (lit percent), 'phase' (current phase), or 'both' (percent & phase)\n\t\tlat: 47.630539,\n\t\tlon: -122.344147\n\t},\n\t// Define required scripts.\n\tgetScripts () {\n\t\treturn [\"moment.js\", \"moment-timezone.js\", \"suncalc.js\"];\n\t},\n\t// Define styles.\n\tgetStyles () {\n\t\treturn [\"clock_styles.css\", \"font-awesome.css\"];\n\t},\n\t// Define start sequence.\n\tstart () {\n\t\tLog.info(`Starting module: ${this.name}`);\n\n\t\t// Schedule update interval.\n\t\tthis.second = moment().second();\n\t\tthis.minute = moment().minute();\n\n\t\t// Calculate how many ms should pass until next update depending on if seconds is displayed or not\n\t\tconst delayCalculator = (reducedSeconds) => {\n\t\t\tconst EXTRA_DELAY = 50; // Deliberate imperceptible delay to prevent off-by-one timekeeping errors\n\n\t\t\tif (this.config.displaySeconds) {\n\t\t\t\treturn 1000 - moment().milliseconds() + EXTRA_DELAY;\n\t\t\t} else {\n\t\t\t\treturn (60 - reducedSeconds) * 1000 - moment().milliseconds() + EXTRA_DELAY;\n\t\t\t}\n\t\t};\n\n\t\t// A recursive timeout function instead of interval to avoid drifting\n\t\tconst notificationTimer = () => {\n\t\t\tthis.updateDom();\n\n\t\t\tif (this.config.sendNotifications) {\n\t\t\t\t// If seconds is displayed CLOCK_SECOND-notification should be sent (but not when CLOCK_MINUTE-notification is sent)\n\t\t\t\tif (this.config.displaySeconds) {\n\t\t\t\t\tthis.second = moment().second();\n\t\t\t\t\tif (this.second !== 0) {\n\t\t\t\t\t\tthis.sendNotification(\"CLOCK_SECOND\", this.second);\n\t\t\t\t\t\tsetTimeout(notificationTimer, delayCalculator(0));\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// If minute changed or seconds isn't displayed send CLOCK_MINUTE-notification\n\t\t\t\tthis.minute = moment().minute();\n\t\t\t\tthis.sendNotification(\"CLOCK_MINUTE\", this.minute);\n\t\t\t}\n\n\t\t\tsetTimeout(notificationTimer, delayCalculator(0));\n\t\t};\n\n\t\t// Set the initial timeout with the amount of seconds elapsed as\n\t\t// reducedSeconds, so it will trigger when the minute changes\n\t\tsetTimeout(notificationTimer, delayCalculator(this.second));\n\n\t\t// Set locale.\n\t\tmoment.locale(config.language);\n\t},\n\t// Override dom generator.\n\tgetDom () {\n\t\tconst wrapper = document.createElement(\"div\");\n\t\twrapper.classList.add(\"clock-grid\");\n\n\t\t/************************************\n\t\t * Create wrappers for analog and digital clock\n\t\t */\n\t\tconst analogWrapper = document.createElement(\"div\");\n\t\tanalogWrapper.className = \"clock-circle\";\n\t\tconst digitalWrapper = document.createElement(\"div\");\n\t\tdigitalWrapper.className = \"digital\";\n\n\t\t/************************************\n\t\t * Create wrappers for DIGITAL clock\n\t\t */\n\t\tconst dateWrapper = document.createElement(\"div\");\n\t\tconst timeWrapper = document.createElement(\"div\");\n\t\tconst hoursWrapper = document.createElement(\"span\");\n\t\tconst minutesWrapper = document.createElement(\"span\");\n\t\tconst secondsWrapper = document.createElement(\"sup\");\n\t\tconst periodWrapper = document.createElement(\"span\");\n\t\tconst sunWrapper = document.createElement(\"div\");\n\t\tconst moonWrapper = document.createElement(\"div\");\n\t\tconst weekWrapper = document.createElement(\"div\");\n\n\t\t// Style Wrappers\n\t\tdateWrapper.className = \"date normal medium\";\n\t\ttimeWrapper.className = \"time bright large light\";\n\t\thoursWrapper.className = \"clock-hour-digital\";\n\t\tminutesWrapper.className = \"clock-minute-digital\";\n\t\tsecondsWrapper.className = \"clock-second-digital dimmed\";\n\t\tsunWrapper.className = \"sun dimmed small\";\n\t\tmoonWrapper.className = \"moon dimmed small\";\n\t\tweekWrapper.className = \"week dimmed medium\";\n\n\t\t// Set content of wrappers.\n\t\tconst now = moment();\n\t\tif (this.config.timezone) {\n\t\t\tnow.tz(this.config.timezone);\n\t\t}\n\n\t\tif (this.config.showDate) {\n\t\t\tdateWrapper.innerHTML = now.format(this.config.dateFormat);\n\t\t\tdigitalWrapper.appendChild(dateWrapper);\n\t\t}\n\n\t\tif (this.config.displayType !== \"analog\" && this.config.showTime) {\n\t\t\tlet hourSymbol = \"HH\";\n\t\t\tif (this.config.timeFormat !== 24) {\n\t\t\t\thourSymbol = \"h\";\n\t\t\t}\n\n\t\t\thoursWrapper.innerHTML = now.format(hourSymbol);\n\t\t\tminutesWrapper.innerHTML = now.format(\"mm\");\n\n\t\t\ttimeWrapper.appendChild(hoursWrapper);\n\t\t\tif (this.config.clockBold) {\n\t\t\t\tminutesWrapper.classList.add(\"bold\");\n\t\t\t} else {\n\t\t\t\ttimeWrapper.innerHTML += \":\";\n\t\t\t}\n\t\t\ttimeWrapper.appendChild(minutesWrapper);\n\t\t\tsecondsWrapper.innerHTML = now.format(\"ss\");\n\t\t\tif (this.config.showPeriodUpper) {\n\t\t\t\tperiodWrapper.innerHTML = now.format(\"A\");\n\t\t\t} else {\n\t\t\t\tperiodWrapper.innerHTML = now.format(\"a\");\n\t\t\t}\n\t\t\tif (this.config.displaySeconds) {\n\t\t\t\ttimeWrapper.appendChild(secondsWrapper);\n\t\t\t}\n\t\t\tif (this.config.showPeriod && this.config.timeFormat !== 24) {\n\t\t\t\ttimeWrapper.appendChild(periodWrapper);\n\t\t\t}\n\t\t\tdigitalWrapper.appendChild(timeWrapper);\n\t\t}\n\n\t\t/****************************************************************\n\t\t * Create wrappers for Sun Times, only if specified in config\n\t\t */\n\t\tif (this.config.showSunTimes) {\n\t\t\tconst sunTimes = SunCalc.getTimes(now, this.config.lat, this.config.lon);\n\t\t\tconst isVisible = now.isBetween(sunTimes.sunrise, sunTimes.sunset);\n\t\t\tlet sunWrapperInnerHTML = \"\";\n\n\t\t\tif (this.config.showSunTimes !== \"disableNextEvent\") {\n\t\t\t\tlet nextEvent;\n\t\t\t\tif (now.isBefore(sunTimes.sunrise)) {\n\t\t\t\t\tnextEvent = sunTimes.sunrise;\n\t\t\t\t} else if (now.isBefore(sunTimes.sunset)) {\n\t\t\t\t\tnextEvent = sunTimes.sunset;\n\t\t\t\t} else {\n\t\t\t\t\tconst tomorrowSunTimes = SunCalc.getTimes(now.clone().add(1, \"day\"), this.config.lat, this.config.lon);\n\t\t\t\t\tnextEvent = tomorrowSunTimes.sunrise;\n\t\t\t\t}\n\t\t\t\tconst untilNextEvent = moment.duration(moment(nextEvent).diff(now));\n\t\t\t\tconst untilNextEventString = `${untilNextEvent.hours()}h ${untilNextEvent.minutes()}m`;\n\n\t\t\t\tsunWrapperInnerHTML = `<span class=\"${isVisible ? \"bright\" : \"\"}\"><i class=\"fas fa-sun\" aria-hidden=\"true\"></i> ${untilNextEventString}</span>`;\n\t\t\t}\n\n\t\t\tsunWrapperInnerHTML += `<span><i class=\"fas fa-arrow-up\" aria-hidden=\"true\"></i> ${formatTime(this.config, sunTimes.sunrise)}</span>`\n\t\t\t  + `<span><i class=\"fas fa-arrow-down\" aria-hidden=\"true\"></i> ${formatTime(this.config, sunTimes.sunset)}</span>`;\n\n\t\t\tsunWrapper.innerHTML = sunWrapperInnerHTML;\n\t\t\tdigitalWrapper.appendChild(sunWrapper);\n\t\t}\n\n\t\t/****************************************************************\n\t\t * Create wrappers for Moon Times, only if specified in config\n\t\t */\n\t\tif (this.config.showMoonTimes) {\n\t\t\tconst moonIllumination = SunCalc.getMoonIllumination(now.toDate());\n\t\t\tconst moonTimes = SunCalc.getMoonTimes(now, this.config.lat, this.config.lon);\n\t\t\tconst moonRise = moonTimes.rise;\n\t\t\tlet moonSet;\n\t\t\tif (moment(moonTimes.set).isAfter(moonTimes.rise)) {\n\t\t\t\tmoonSet = moonTimes.set;\n\t\t\t} else {\n\t\t\t\tconst nextMoonTimes = SunCalc.getMoonTimes(now.clone().add(1, \"day\"), this.config.lat, this.config.lon);\n\t\t\t\tmoonSet = nextMoonTimes.set;\n\t\t\t}\n\t\t\tconst isVisible = now.isBetween(moonRise, moonSet) || moonTimes.alwaysUp === true;\n\t\t\tconst showFraction = [\"both\", \"percent\"].includes(this.config.showMoonTimes);\n\t\t\tconst showUnicode = [\"both\", \"phase\"].includes(this.config.showMoonTimes);\n\t\t\tconst illuminatedFractionString = `${Math.round(moonIllumination.fraction * 100)}%`;\n\t\t\tconst image = showUnicode ? [...\"🌑🌒🌓🌔🌕🌖🌗🌘\"][Math.floor(moonIllumination.phase * 8)] : \"<i class=\\\"fas fa-moon\\\" aria-hidden=\\\"true\\\"></i>\";\n\n\t\t\tmoonWrapper.innerHTML\n\t\t\t\t= `<span class=\"${isVisible ? \"bright\" : \"\"}\">${image} ${showFraction ? illuminatedFractionString : \"\"}</span>`\n\t\t\t\t  + `<span><i class=\"fas fa-arrow-up\" aria-hidden=\"true\"></i> ${moonRise ? formatTime(this.config, moonRise) : \"...\"}</span>`\n\t\t\t\t  + `<span><i class=\"fas fa-arrow-down\" aria-hidden=\"true\"></i> ${moonSet ? formatTime(this.config, moonSet) : \"...\"}</span>`;\n\t\t\tdigitalWrapper.appendChild(moonWrapper);\n\t\t}\n\n\t\tif (this.config.showWeek) {\n\t\t\tif (this.config.showWeek === \"short\") {\n\t\t\t\tweekWrapper.innerHTML = this.translate(\"WEEK_SHORT\", { weekNumber: now.week() });\n\t\t\t} else {\n\t\t\t\tweekWrapper.innerHTML = this.translate(\"WEEK\", { weekNumber: now.week() });\n\t\t\t}\n\n\t\t\tdigitalWrapper.appendChild(weekWrapper);\n\t\t}\n\n\t\t/****************************************************************\n\t\t * Create wrappers for ANALOG clock, only if specified in config\n\t\t */\n\t\tif (this.config.displayType !== \"digital\") {\n\t\t\t// If it isn't 'digital', then an 'analog' clock was also requested\n\n\t\t\t// Calculate the degree offset for each hand of the clock\n\t\t\tif (this.config.timezone) {\n\t\t\t\tnow.tz(this.config.timezone);\n\t\t\t}\n\t\t\tconst second = now.seconds() * 6,\n\t\t\t\tminute = now.minute() * 6 + second / 60,\n\t\t\t\thour = ((now.hours() % 12) / 12) * 360 + 90 + minute / 12;\n\n\t\t\t// Create wrappers\n\t\t\tanalogWrapper.style.width = this.config.analogSize;\n\t\t\tanalogWrapper.style.height = this.config.analogSize;\n\n\t\t\tif (this.config.analogFace !== \"\" && this.config.analogFace !== \"simple\" && this.config.analogFace !== \"none\") {\n\t\t\t\tanalogWrapper.style.background = `url(${this.data.path}faces/${this.config.analogFace}.svg)`;\n\t\t\t\tanalogWrapper.style.backgroundSize = \"100%\";\n\n\t\t\t\t// The following line solves issue: https://github.com/MagicMirrorOrg/MagicMirror/issues/611\n\t\t\t\t// analogWrapper.style.border = \"1px solid black\";\n\t\t\t\tanalogWrapper.style.border = \"rgba(0, 0, 0, 0.1)\"; //Updated fix for Issue 611 where non-black backgrounds are used\n\t\t\t} else if (this.config.analogFace !== \"none\") {\n\t\t\t\tanalogWrapper.style.border = \"2px solid white\";\n\t\t\t}\n\t\t\tconst clockFace = document.createElement(\"div\");\n\t\t\tclockFace.className = \"clock-face\";\n\n\t\t\tconst clockHour = document.createElement(\"div\");\n\t\t\tclockHour.id = \"clock-hour\";\n\t\t\tclockHour.style.transform = `rotate(${hour}deg)`;\n\t\t\tclockHour.className = \"clock-hour\";\n\t\t\tconst clockMinute = document.createElement(\"div\");\n\t\t\tclockMinute.id = \"clock-minute\";\n\t\t\tclockMinute.style.transform = `rotate(${minute}deg)`;\n\t\t\tclockMinute.className = \"clock-minute\";\n\n\t\t\t// Combine analog wrappers\n\t\t\tclockFace.appendChild(clockHour);\n\t\t\tclockFace.appendChild(clockMinute);\n\n\t\t\tif (this.config.displaySeconds) {\n\t\t\t\tconst clockSecond = document.createElement(\"div\");\n\t\t\t\tclockSecond.id = \"clock-second\";\n\t\t\t\tclockSecond.style.transform = `rotate(${second}deg)`;\n\t\t\t\tclockSecond.className = \"clock-second\";\n\t\t\t\tclockSecond.style.backgroundColor = this.config.secondsColor; /* DEPRECATED, to be removed in a future version , use CSS instead */\n\t\t\t\tclockFace.appendChild(clockSecond);\n\t\t\t}\n\t\t\tanalogWrapper.appendChild(clockFace);\n\t\t}\n\n\t\t/*******************************************\n\t\t * Update placement, respect old analogShowDate even if it's not needed anymore\n\t\t */\n\t\tif (this.config.displayType === \"analog\") {\n\t\t\t// Display only an analog clock\n\t\t\tif (this.config.showDate) {\n\t\t\t\t// Add date to the analog clock\n\t\t\t\tdateWrapper.innerHTML = now.format(this.config.dateFormat);\n\t\t\t\twrapper.appendChild(dateWrapper);\n\t\t\t}\n\t\t\tif (this.config.analogShowDate === \"bottom\") {\n\t\t\t\twrapper.classList.add(\"clock-grid-bottom\");\n\t\t\t} else if (this.config.analogShowDate === \"top\") {\n\t\t\t\twrapper.classList.add(\"clock-grid-top\");\n\t\t\t}\n\t\t\twrapper.appendChild(analogWrapper);\n\t\t} else if (this.config.displayType === \"digital\") {\n\t\t\twrapper.appendChild(digitalWrapper);\n\t\t} else if (this.config.displayType === \"both\") {\n\t\t\twrapper.classList.add(`clock-grid-${this.config.analogPlacement}`);\n\t\t\twrapper.appendChild(analogWrapper);\n\t\t\twrapper.appendChild(digitalWrapper);\n\t\t}\n\n\t\t// Return the wrapper to the dom.\n\t\treturn wrapper;\n\t}\n});\n"
  },
  {
    "path": "modules/default/clock/clock_styles.css",
    "content": ".clock-grid {\n  display: inline-flex;\n  gap: 15px;\n}\n\n.clock-grid-left {\n  flex-direction: row;\n}\n\n.clock-grid-right {\n  flex-direction: row-reverse;\n}\n\n.clock-grid-top {\n  flex-direction: column;\n}\n\n.clock-grid-bottom {\n  flex-direction: column-reverse;\n}\n\n.clock-circle {\n  place-self: center;\n  position: relative;\n  border-radius: 50%;\n  background-size: 100%;\n}\n\n.clock-face {\n  width: 100%;\n  height: 100%;\n}\n\n.clock-face::after {\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  width: 6px;\n  height: 6px;\n  margin: -3px 0 0 -3px;\n  background: var(--color-text-bright);\n  border-radius: 3px;\n  content: \"\";\n  display: block;\n}\n\n.clock-hour {\n  width: 0;\n  height: 0;\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  margin: -2px 0 -2px -25%; /* numbers must match negative length & thickness */\n  padding: 2px 0 2px 25%; /* indicator length & thickness */\n  background: var(--color-text-bright);\n  transform-origin: 100% 50%;\n  border-radius: 3px 0 0 3px;\n}\n\n.clock-minute {\n  width: 0;\n  height: 0;\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  margin: -35% -2px 0; /* numbers must match negative length & thickness */\n  padding: 35% 2px 0; /* indicator length & thickness */\n  background: var(--color-text-bright);\n  transform-origin: 50% 100%;\n  border-radius: 3px 0 0 3px;\n}\n\n.clock-second {\n  width: 0;\n  height: 0;\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  margin: -38% -1px 0 0; /* numbers must match negative length & thickness */\n  padding: 38% 1px 0 0; /* indicator length & thickness */\n\n  /* background: #888888 !important; */\n\n  /* use this instead of secondsColor */\n\n  /* have to use !important, because the code explicitly sets the color currently */\n  transform-origin: 50% 100%;\n}\n\n.module.clock .digital {\n  display: flex;\n  flex-direction: column;\n  gap: 3px;\n}\n\n.module.clock .sun,\n.module.clock .moon {\n  display: flex;\n  white-space: nowrap;\n  gap: 10px;\n}\n\n.module.clock .sun > *,\n.module.clock .moon > * {\n  flex: 1;\n}\n\n.module.clock .clock-hour-digital {\n  color: var(--color-text-bright);\n}\n\n.module.clock .clock-minute-digital {\n  color: var(--color-text-bright);\n}\n\n.module.clock .clock-second-digital {\n  color: var(--color-text-dimmed);\n}\n"
  },
  {
    "path": "modules/default/compliments/README.md",
    "content": "# Module: Compliments\n\nThe `compliments` module is one of the default modules of the MagicMirror².\nThis module displays a random compliment.\n\nFor configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/compliments.html).\n"
  },
  {
    "path": "modules/default/compliments/compliments.js",
    "content": "/* global Cron */\n\nModule.register(\"compliments\", {\n\t// Module config defaults.\n\tdefaults: {\n\t\tcompliments: {\n\t\t\tanytime: [\"Hey there sexy!\"],\n\t\t\tmorning: [\"Good morning, handsome!\", \"Enjoy your day!\", \"How was your sleep?\"],\n\t\t\tafternoon: [\"Hello, beauty!\", \"You look sexy!\", \"Looking good today!\"],\n\t\t\tevening: [\"Wow, you look hot!\", \"You look nice!\", \"Hi, sexy!\"],\n\t\t\t\"....-01-01\": [\"Happy new year!\"]\n\t\t},\n\t\tupdateInterval: 30000,\n\t\tremoteFile: null,\n\t\tremoteFileRefreshInterval: 0,\n\t\tfadeSpeed: 4000,\n\t\tmorningStartTime: 3,\n\t\tmorningEndTime: 12,\n\t\tafternoonStartTime: 12,\n\t\tafternoonEndTime: 17,\n\t\trandom: true,\n\t\tspecialDayUnique: false\n\t},\n\tcompliments_new: null,\n\trefreshMinimumDelay: 15 * 60 * 1000, // 15 minutes\n\tlastIndexUsed: -1,\n\t// Set currentweather from module\n\tcurrentWeatherType: \"\",\n\tcron_regex: /^(((\\d+,)+\\d+|((\\d+|[*])[/]\\d+|((JAN|FEB|APR|MA[RY]|JU[LN]|AUG|SEP|OCT|NOV|DEC)(-(JAN|FEB|APR|MA[RY]|JU[LN]|AUG|SEP|OCT|NOV|DEC))?))|(\\d+-\\d+)|\\d+(-\\d+)?[/]\\d+(-\\d+)?|\\d+|[*]|(MON|TUE|WED|THU|FRI|SAT|SUN)(-(MON|TUE|WED|THU|FRI|SAT|SUN))?) ?){5}$/i,\n\tdate_regex: \"[1-9.][0-9.][0-9.]{2}-([0][1-9]|[1][0-2])-([1-2][0-9]|[0][1-9]|[3][0-1])\",\n\tpre_defined_types: [\"anytime\", \"morning\", \"afternoon\", \"evening\"],\n\t// Define required scripts.\n\tgetScripts () {\n\t\treturn [\"croner.js\", \"moment.js\"];\n\t},\n\n\t// Define start sequence.\n\tasync start () {\n\t\tLog.info(`Starting module: ${this.name}`);\n\n\t\tthis.lastComplimentIndex = -1;\n\n\t\tif (this.config.remoteFile !== null) {\n\t\t\tconst response = await this.loadComplimentFile();\n\t\t\tthis.config.compliments = JSON.parse(response);\n\t\t\tthis.updateDom();\n\t\t\tif (this.config.remoteFileRefreshInterval !== 0) {\n\t\t\t\tif ((this.config.remoteFileRefreshInterval >= this.refreshMinimumDelay) || window.mmTestMode === \"true\") {\n\t\t\t\t\tsetInterval(async () => {\n\t\t\t\t\t\tconst response = await this.loadComplimentFile();\n\t\t\t\t\t\tif (response) {\n\t\t\t\t\t\t\tthis.compliments_new = JSON.parse(response);\n\t\t\t\t\t\t}\n\t\t\t\t\t\telse {\n\t\t\t\t\t\t\tLog.error(`[compliments] ${this.name} remoteFile refresh failed`);\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\tthis.config.remoteFileRefreshInterval);\n\t\t\t\t} else {\n\t\t\t\t\tLog.error(`[compliments] ${this.name} remoteFileRefreshInterval less than minimum`);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tlet minute_sync_delay = 1;\n\t\t// loop thru all the configured when events\n\t\tfor (let m of Object.keys(this.config.compliments)) {\n\t\t\t// if it is a cron entry\n\t\t\tif (this.isCronEntry(m)) {\n\t\t\t\t// we need to synch our interval cycle to the minute\n\t\t\t\tminute_sync_delay = (60 - (moment().second())) * 1000;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\t// Schedule update timer. sync to the minute start (if needed), so minute based events happen on the minute start\n\t\tsetTimeout(() => {\n\t\t\tsetInterval(() => {\n\t\t\t\tthis.updateDom(this.config.fadeSpeed);\n\t\t\t}, this.config.updateInterval);\n\t\t},\n\t\tminute_sync_delay);\n\t},\n\n\t// check to see if this entry could be a cron entry which contains spaces\n\tisCronEntry (entry) {\n\t\treturn entry.includes(\" \");\n\t},\n\n\t/**\n\t * @param {string} cronExpression The cron expression. See https://croner.56k.guru/usage/pattern/\n\t * @param {Date} [timestamp] The timestamp to check. Defaults to the current time.\n\t * @returns {number} The number of seconds until the next cron run.\n\t */\n\tgetSecondsUntilNextCronRun (cronExpression, timestamp = new Date()) {\n\t\t// Required for seconds precision\n\t\tconst adjustedTimestamp = new Date(timestamp.getTime() - 1000);\n\n\t\t// https://www.npmjs.com/package/croner\n\t\tconst cronJob = new Cron(cronExpression);\n\t\tconst nextRunTime = cronJob.nextRun(adjustedTimestamp);\n\n\t\tconst secondsDelta = (nextRunTime - adjustedTimestamp) / 1000;\n\t\treturn secondsDelta;\n\t},\n\n\t/**\n\t * Generate a random index for a list of compliments.\n\t * @param {string[]} compliments Array with compliments.\n\t * @returns {number} a random index of given array\n\t */\n\trandomIndex (compliments) {\n\t\tif (compliments.length <= 1) {\n\t\t\treturn 0;\n\t\t}\n\n\t\tconst generate = function () {\n\t\t\treturn Math.floor(Math.random() * compliments.length);\n\t\t};\n\n\t\tlet complimentIndex = generate();\n\n\t\twhile (complimentIndex === this.lastComplimentIndex) {\n\t\t\tcomplimentIndex = generate();\n\t\t}\n\n\t\tthis.lastComplimentIndex = complimentIndex;\n\n\t\treturn complimentIndex;\n\t},\n\n\t/**\n\t * Retrieve an array of compliments for the time of the day.\n\t * @returns {string[]} array with compliments for the time of the day.\n\t */\n\tcomplimentArray () {\n\t\tconst now = moment();\n\t\tconst hour = now.hour();\n\t\tconst date = now.format(\"YYYY-MM-DD\");\n\t\tlet compliments = [];\n\n\t\t// Add time of day compliments\n\t\tlet timeOfDay;\n\t\tif (hour >= this.config.morningStartTime && hour < this.config.morningEndTime) {\n\t\t\ttimeOfDay = \"morning\";\n\t\t} else if (hour >= this.config.afternoonStartTime && hour < this.config.afternoonEndTime) {\n\t\t\ttimeOfDay = \"afternoon\";\n\t\t} else {\n\t\t\ttimeOfDay = \"evening\";\n\t\t}\n\n\t\tif (timeOfDay && this.config.compliments.hasOwnProperty(timeOfDay)) {\n\t\t\tcompliments = [...this.config.compliments[timeOfDay]];\n\t\t}\n\n\t\t// Add compliments based on weather\n\t\tif (this.currentWeatherType in this.config.compliments) {\n\t\t\tArray.prototype.push.apply(compliments, this.config.compliments[this.currentWeatherType]);\n\t\t\t// if the predefine list doesn't include it (yet)\n\t\t\tif (!this.pre_defined_types.includes(this.currentWeatherType)) {\n\t\t\t\t// add it\n\t\t\t\tthis.pre_defined_types.push(this.currentWeatherType);\n\t\t\t}\n\t\t}\n\n\t\t// Add compliments for anytime\n\t\tArray.prototype.push.apply(compliments, this.config.compliments.anytime);\n\n\t\t// get the list of just date entry keys\n\t\tlet temp_list = Object.keys(this.config.compliments).filter((k) => {\n\t\t\tif (this.pre_defined_types.includes(k)) return false;\n\t\t\telse return true;\n\t\t});\n\n\t\tlet date_compliments = [];\n\t\t// Add compliments for special day/times\n\t\tfor (let entry of temp_list) {\n\t\t\t// check if this could be a cron type entry\n\t\t\tif (this.isCronEntry(entry)) {\n\t\t\t\t// make sure the regex is valid\n\t\t\t\tif (new RegExp(this.cron_regex).test(entry)) {\n\t\t\t\t\t// check if we are in the time range for the cron entry\n\t\t\t\t\tif (this.getSecondsUntilNextCronRun(entry, now.set(\"seconds\", 0).toDate()) <= 1) {\n\t\t\t\t\t\t// if so, use its notice entries\n\t\t\t\t\t\tArray.prototype.push.apply(date_compliments, this.config.compliments[entry]);\n\t\t\t\t\t}\n\t\t\t\t} else Log.error(`[compliments] cron syntax invalid=${JSON.stringify(entry)}`);\n\t\t\t} else if (new RegExp(entry).test(date)) {\n\t\t\t\tArray.prototype.push.apply(date_compliments, this.config.compliments[entry]);\n\t\t\t}\n\t\t}\n\n\t\t// if we found any date compliments\n\t\tif (date_compliments.length) {\n\t\t\t// and the special flag is true\n\t\t\tif (this.config.specialDayUnique) {\n\t\t\t\t// clear the non-date compliments if any\n\t\t\t\tcompliments.length = 0;\n\t\t\t}\n\t\t\t// put the date based compliments on the list\n\t\t\tArray.prototype.push.apply(compliments, date_compliments);\n\t\t}\n\n\t\treturn compliments;\n\t},\n\n\t/**\n\t * Retrieve a file from the local filesystem\n\t * @returns {Promise<string|null>} Resolved with file content or null on error\n\t */\n\tasync loadComplimentFile () {\n\t\tconst { remoteFile, remoteFileRefreshInterval } = this.config;\n\t\tconst isRemote = remoteFile.startsWith(\"http://\") || remoteFile.startsWith(\"https://\");\n\t\tlet url = isRemote ? remoteFile : this.file(remoteFile);\n\n\t\ttry {\n\t\t\t// Validate URL\n\t\t\tconst urlObj = new URL(url);\n\t\t\t// Add cache-busting parameter to remote URLs to prevent cached responses\n\t\t\tif (isRemote && remoteFileRefreshInterval !== 0) {\n\t\t\t\turlObj.searchParams.set(\"dummy\", Date.now());\n\t\t\t}\n\t\t\turl = urlObj.toString();\n\t\t} catch {\n\t\t\tLog.warn(`[compliments] Invalid URL: ${url}`);\n\t\t}\n\n\t\ttry {\n\t\t\tconst response = await fetch(url);\n\t\t\tif (!response.ok) {\n\t\t\t\tLog.error(`[compliments] HTTP error: ${response.status} ${response.statusText}`);\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\treturn await response.text();\n\t\t} catch (error) {\n\t\t\tLog.info(\"[compliments] fetch failed:\", error.message);\n\t\t\treturn null;\n\t\t}\n\t},\n\n\t/**\n\t * Retrieve a random compliment.\n\t * @returns {string} a compliment\n\t */\n\tgetRandomCompliment () {\n\t\t// get the current time of day compliments list\n\t\tconst compliments = this.complimentArray();\n\t\t// variable for index to next message to display\n\t\tlet index;\n\t\t// are we randomizing\n\t\tif (this.config.random) {\n\t\t\t// yes\n\t\t\tindex = this.randomIndex(compliments);\n\t\t} else {\n\t\t\t// no, sequential\n\t\t\t// if doing sequential, don't fall off the end\n\t\t\tindex = this.lastIndexUsed >= compliments.length - 1 ? 0 : ++this.lastIndexUsed;\n\t\t}\n\n\t\treturn compliments[index] || \"\";\n\t},\n\n\t// Override dom generator.\n\tgetDom () {\n\t\tconst wrapper = document.createElement(\"div\");\n\t\twrapper.className = this.config.classes ? this.config.classes : \"thin xlarge bright pre-line\";\n\t\t// get the compliment text\n\t\tconst complimentText = this.getRandomCompliment();\n\t\t// split it into parts on newline text\n\t\tconst parts = complimentText.split(\"\\n\");\n\t\t// create a span to hold the compliment\n\t\tconst compliment = document.createElement(\"span\");\n\t\t// process all the parts of the compliment text\n\t\tfor (const part of parts) {\n\t\t\tif (part !== \"\") {\n\t\t\t\t// create a text element for each part\n\t\t\t\tcompliment.appendChild(document.createTextNode(part));\n\t\t\t\t// add a break\n\t\t\t\tcompliment.appendChild(document.createElement(\"BR\"));\n\t\t\t}\n\t\t}\n\t\t// only add compliment to wrapper if there is actual text in there\n\t\tif (compliment.children.length > 0) {\n\t\t\t// remove the last break\n\t\t\tcompliment.lastElementChild.remove();\n\t\t\twrapper.appendChild(compliment);\n\t\t}\n\t\t// if a new set of compliments was loaded from the refresh task\n\t\t// we do this here to make sure no other function is using the compliments list\n\t\tif (this.compliments_new) {\n\t\t\t// use them\n\t\t\tif (JSON.stringify(this.config.compliments) !== JSON.stringify(this.compliments_new)) {\n\t\t\t\t// only reset if the contents changes\n\t\t\t\tthis.config.compliments = this.compliments_new;\n\t\t\t\t// reset the index\n\t\t\t\tthis.lastIndexUsed = -1;\n\t\t\t}\n\t\t\t// clear new file list so we don't waste cycles comparing between refreshes\n\t\t\tthis.compliments_new = null;\n\t\t}\n\t\t// only in test mode\n\t\tif (window.mmTestMode === \"true\") {\n\t\t\t// check for (undocumented) remoteFile2 to test new file load\n\t\t\tif (this.config.remoteFile2 !== null && this.config.remoteFileRefreshInterval !== 0) {\n\t\t\t\t// switch the file so that next time it will be loaded from a changed file\n\t\t\t\tthis.config.remoteFile = this.config.remoteFile2;\n\t\t\t}\n\t\t}\n\t\treturn wrapper;\n\t},\n\n\t// Override notification handler.\n\tnotificationReceived (notification, payload, sender) {\n\t\tif (notification === \"CURRENTWEATHER_TYPE\") {\n\t\t\tthis.currentWeatherType = payload.type;\n\t\t}\n\t}\n});\n"
  },
  {
    "path": "modules/default/defaultmodules.js",
    "content": "/*\n * Default Modules List\n * Modules listed below can be loaded without the 'default/' prefix. Omitting the default folder name.\n */\nconst defaultModules = [\"alert\", \"calendar\", \"clock\", \"compliments\", \"helloworld\", \"newsfeed\", \"updatenotification\", \"weather\"];\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = defaultModules;\n}\n"
  },
  {
    "path": "modules/default/helloworld/README.md",
    "content": "# Module: Hello World\n\nThe `helloworld` module is one of the default modules of the MagicMirror². It is a simple way to display a static text on the mirror.\n\nFor configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/helloworld.html).\n"
  },
  {
    "path": "modules/default/helloworld/helloworld.js",
    "content": "Module.register(\"helloworld\", {\n\t// Default module config.\n\tdefaults: {\n\t\ttext: \"Hello World!\"\n\t},\n\n\tgetTemplate () {\n\t\treturn \"helloworld.njk\";\n\t},\n\n\tgetTemplateData () {\n\t\treturn this.config;\n\t}\n});\n"
  },
  {
    "path": "modules/default/helloworld/helloworld.njk",
    "content": "<!--\n\tUse ` | safe` to allow html tags within the text string.\n\thttps://mozilla.github.io/nunjucks/templating.html#autoescaping\n-->\n<div>{{ text | safe }}</div>\n"
  },
  {
    "path": "modules/default/newsfeed/README.md",
    "content": "# Module: News Feed\n\nThe `newsfeed` module is one of the default modules of the MagicMirror².\nThis module displays news headlines based on an RSS feed. Scrolling through news headlines happens time-based (`updateInterval`), but can also be controlled by sending news feed specific notifications to the module.\n\nFor configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/newsfeed.html).\n"
  },
  {
    "path": "modules/default/newsfeed/fullarticle.njk",
    "content": "<div>\n  <iframe class=\"newsfeed-fullarticle\" src=\"{{ url }}\"></iframe>\n</div>\n"
  },
  {
    "path": "modules/default/newsfeed/newsfeed.css",
    "content": "iframe.newsfeed-fullarticle {\n  width: 100vw;\n\n  /* very large height value to allow scrolling */\n  height: 3000px;\n  top: 0;\n  left: 0;\n  border: none;\n  z-index: 1;\n}\n\n.region.bottom.bar.newsfeed-fullarticle {\n  bottom: inherit;\n  top: -90px;\n}\n\n.newsfeed-list {\n  list-style: none;\n}\n\n.newsfeed-list li {\n  text-align: justify;\n  margin-bottom: 0.5em;\n}\n"
  },
  {
    "path": "modules/default/newsfeed/newsfeed.js",
    "content": "Module.register(\"newsfeed\", {\n\t// Default module config.\n\tdefaults: {\n\t\tfeeds: [\n\t\t\t{\n\t\t\t\ttitle: \"New York Times\",\n\t\t\t\turl: \"https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml\",\n\t\t\t\tencoding: \"UTF-8\" //ISO-8859-1\n\t\t\t}\n\t\t],\n\t\tshowAsList: false,\n\t\tshowSourceTitle: true,\n\t\tshowPublishDate: true,\n\t\tbroadcastNewsFeeds: true,\n\t\tbroadcastNewsUpdates: true,\n\t\tshowDescription: false,\n\t\tshowTitleAsUrl: false,\n\t\twrapTitle: true,\n\t\twrapDescription: true,\n\t\ttruncDescription: true,\n\t\tlengthDescription: 400,\n\t\thideLoading: false,\n\t\treloadInterval: 5 * 60 * 1000, // every 5 minutes\n\t\tupdateInterval: 10 * 1000,\n\t\tanimationSpeed: 2.5 * 1000,\n\t\tmaxNewsItems: 0, // 0 for unlimited\n\t\tignoreOldItems: false,\n\t\tignoreOlderThan: 24 * 60 * 60 * 1000, // 1 day\n\t\tremoveStartTags: \"\",\n\t\tremoveEndTags: \"\",\n\t\tstartTags: [],\n\t\tendTags: [],\n\t\tprohibitedWords: [],\n\t\tscrollLength: 500,\n\t\tlogFeedWarnings: false,\n\t\tdangerouslyDisableAutoEscaping: false\n\t},\n\n\tgetUrlPrefix (item) {\n\t\tif (item.useCorsProxy) {\n\t\t\treturn `${location.protocol}//${location.host}${config.basePath}cors?url=`;\n\t\t} else {\n\t\t\treturn \"\";\n\t\t}\n\t},\n\n\t// Define required scripts.\n\tgetScripts () {\n\t\treturn [\"moment.js\"];\n\t},\n\n\t//Define required styles.\n\tgetStyles () {\n\t\treturn [\"newsfeed.css\"];\n\t},\n\n\t// Define required translations.\n\tgetTranslations () {\n\t\t// The translations for the default modules are defined in the core translation files.\n\t\t// Therefore we can just return false. Otherwise we should have returned a dictionary.\n\t\t// If you're trying to build your own module including translations, check out the documentation.\n\t\treturn false;\n\t},\n\n\t// Define start sequence.\n\tstart () {\n\t\tLog.info(`Starting module: ${this.name}`);\n\n\t\t// Set locale.\n\t\tmoment.locale(config.language);\n\n\t\tthis.newsItems = [];\n\t\tthis.loaded = false;\n\t\tthis.error = null;\n\t\tthis.activeItem = 0;\n\t\tthis.scrollPosition = 0;\n\n\t\tthis.registerFeeds();\n\n\t\tthis.isShowingDescription = this.config.showDescription;\n\t},\n\n\t// Override socket notification handler.\n\tsocketNotificationReceived (notification, payload) {\n\t\tif (notification === \"NEWS_ITEMS\") {\n\t\t\tthis.generateFeed(payload);\n\n\t\t\tif (!this.loaded) {\n\t\t\t\tif (this.config.hideLoading) {\n\t\t\t\t\tthis.show();\n\t\t\t\t}\n\t\t\t\tthis.scheduleUpdateInterval();\n\t\t\t}\n\n\t\t\tthis.loaded = true;\n\t\t\tthis.error = null;\n\t\t} else if (notification === \"NEWSFEED_ERROR\") {\n\t\t\tthis.error = this.translate(payload.error_type);\n\t\t\tthis.scheduleUpdateInterval();\n\t\t}\n\t},\n\n\t//Override fetching of template name\n\tgetTemplate () {\n\t\tif (this.config.feedUrl) {\n\t\t\treturn \"oldconfig.njk\";\n\t\t} else if (this.config.showFullArticle) {\n\t\t\treturn \"fullarticle.njk\";\n\t\t}\n\t\treturn \"newsfeed.njk\";\n\t},\n\n\t//Override template data and return whats used for the current template\n\tgetTemplateData () {\n\t\tif (this.activeItem >= this.newsItems.length) {\n\t\t\tthis.activeItem = 0;\n\t\t}\n\t\tthis.activeItemCount = this.newsItems.length;\n\t\t// this.config.showFullArticle is a run-time configuration, triggered by optional notifications\n\t\tif (this.config.showFullArticle) {\n\t\t\tthis.activeItemHash = this.newsItems[this.activeItem]?.hash;\n\t\t\treturn {\n\t\t\t\turl: this.getActiveItemURL()\n\t\t\t};\n\t\t}\n\t\tif (this.error) {\n\t\t\tthis.activeItemHash = undefined;\n\t\t\treturn {\n\t\t\t\terror: this.error\n\t\t\t};\n\t\t}\n\t\tif (this.newsItems.length === 0) {\n\t\t\tthis.activeItemHash = undefined;\n\t\t\treturn {\n\t\t\t\tempty: true\n\t\t\t};\n\t\t}\n\n\t\tconst item = this.newsItems[this.activeItem];\n\t\tthis.activeItemHash = item.hash;\n\n\t\tconst items = this.newsItems.map(function (item) {\n\t\t\titem.publishDate = moment(new Date(item.pubdate)).fromNow();\n\t\t\treturn item;\n\t\t});\n\n\t\treturn {\n\t\t\tloaded: true,\n\t\t\tconfig: this.config,\n\t\t\tsourceTitle: item.sourceTitle,\n\t\t\tpublishDate: moment(new Date(item.pubdate)).fromNow(),\n\t\t\ttitle: item.title,\n\t\t\turl: this.getActiveItemURL(),\n\t\t\tdescription: item.description,\n\t\t\titems: items\n\t\t};\n\t},\n\n\tgetActiveItemURL () {\n\t\tconst item = this.newsItems[this.activeItem];\n\t\tif (item) {\n\t\t\treturn typeof item.url === \"string\" ? this.getUrlPrefix(item) + item.url : this.getUrlPrefix(item) + item.url.href;\n\t\t} else {\n\t\t\treturn \"\";\n\t\t}\n\t},\n\n\t/**\n\t * Registers the feeds to be used by the backend.\n\t */\n\tregisterFeeds () {\n\t\tfor (let feed of this.config.feeds) {\n\t\t\tthis.sendSocketNotification(\"ADD_FEED\", {\n\t\t\t\tfeed: feed,\n\t\t\t\tconfig: this.config\n\t\t\t});\n\t\t}\n\t},\n\n\t/**\n\t * Gets a feed property by name\n\t * @param {object} feed A feed object.\n\t * @param {string} property The name of the property.\n\t * @returns {string} The value of the specified property for the feed.\n\t */\n\tgetFeedProperty (feed, property) {\n\t\tlet res = this.config[property];\n\t\tconst f = this.config.feeds.find((feedItem) => feedItem.url === feed);\n\t\tif (f && f[property]) res = f[property];\n\t\treturn res;\n\t},\n\n\t/**\n\t * Generate an ordered list of items for this configured module.\n\t * @param {object} feeds An object with feeds returned by the node helper.\n\t */\n\tgenerateFeed (feeds) {\n\t\tlet newsItems = [];\n\t\tfor (let feed in feeds) {\n\t\t\tconst feedItems = feeds[feed];\n\t\t\tif (this.subscribedToFeed(feed)) {\n\t\t\t\tfor (let item of feedItems) {\n\t\t\t\t\titem.sourceTitle = this.titleForFeed(feed);\n\t\t\t\t\tif (!(this.getFeedProperty(feed, \"ignoreOldItems\") && Date.now() - new Date(item.pubdate) > this.getFeedProperty(feed, \"ignoreOlderThan\"))) {\n\t\t\t\t\t\tnewsItems.push(item);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tnewsItems.sort(function (a, b) {\n\t\t\tconst dateA = new Date(a.pubdate);\n\t\t\tconst dateB = new Date(b.pubdate);\n\t\t\treturn dateB - dateA;\n\t\t});\n\n\t\tif (this.config.maxNewsItems > 0) {\n\t\t\tnewsItems = newsItems.slice(0, this.config.maxNewsItems);\n\t\t}\n\n\t\tif (this.config.prohibitedWords.length > 0) {\n\t\t\tnewsItems = newsItems.filter(function (item) {\n\t\t\t\tfor (let word of this.config.prohibitedWords) {\n\t\t\t\t\tif (item.title.toLowerCase().indexOf(word.toLowerCase()) > -1) {\n\t\t\t\t\t\treturn false;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn true;\n\t\t\t}, this);\n\t\t}\n\t\tnewsItems.forEach((item) => {\n\t\t\t//Remove selected tags from the beginning of rss feed items (title or description)\n\t\t\tif (this.config.removeStartTags === \"title\" || this.config.removeStartTags === \"both\") {\n\t\t\t\tfor (let startTag of this.config.startTags) {\n\t\t\t\t\tif (item.title.slice(0, startTag.length) === startTag) {\n\t\t\t\t\t\titem.title = item.title.slice(startTag.length, item.title.length);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (this.config.removeStartTags === \"description\" || this.config.removeStartTags === \"both\") {\n\t\t\t\tif (this.isShowingDescription) {\n\t\t\t\t\tfor (let startTag of this.config.startTags) {\n\t\t\t\t\t\tif (item.description.slice(0, startTag.length) === startTag) {\n\t\t\t\t\t\t\titem.description = item.description.slice(startTag.length, item.description.length);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t//Remove selected tags from the end of rss feed items (title or description)\n\t\t\tif (this.config.removeEndTags) {\n\t\t\t\tfor (let endTag of this.config.endTags) {\n\t\t\t\t\tif (item.title.slice(-endTag.length) === endTag) {\n\t\t\t\t\t\titem.title = item.title.slice(0, -endTag.length);\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif (this.isShowingDescription) {\n\t\t\t\t\tfor (let endTag of this.config.endTags) {\n\t\t\t\t\t\tif (item.description.slice(-endTag.length) === endTag) {\n\t\t\t\t\t\t\titem.description = item.description.slice(0, -endTag.length);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\n\t\t// get updated news items and broadcast them\n\t\tconst updatedItems = [];\n\t\tnewsItems.forEach((value) => {\n\t\t\tif (this.newsItems.findIndex((value1) => value1 === value) === -1) {\n\t\t\t\t// Add item to updated items list\n\t\t\t\tupdatedItems.push(value);\n\t\t\t}\n\t\t});\n\n\t\t// check if updated items exist, if so and if we should broadcast these updates, then lets do so\n\t\tif (this.config.broadcastNewsUpdates && updatedItems.length > 0) {\n\t\t\tthis.sendNotification(\"NEWS_FEED_UPDATE\", { items: updatedItems });\n\t\t}\n\n\t\tthis.newsItems = newsItems;\n\t},\n\n\t/**\n\t * Check if this module is configured to show this feed.\n\t * @param {string} feedUrl Url of the feed to check.\n\t * @returns {boolean} True if it is subscribed, false otherwise\n\t */\n\tsubscribedToFeed (feedUrl) {\n\t\tfor (let feed of this.config.feeds) {\n\t\t\tif (feed.url === feedUrl) {\n\t\t\t\treturn true;\n\t\t\t}\n\t\t}\n\t\treturn false;\n\t},\n\n\t/**\n\t * Returns title for the specific feed url.\n\t * @param {string} feedUrl Url of the feed\n\t * @returns {string} The title of the feed\n\t */\n\ttitleForFeed (feedUrl) {\n\t\tfor (let feed of this.config.feeds) {\n\t\t\tif (feed.url === feedUrl) {\n\t\t\t\treturn feed.title || \"\";\n\t\t\t}\n\t\t}\n\t\treturn \"\";\n\t},\n\n\t/**\n\t * Schedule visual update.\n\t */\n\tscheduleUpdateInterval () {\n\t\tthis.updateDom(this.config.animationSpeed);\n\n\t\t// Broadcast NewsFeed if needed\n\t\tif (this.config.broadcastNewsFeeds) {\n\t\t\tthis.sendNotification(\"NEWS_FEED\", { items: this.newsItems });\n\t\t}\n\n\t\t// #2638 Clear timer if it already exists\n\t\tif (this.timer) clearInterval(this.timer);\n\n\t\tthis.timer = setInterval(() => {\n\n\t\t\t/*\n\t\t\t * When animations are enabled, don't update the DOM unless we are actually changing what we are displaying.\n\t\t\t * (Animating from a headline to itself is unsightly.)\n\t\t\t * Cases:\n\t\t\t *\n\t\t\t * Number of items | Number of items | Display\n\t\t\t * at last update  |   right now     | Behaviour\n\t\t\t * ----------------------------------------------------\n\t\t\t *     0           |      0          | do not update\n\t\t\t *     0           |     >0          | update\n\t\t\t *     1           |   0 or >1       | update\n\t\t\t *     1           |      1          | update only if item details (hash value) changed\n\t\t\t *    >1           |    any          | update\n\t\t\t *\n\t\t\t * (N.B. We set activeItemCount and activeItemHash in getTemplateData().)\n\t\t\t */\n\t\t\tif (this.newsItems.length > 1 || this.newsItems.length !== this.activeItemCount || this.activeItemHash !== this.newsItems[0]?.hash) {\n\t\t\t\tthis.activeItem++; // this is OK if newsItems.Length==1; getTemplateData will wrap it around\n\t\t\t\tthis.updateDom(this.config.animationSpeed);\n\t\t\t}\n\n\t\t\t// Broadcast NewsFeed if needed\n\t\t\tif (this.config.broadcastNewsFeeds) {\n\t\t\t\tthis.sendNotification(\"NEWS_FEED\", { items: this.newsItems });\n\t\t\t}\n\t\t}, this.config.updateInterval);\n\t},\n\n\tresetDescrOrFullArticleAndTimer () {\n\t\tthis.isShowingDescription = this.config.showDescription;\n\t\tthis.config.showFullArticle = false;\n\t\tthis.scrollPosition = 0;\n\t\t// reset bottom bar alignment\n\t\tdocument.getElementsByClassName(\"region bottom bar\")[0].classList.remove(\"newsfeed-fullarticle\");\n\t\tif (!this.timer) {\n\t\t\tthis.scheduleUpdateInterval();\n\t\t}\n\t},\n\n\tnotificationReceived (notification, payload, sender) {\n\t\tconst before = this.activeItem;\n\t\tif (notification === \"MODULE_DOM_CREATED\" && this.config.hideLoading) {\n\t\t\tthis.hide();\n\t\t} else if (notification === \"ARTICLE_NEXT\") {\n\t\t\tthis.activeItem++;\n\t\t\tif (this.activeItem >= this.newsItems.length) {\n\t\t\t\tthis.activeItem = 0;\n\t\t\t}\n\t\t\tthis.resetDescrOrFullArticleAndTimer();\n\t\t\tLog.debug(`[newsfeed] going from article #${before} to #${this.activeItem} (of ${this.newsItems.length})`);\n\t\t\tthis.updateDom(100);\n\t\t} else if (notification === \"ARTICLE_PREVIOUS\") {\n\t\t\tthis.activeItem--;\n\t\t\tif (this.activeItem < 0) {\n\t\t\t\tthis.activeItem = this.newsItems.length - 1;\n\t\t\t}\n\t\t\tthis.resetDescrOrFullArticleAndTimer();\n\t\t\tLog.debug(`[newsfeed] going from article #${before} to #${this.activeItem} (of ${this.newsItems.length})`);\n\t\t\tthis.updateDom(100);\n\t\t}\n\t\t// if \"more details\" is received the first time: show article summary, on second time show full article\n\t\telse if (notification === \"ARTICLE_MORE_DETAILS\") {\n\t\t\t// full article is already showing, so scrolling down\n\t\t\tif (this.config.showFullArticle === true) {\n\t\t\t\tthis.scrollPosition += this.config.scrollLength;\n\t\t\t\twindow.scrollTo(0, this.scrollPosition);\n\t\t\t\tLog.debug(\"[newsfeed] scrolling down\");\n\t\t\t\tLog.debug(`[newsfeed] ARTICLE_MORE_DETAILS, scroll position: ${this.config.scrollLength}`);\n\t\t\t} else {\n\t\t\t\tthis.showFullArticle();\n\t\t\t}\n\t\t} else if (notification === \"ARTICLE_SCROLL_UP\") {\n\t\t\tif (this.config.showFullArticle === true) {\n\t\t\t\tthis.scrollPosition -= this.config.scrollLength;\n\t\t\t\twindow.scrollTo(0, this.scrollPosition);\n\t\t\t\tLog.debug(\"[newsfeed] scrolling up\");\n\t\t\t\tLog.debug(`[newsfeed] ARTICLE_SCROLL_UP, scroll position: ${this.config.scrollLength}`);\n\t\t\t}\n\t\t} else if (notification === \"ARTICLE_LESS_DETAILS\") {\n\t\t\tthis.resetDescrOrFullArticleAndTimer();\n\t\t\tLog.debug(\"[newsfeed] showing only article titles again\");\n\t\t\tthis.updateDom(100);\n\t\t} else if (notification === \"ARTICLE_TOGGLE_FULL\") {\n\t\t\tif (this.config.showFullArticle) {\n\t\t\t\tthis.activeItem++;\n\t\t\t\tthis.resetDescrOrFullArticleAndTimer();\n\t\t\t} else {\n\t\t\t\tthis.showFullArticle();\n\t\t\t}\n\t\t} else if (notification === \"ARTICLE_INFO_REQUEST\") {\n\t\t\tthis.sendNotification(\"ARTICLE_INFO_RESPONSE\", {\n\t\t\t\ttitle: this.newsItems[this.activeItem].title,\n\t\t\t\tsource: this.newsItems[this.activeItem].sourceTitle,\n\t\t\t\tdate: this.newsItems[this.activeItem].pubdate,\n\t\t\t\tdesc: this.newsItems[this.activeItem].description,\n\t\t\t\turl: this.getActiveItemURL()\n\t\t\t});\n\t\t}\n\t},\n\n\tshowFullArticle () {\n\t\tthis.isShowingDescription = !this.isShowingDescription;\n\t\tthis.config.showFullArticle = !this.isShowingDescription;\n\t\t// make bottom bar align to top to allow scrolling\n\t\tif (this.config.showFullArticle === true) {\n\t\t\tdocument.getElementsByClassName(\"region bottom bar\")[0].classList.add(\"newsfeed-fullarticle\");\n\t\t}\n\t\tclearInterval(this.timer);\n\t\tthis.timer = null;\n\t\tLog.debug(`[newsfeed] showing ${this.isShowingDescription ? \"article description\" : \"full article\"}`);\n\t\tthis.updateDom(100);\n\t}\n});\n"
  },
  {
    "path": "modules/default/newsfeed/newsfeed.njk",
    "content": "{% macro escapeText(text, dangerouslyDisableAutoEscaping=false) %}\n  {% if dangerouslyDisableAutoEscaping -%}\n    {{ text | safe }}\n  {%- else -%}\n    {{ text }}\n  {%- endif %}\n{% endmacro %}\n{% macro escapeTitle(title, url, dangerouslyDisableAutoEscaping=false, showTitleAsUrl=false) %}\n  {% if dangerouslyDisableAutoEscaping %}\n    {% if showTitleAsUrl %}\n      <a\n        href=\"{{ url }}\"\n        style=\"text-decoration:none;\n                      color:#ffffff\"\n        target=\"_blank\"\n        >{{ title | safe }}</a\n      >\n    {% else %}\n      {{ title | safe }}\n    {% endif %}\n  {% else %}\n    {% if showTitleAsUrl %}\n      <a\n        href=\"{{ url }}\"\n        style=\"text-decoration:none;\n                      color:#ffffff\"\n        target=\"_blank\"\n        >{{ title }}</a\n      >\n    {% else %}\n      {{ title }}\n    {% endif %}\n  {% endif %}\n{% endmacro %}\n{% if loaded %}\n  {% if config.showAsList %}\n    <ul class=\"newsfeed-list\">\n      {% for item in items %}\n        <li>\n          {% if (config.showSourceTitle and item.sourceTitle) or config.showPublishDate %}\n            <div class=\"newsfeed-source light small dimmed\">\n              {% if item.sourceTitle and config.showSourceTitle %}\n                {{ item.sourceTitle }}{% if config.showPublishDate %},&nbsp;{% else %}:{% endif %}\n              {% endif %}\n              {% if config.showPublishDate %}{{ item.publishDate }}:{% endif %}\n            </div>\n          {% endif %}\n          <div class=\"newsfeed-title bright medium light{{ ' no-wrap' if not config.wrapTitle }}\">{{ escapeTitle(item.title, item.url, config.dangerouslyDisableAutoEscaping, config.showTitleAsUrl) }}</div>\n          {% if config.showDescription %}\n            <div class=\"newsfeed-desc small light{{ ' no-wrap' if not config.wrapDescription }}\">\n              {% if config.truncDescription %}\n                {{ escapeText(item.description | truncate(config.lengthDescription) , config.dangerouslyDisableAutoEscaping) }}\n              {% else %}\n                {{ escapeText(item.description, config.dangerouslyDisableAutoEscaping) }}\n              {% endif %}\n            </div>\n          {% endif %}\n        </li>\n      {% endfor %}\n    </ul>\n  {% else %}\n    <div>\n      {% if (config.showSourceTitle and sourceTitle) or config.showPublishDate %}\n        <div class=\"newsfeed-source light small dimmed\">\n          {% if sourceTitle and config.showSourceTitle %}\n            {{ escapeText(sourceTitle, config.dangerouslyDisableAutoEscaping) }}{% if config.showPublishDate %},&nbsp;{% else %}:{% endif %}\n          {% endif %}\n          {% if config.showPublishDate %}{{ publishDate }}:{% endif %}\n        </div>\n      {% endif %}\n      <div class=\"newsfeed-title bright medium light{{ ' no-wrap' if not config.wrapTitle }}\">{{ escapeTitle(title, url, config.dangerouslyDisableAutoEscaping, config.showTitleAsUrl) }}</div>\n      {% if config.showDescription %}\n        <div class=\"newsfeed-desc small light{{ ' no-wrap' if not config.wrapDescription }}\">\n          {% if config.truncDescription %}\n            {{ escapeText(description | truncate(config.lengthDescription) , config.dangerouslyDisableAutoEscaping) }}\n          {% else %}\n            {{ escapeText(description, config.dangerouslyDisableAutoEscaping) }}\n          {% endif %}\n        </div>\n      {% endif %}\n    </div>\n  {% endif %}\n  {% elseif empty %}\n  <div class=\"small dimmed\">{{ \"NEWSFEED_NO_ITEMS\" | translate | safe }}</div>\n  {% elseif error %}\n  <div class=\"small dimmed\">{{ \"MODULE_CONFIG_ERROR\" | translate({MODULE_NAME: \"Newsfeed\", ERROR: error}) | safe }}</div>\n{% else %}\n  <div class=\"small dimmed\">{{ \"LOADING\" | translate | safe }}</div>\n{% endif %}\n"
  },
  {
    "path": "modules/default/newsfeed/newsfeedfetcher.js",
    "content": "const crypto = require(\"node:crypto\");\nconst stream = require(\"node:stream\");\nconst FeedMe = require(\"feedme\");\nconst iconv = require(\"iconv-lite\");\nconst { htmlToText } = require(\"html-to-text\");\nconst Log = require(\"logger\");\nconst NodeHelper = require(\"node_helper\");\nconst { getUserAgent } = require(\"#server_functions\");\nconst { scheduleTimer } = require(\"#module_functions\");\n\n/**\n * Responsible for requesting an update on the set interval and broadcasting the data.\n * @param {string} url URL of the news feed.\n * @param {number} reloadInterval Reload interval in milliseconds.\n * @param {string} encoding Encoding of the feed.\n * @param {boolean} logFeedWarnings If true log warnings when there is an error parsing a news article.\n * @param {boolean} useCorsProxy If true cors proxy is used for article url's.\n * @class\n */\nconst NewsfeedFetcher = function (url, reloadInterval, encoding, logFeedWarnings, useCorsProxy) {\n\tlet reloadTimer = null;\n\tlet items = [];\n\tlet reloadIntervalMS = reloadInterval;\n\n\tlet fetchFailedCallback = function () {};\n\tlet itemsReceivedCallback = function () {};\n\n\tif (reloadIntervalMS < 1000) {\n\t\treloadIntervalMS = 1000;\n\t}\n\n\t/* private methods */\n\n\t/**\n\t * Request the new items.\n\t */\n\tconst fetchNews = () => {\n\t\tclearTimeout(reloadTimer);\n\t\treloadTimer = null;\n\t\titems = [];\n\n\t\tconst parser = new FeedMe();\n\n\t\tparser.on(\"item\", (item) => {\n\t\t\tconst title = item.title;\n\t\t\tlet description = item.description || item.summary || item.content || \"\";\n\t\t\tconst pubdate = item.pubdate || item.published || item.updated || item[\"dc:date\"] || item[\"a10:updated\"];\n\t\t\tconst url = item.url || item.link || \"\";\n\n\t\t\tif (title && pubdate) {\n\t\t\t\t// Convert HTML entities, codes and tag\n\t\t\t\tdescription = htmlToText(description, {\n\t\t\t\t\twordwrap: false,\n\t\t\t\t\tselectors: [\n\t\t\t\t\t\t{ selector: \"a\", options: { ignoreHref: true, noAnchorUrl: true } },\n\t\t\t\t\t\t{ selector: \"br\", format: \"inlineSurround\", options: { prefix: \" \" } },\n\t\t\t\t\t\t{ selector: \"img\", format: \"skip\" }\n\t\t\t\t\t]\n\t\t\t\t});\n\n\t\t\t\titems.push({\n\t\t\t\t\ttitle: title,\n\t\t\t\t\tdescription: description,\n\t\t\t\t\tpubdate: pubdate,\n\t\t\t\t\turl: url,\n\t\t\t\t\tuseCorsProxy: useCorsProxy,\n\t\t\t\t\thash: crypto.createHash(\"sha256\").update(`${pubdate} :: ${title} :: ${url}`).digest(\"hex\")\n\t\t\t\t});\n\t\t\t} else if (logFeedWarnings) {\n\t\t\t\tLog.warn(\"Can't parse feed item:\", item);\n\t\t\t\tLog.warn(`Title: ${title}`);\n\t\t\t\tLog.warn(`Description: ${description}`);\n\t\t\t\tLog.warn(`Pubdate: ${pubdate}`);\n\t\t\t}\n\t\t});\n\n\t\tparser.on(\"end\", () => {\n\t\t\tthis.broadcastItems();\n\t\t});\n\n\t\tparser.on(\"error\", (error) => {\n\t\t\tfetchFailedCallback(this, error);\n\t\t\tscheduleTimer(reloadTimer, reloadIntervalMS, fetchNews);\n\t\t});\n\n\t\t//\"end\" event is not broadcast if the feed is empty but \"finish\" is used for both\n\t\tparser.on(\"finish\", () => {\n\t\t\tscheduleTimer(reloadTimer, reloadIntervalMS, fetchNews);\n\t\t});\n\n\t\tparser.on(\"ttl\", (minutes) => {\n\t\t\ttry {\n\t\t\t\t// 86400000 = 24 hours is mentioned in the docs as maximum value:\n\t\t\t\tconst ttlms = Math.min(minutes * 60 * 1000, 86400000);\n\t\t\t\tif (ttlms > reloadIntervalMS) {\n\t\t\t\t\treloadIntervalMS = ttlms;\n\t\t\t\t\tLog.info(`reloadInterval set to ttl=${reloadIntervalMS} for url ${url}`);\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tLog.warn(`feed ttl is no valid integer=${minutes} for url ${url}`);\n\t\t\t}\n\t\t});\n\n\t\tconst headers = {\n\t\t\t\"User-Agent\": getUserAgent(),\n\t\t\t\"Cache-Control\": \"max-age=0, no-cache, no-store, must-revalidate\",\n\t\t\tPragma: \"no-cache\"\n\t\t};\n\n\t\tfetch(url, { headers: headers })\n\t\t\t.then(NodeHelper.checkFetchStatus)\n\t\t\t.then((response) => {\n\t\t\t\tlet nodeStream;\n\t\t\t\tif (response.body instanceof stream.Readable) {\n\t\t\t\t\tnodeStream = response.body;\n\t\t\t\t} else {\n\t\t\t\t\tnodeStream = stream.Readable.fromWeb(response.body);\n\t\t\t\t}\n\t\t\t\tnodeStream.pipe(iconv.decodeStream(encoding)).pipe(parser);\n\t\t\t})\n\t\t\t.catch((error) => {\n\t\t\t\tfetchFailedCallback(this, error);\n\t\t\t\tscheduleTimer(reloadTimer, reloadIntervalMS, fetchNews);\n\t\t\t});\n\t};\n\n\t/* public methods */\n\n\t/**\n\t * Update the reload interval, but only if we need to increase the speed.\n\t * @param {number} interval Interval for the update in milliseconds.\n\t */\n\tthis.setReloadInterval = function (interval) {\n\t\tif (interval > 1000 && interval < reloadIntervalMS) {\n\t\t\treloadIntervalMS = interval;\n\t\t}\n\t};\n\n\t/**\n\t * Initiate fetchNews();\n\t */\n\tthis.startFetch = function () {\n\t\tfetchNews();\n\t};\n\n\t/**\n\t * Broadcast the existing items.\n\t */\n\tthis.broadcastItems = function () {\n\t\tif (items.length <= 0) {\n\t\t\tLog.info(\"No items to broadcast yet.\");\n\t\t\treturn;\n\t\t}\n\t\tLog.info(`Broadcasting ${items.length} items.`);\n\t\titemsReceivedCallback(this);\n\t};\n\n\tthis.onReceive = function (callback) {\n\t\titemsReceivedCallback = callback;\n\t};\n\n\tthis.onError = function (callback) {\n\t\tfetchFailedCallback = callback;\n\t};\n\n\tthis.url = function () {\n\t\treturn url;\n\t};\n\n\tthis.items = function () {\n\t\treturn items;\n\t};\n};\n\nmodule.exports = NewsfeedFetcher;\n"
  },
  {
    "path": "modules/default/newsfeed/node_helper.js",
    "content": "const NodeHelper = require(\"node_helper\");\nconst Log = require(\"logger\");\nconst NewsfeedFetcher = require(\"./newsfeedfetcher\");\n\nmodule.exports = NodeHelper.create({\n\t// Override start method.\n\tstart () {\n\t\tLog.log(`Starting node helper for: ${this.name}`);\n\t\tthis.fetchers = [];\n\t},\n\n\t// Override socketNotificationReceived received.\n\tsocketNotificationReceived (notification, payload) {\n\t\tif (notification === \"ADD_FEED\") {\n\t\t\tthis.createFetcher(payload.feed, payload.config);\n\t\t}\n\t},\n\n\t/**\n\t * Creates a fetcher for a new feed if it doesn't exist yet.\n\t * Otherwise it reuses the existing one.\n\t * @param {object} feed The feed object\n\t * @param {object} config The configuration object\n\t */\n\tcreateFetcher (feed, config) {\n\t\tconst url = feed.url || \"\";\n\t\tconst encoding = feed.encoding || \"UTF-8\";\n\t\tconst reloadInterval = feed.reloadInterval || config.reloadInterval || 5 * 60 * 1000;\n\t\tlet useCorsProxy = feed.useCorsProxy;\n\t\tif (useCorsProxy === undefined) useCorsProxy = true;\n\n\t\ttry {\n\t\t\tnew URL(url);\n\t\t} catch (error) {\n\t\t\tLog.error(\"Error: Malformed newsfeed url: \", url, error);\n\t\t\tthis.sendSocketNotification(\"NEWSFEED_ERROR\", { error_type: \"MODULE_ERROR_MALFORMED_URL\" });\n\t\t\treturn;\n\t\t}\n\n\t\tlet fetcher;\n\t\tif (typeof this.fetchers[url] === \"undefined\") {\n\t\t\tLog.log(`Create new newsfetcher for url: ${url} - Interval: ${reloadInterval}`);\n\t\t\tfetcher = new NewsfeedFetcher(url, reloadInterval, encoding, config.logFeedWarnings, useCorsProxy);\n\n\t\t\tfetcher.onReceive(() => {\n\t\t\t\tthis.broadcastFeeds();\n\t\t\t});\n\n\t\t\tfetcher.onError((fetcher, error) => {\n\t\t\t\tLog.error(\"Error: Could not fetch newsfeed: \", url, error);\n\t\t\t\tlet error_type = NodeHelper.checkFetchError(error);\n\t\t\t\tthis.sendSocketNotification(\"NEWSFEED_ERROR\", {\n\t\t\t\t\terror_type\n\t\t\t\t});\n\t\t\t});\n\n\t\t\tthis.fetchers[url] = fetcher;\n\t\t} else {\n\t\t\tLog.log(`Use existing newsfetcher for url: ${url}`);\n\t\t\tfetcher = this.fetchers[url];\n\t\t\tfetcher.setReloadInterval(reloadInterval);\n\t\t\tfetcher.broadcastItems();\n\t\t}\n\n\t\tfetcher.startFetch();\n\t},\n\n\t/**\n\t * Creates an object with all feed items of the different registered feeds,\n\t * and broadcasts these using sendSocketNotification.\n\t */\n\tbroadcastFeeds () {\n\t\tconst feeds = {};\n\t\tfor (let f in this.fetchers) {\n\t\t\tfeeds[f] = this.fetchers[f].items();\n\t\t}\n\t\tthis.sendSocketNotification(\"NEWS_ITEMS\", feeds);\n\t}\n});\n"
  },
  {
    "path": "modules/default/newsfeed/oldconfig.njk",
    "content": "<div class=\"small bright\">{{ \"MODULE_CONFIG_CHANGED\" | translate({MODULE_NAME: \"Newsfeed\"}) | safe }}</div>\n"
  },
  {
    "path": "modules/default/updatenotification/README.md",
    "content": "# Module: Update Notification\n\nThe `updatenotification` module is one of the default modules of the MagicMirror².\nThis will display a message whenever a new version of the MagicMirror² application is available.\n\nFor configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/updatenotification.html).\n"
  },
  {
    "path": "modules/default/updatenotification/git_helper.js",
    "content": "const util = require(\"node:util\");\nconst exec = util.promisify(require(\"node:child_process\").exec);\nconst fs = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst Log = require(\"logger\");\n\nclass GitHelper {\n\tconstructor () {\n\t\tthis.gitRepos = [];\n\t\tthis.gitResultList = [];\n\t}\n\n\tgetRefRegex (branch) {\n\t\treturn new RegExp(`s*([a-z,0-9]+[.][.][a-z,0-9]+)  ${branch}`, \"g\");\n\t}\n\n\tasync execShell (command) {\n\t\tconst { stdout = \"\", stderr = \"\" } = await exec(command);\n\n\t\treturn { stdout, stderr };\n\t}\n\n\tasync isGitRepo (moduleFolder) {\n\t\tconst { stderr } = await this.execShell(`cd ${moduleFolder} && git remote -v`);\n\n\t\tif (stderr) {\n\t\t\tLog.error(`Failed to fetch git data for ${moduleFolder}: ${stderr}`);\n\n\t\t\treturn false;\n\t\t}\n\n\t\treturn true;\n\t}\n\n\tasync add (moduleName) {\n\t\tlet moduleFolder = `${global.root_path}`;\n\n\t\tif (moduleName !== \"MagicMirror\") {\n\t\t\tmoduleFolder = `${moduleFolder}/modules/${moduleName}`;\n\t\t}\n\n\t\ttry {\n\t\t\tLog.info(`Checking git for module: ${moduleName}`);\n\t\t\t// Throws error if file doesn't exist\n\t\t\tfs.statSync(path.join(moduleFolder, \".git\"));\n\n\t\t\t// Fetch the git or throw error if no remotes\n\t\t\tconst isGitRepo = await this.isGitRepo(moduleFolder);\n\n\t\t\tif (isGitRepo) {\n\t\t\t\t// Folder has .git and has at least one git remote, watch this folder\n\t\t\t\tthis.gitRepos.push({ module: moduleName, folder: moduleFolder });\n\t\t\t}\n\t\t} catch (err) {\n\t\t\t// Error when directory .git doesn't exist or doesn't have any remotes\n\t\t\t// This module is not managed with git, skip\n\t\t}\n\t}\n\n\tasync getStatusInfo (repo) {\n\t\tlet gitInfo = {\n\t\t\tmodule: repo.module,\n\t\t\tbehind: 0, // commits behind\n\t\t\tcurrent: \"\", // branch name\n\t\t\thash: \"\", // current hash\n\t\t\ttracking: \"\", // remote branch\n\t\t\tisBehindInStatus: false\n\t\t};\n\n\t\tif (repo.module === \"MagicMirror\") {\n\t\t\t// the hash is only needed for the mm repo\n\t\t\tconst { stderr, stdout } = await this.execShell(`cd ${repo.folder} && git rev-parse HEAD`);\n\n\t\t\tif (stderr) {\n\t\t\t\tLog.error(`Failed to get current commit hash for ${repo.module}: ${stderr}`);\n\t\t\t}\n\n\t\t\tgitInfo.hash = stdout;\n\t\t}\n\n\t\tconst { stderr, stdout } = await this.execShell(`cd ${repo.folder} && git status -sb`);\n\n\t\tif (stderr) {\n\t\t\tLog.error(`Failed to get git status for ${repo.module}: ${stderr}`);\n\t\t\t// exit without git status info\n\t\t\treturn;\n\t\t}\n\n\t\t// only the first line of stdout is evaluated\n\t\tlet status = stdout.split(\"\\n\")[0];\n\t\t// examples for status:\n\t\t// ## develop...origin/develop\n\t\t// ## master...origin/master [behind 8]\n\t\t// ## master...origin/master [ahead 8, behind 1]\n\t\t// ## HEAD (no branch)\n\t\tstatus = status.match(/## (.*)\\.\\.\\.([^ ]*)(?: .*behind (\\d+))?/);\n\t\t// examples for status:\n\t\t// [ '## develop...origin/develop', 'develop', 'origin/develop' ]\n\t\t// [ '## master...origin/master [behind 8]', 'master', 'origin/master', '8' ]\n\t\t// [ '## master...origin/master [ahead 8, behind 1]', 'master', 'origin/master', '1' ]\n\t\tif (status) {\n\t\t\tgitInfo.current = status[1];\n\t\t\tgitInfo.tracking = status[2];\n\n\t\t\tif (status[3]) {\n\t\t\t\t// git fetch was already called before so `git status -sb` delivers already the behind number\n\t\t\t\tgitInfo.behind = parseInt(status[3]);\n\t\t\t\tgitInfo.isBehindInStatus = true;\n\t\t\t}\n\t\t}\n\n\t\treturn gitInfo;\n\t}\n\n\tasync getRepoInfo (repo) {\n\t\tconst gitInfo = await this.getStatusInfo(repo);\n\n\t\tif (!gitInfo || !gitInfo.current) {\n\t\t\treturn;\n\t\t}\n\n\t\tif (gitInfo.isBehindInStatus && (gitInfo.module !== \"MagicMirror\" || gitInfo.current !== \"master\")) {\n\t\t\treturn gitInfo;\n\t\t}\n\n\t\tconst { stderr } = await this.execShell(`cd ${repo.folder} && git fetch -n --dry-run`);\n\n\t\t// example output:\n\t\t// From https://github.com/MagicMirrorOrg/MagicMirror\n\t\t//    e40ddd4..06389e3  develop    -> origin/develop\n\t\t// here the result is in stderr (this is a git default, don't ask why ...)\n\t\tconst matches = stderr.match(this.getRefRegex(gitInfo.current));\n\n\t\t// this is the default if there was no match from \"git fetch -n --dry-run\".\n\t\t// Its a fallback because if there was a real \"git fetch\", the above \"git fetch -n --dry-run\" would deliver nothing.\n\t\tlet refDiff = `${gitInfo.current}..origin/${gitInfo.current}`;\n\t\tif (matches && matches[0]) {\n\t\t\trefDiff = matches[0];\n\t\t}\n\n\t\t// get behind with refs\n\t\ttry {\n\t\t\tconst { stdout } = await this.execShell(`cd ${repo.folder} && git rev-list --ancestry-path --count ${refDiff}`);\n\t\t\tgitInfo.behind = parseInt(stdout);\n\n\t\t\t// for MagicMirror-Repo and \"master\" branch avoid getting notified when no tag is in refDiff\n\t\t\t// so only releases are reported and we can change e.g. the README.md without sending notifications\n\t\t\tif (gitInfo.behind > 0 && gitInfo.module === \"MagicMirror\" && gitInfo.current === \"master\") {\n\t\t\t\tlet tagList = \"\";\n\t\t\t\ttry {\n\t\t\t\t\tconst { stdout } = await this.execShell(`cd ${repo.folder} && git ls-remote -q --tags --refs`);\n\t\t\t\t\ttagList = stdout.trim();\n\t\t\t\t} catch (err) {\n\t\t\t\t\tLog.error(`Failed to get tag list for ${repo.module}: ${err}`);\n\t\t\t\t}\n\t\t\t\t// check if tag is between commits and only report behind > 0 if so\n\t\t\t\ttry {\n\t\t\t\t\tconst { stdout } = await this.execShell(`cd ${repo.folder} && git rev-list --ancestry-path ${refDiff}`);\n\t\t\t\t\tlet cnt = 0;\n\t\t\t\t\tfor (const ref of stdout.trim().split(\"\\n\")) {\n\t\t\t\t\t\tif (tagList.includes(ref)) cnt++; // tag found\n\t\t\t\t\t}\n\t\t\t\t\tif (cnt === 0) gitInfo.behind = 0;\n\t\t\t\t} catch (err) {\n\t\t\t\t\tLog.error(`Failed to get git revisions for ${repo.module}: ${err}`);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn gitInfo;\n\t\t} catch (err) {\n\t\t\tLog.error(`Failed to get git revisions for ${repo.module}: ${err}`);\n\t\t}\n\t}\n\n\tasync getRepos () {\n\t\tthis.gitResultList = [];\n\n\t\tfor (const repo of this.gitRepos) {\n\t\t\ttry {\n\t\t\t\tconst gitInfo = await this.getRepoInfo(repo);\n\n\t\t\t\tif (gitInfo) {\n\t\t\t\t\tthis.gitResultList.push(gitInfo);\n\t\t\t\t}\n\t\t\t} catch (e) {\n\t\t\t\t// Only log errors in non-test environments to keep test output clean\n\t\t\t\tif (process.env.mmTestMode !== \"true\") {\n\t\t\t\t\tLog.error(`Failed to retrieve repo info for ${repo.module}: ${e}`);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn this.gitResultList;\n\t}\n\n\tasync checkUpdates () {\n\t\tvar updates = [];\n\n\t\tconst allRepos = await this.gitResultList.map((module) => {\n\t\t\treturn new Promise((resolve) => {\n\t\t\t\tif (module.behind > 0 && module.module !== \"MagicMirror\") {\n\t\t\t\t\tLog.info(`Update found for module: ${module.module}`);\n\t\t\t\t\tupdates.push(module);\n\t\t\t\t}\n\t\t\t\tresolve(module);\n\t\t\t});\n\t\t});\n\t\tawait Promise.all(allRepos);\n\n\t\treturn updates;\n\t}\n}\n\nmodule.exports = GitHelper;\n"
  },
  {
    "path": "modules/default/updatenotification/node_helper.js",
    "content": "const fs = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst NodeHelper = require(\"node_helper\");\n\nconst defaultModules = require(`${global.root_path}/modules/default/defaultmodules`);\nconst GitHelper = require(\"./git_helper\");\nconst UpdateHelper = require(\"./update_helper\");\n\nconst ONE_MINUTE = 60 * 1000;\n\nmodule.exports = NodeHelper.create({\n\tconfig: {},\n\n\tupdateTimer: null,\n\tupdateProcessStarted: false,\n\n\tgitHelper: new GitHelper(),\n\tupdateHelper: null,\n\n\tgetModules (modules) {\n\t\tif (this.config.useModulesFromConfig) {\n\t\t\treturn modules;\n\t\t} else {\n\t\t\t// get modules from modules-directory\n\t\t\tconst moduleDir = path.normalize(`${global.root_path}/modules`);\n\t\t\tconst getDirectories = (source) => {\n\t\t\t\treturn fs.readdirSync(source, { withFileTypes: true })\n\t\t\t\t\t.filter((dirent) => dirent.isDirectory() && dirent.name !== \"default\")\n\t\t\t\t\t.map((dirent) => dirent.name);\n\t\t\t};\n\t\t\treturn getDirectories(moduleDir);\n\t\t}\n\t},\n\n\tasync configureModules (modules) {\n\t\tfor (const moduleName of this.getModules(modules)) {\n\t\t\tif (!this.ignoreUpdateChecking(moduleName)) {\n\t\t\t\tawait this.gitHelper.add(moduleName);\n\t\t\t}\n\t\t}\n\n\t\tif (!this.ignoreUpdateChecking(\"MagicMirror\")) {\n\t\t\tawait this.gitHelper.add(\"MagicMirror\");\n\t\t}\n\t},\n\n\tasync socketNotificationReceived (notification, payload) {\n\t\tswitch (notification) {\n\t\t\tcase \"CONFIG\":\n\t\t\t\tthis.config = payload;\n\t\t\t\tthis.updateHelper = new UpdateHelper(this.config);\n\t\t\t\tawait this.updateHelper.check_PM2_Process();\n\t\t\t\tbreak;\n\t\t\tcase \"MODULES\":\n\t\t\t\t// if this is the 1st time thru the update check process\n\t\t\t\tif (!this.updateProcessStarted) {\n\t\t\t\t\tthis.updateProcessStarted = true;\n\t\t\t\t\tawait this.configureModules(payload);\n\t\t\t\t\tawait this.performFetch();\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\tcase \"SCAN_UPDATES\":\n\t\t\t\t// 1st time of check allows to force new scan\n\t\t\t\tif (this.updateProcessStarted) {\n\t\t\t\t\tclearTimeout(this.updateTimer);\n\t\t\t\t\tawait this.performFetch();\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t}\n\t},\n\n\tasync performFetch () {\n\t\tconst repos = await this.gitHelper.getRepos();\n\n\t\tfor (const repo of repos) {\n\t\t\tthis.sendSocketNotification(\"REPO_STATUS\", repo);\n\t\t}\n\n\t\tconst updates = await this.gitHelper.checkUpdates();\n\n\t\tif (this.config.sendUpdatesNotifications && updates.length) {\n\t\t\tthis.sendSocketNotification(\"UPDATES\", updates);\n\t\t}\n\n\t\tif (updates.length) {\n\t\t\tconst updateResult = await this.updateHelper.parse(updates);\n\t\t\tfor (const update of updateResult) {\n\t\t\t\tif (update.inProgress) {\n\t\t\t\t\tthis.sendSocketNotification(\"UPDATE_STATUS\", update);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tthis.scheduleNextFetch(this.config.updateInterval);\n\t},\n\n\tscheduleNextFetch (delay) {\n\t\tclearTimeout(this.updateTimer);\n\n\t\tthis.updateTimer = setTimeout(\n\t\t\t() => {\n\t\t\t\tthis.performFetch();\n\t\t\t},\n\t\t\tMath.max(delay, ONE_MINUTE)\n\t\t);\n\t},\n\n\tignoreUpdateChecking (moduleName) {\n\t\t// Should not check for updates for default modules\n\t\tif (defaultModules.includes(moduleName)) {\n\t\t\treturn true;\n\t\t}\n\n\t\t// Should not check for updates for ignored modules\n\t\tif (this.config.ignoreModules.includes(moduleName)) {\n\t\t\treturn true;\n\t\t}\n\n\t\t// The rest of the modules that passes should check for updates\n\t\treturn false;\n\t}\n});\n"
  },
  {
    "path": "modules/default/updatenotification/update_helper.js",
    "content": "const Exec = require(\"node:child_process\").exec;\nconst Spawn = require(\"node:child_process\").spawn;\nconst fs = require(\"node:fs\");\n\nconst Log = require(\"logger\");\n\n/*\n * class Updater\n * Allow to self updating 3rd party modules from command defined in config\n *\n * [constructor] read value in config:\n * updates: [ // array of modules update commands\n *\t\t{\n *\t\t\t<module name>: <update command>\n *\t\t},\n * \t{\n * \t\t...\n * \t}\n * ],\n * updateTimeout: 2 * 60 * 1000, // max update duration\n * updateAutorestart: false // autoRestart MM when update done ?\n *\n * [main command]: parse(<Array of modules>):\n * parse if module update is needed\n * --> Apply ONLY one update (first of the module list)\n * --> auto-restart MagicMirror or wait manual restart by user\n * return array with modules update state information for `updatenotification` module displayer information\n * [\n *\t\t{\n *\t\t\tname = <module-name>, // name of the module\n *\t\t\tupdateCommand = <update command>, // update command (if found)\n *\t\t\tinProgress = <boolean>, // an update if in progress for this module\n *\t\t\terror = <boolean>, // an error if detected when updating\n *\t\t\tupdated = <boolean>, // updated successfully\n *\t\t\tneedRestart = <boolean> // manual restart of MagicMirror is required by user\n *\t\t},\n *\t\t{\n *\t\t\t...\n * \t\t}\n * ]\n */\n\nclass Updater {\n\tconstructor (config) {\n\t\tthis.updates = config.updates;\n\t\tthis.timeout = config.updateTimeout;\n\t\tthis.autoRestart = config.updateAutorestart;\n\t\tthis.moduleList = {};\n\t\tthis.updating = false;\n\t\tthis.usePM2 = false; // don't use pm2 by default\n\t\tthis.PM2Id = null; // pm2 process number\n\t\tthis.version = global.version;\n\t\tthis.root_path = global.root_path;\n\t\tLog.info(\"Updater Class Loaded!\");\n\t}\n\n\t// [main command] parse if module update is needed\n\tasync parse (modules) {\n\t\tvar parser = modules.map(async (module) => {\n\t\t\tif (this.moduleList[module.module] === undefined) {\n\t\t\t\tthis.moduleList[module.module] = {};\n\t\t\t\tthis.moduleList[module.module].name = module.module;\n\t\t\t\tthis.moduleList[module.module].updateCommand = await this.applyCommand(module.module);\n\t\t\t\tthis.moduleList[module.module].inProgress = false;\n\t\t\t\tthis.moduleList[module.module].error = null;\n\t\t\t\tthis.moduleList[module.module].updated = false;\n\t\t\t\tthis.moduleList[module.module].needRestart = false;\n\t\t\t}\n\t\t\tif (!this.moduleList[module.module].inProgress) {\n\t\t\t\tif (!this.updating) {\n\t\t\t\t\tif (!this.moduleList[module.module].updateCommand) {\n\t\t\t\t\t\tthis.updating = false;\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis.updating = true;\n\t\t\t\t\t\tthis.moduleList[module.module].inProgress = true;\n\t\t\t\t\t\tObject.assign(this.moduleList[module.module], await this.updateProcess(this.moduleList[module.module]));\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\n\t\tawait Promise.all(parser);\n\t\tlet updater = Object.values(this.moduleList);\n\t\tLog.debug(\"Update Result:\", updater);\n\t\treturn updater;\n\t}\n\n\t/*\n\t *  module updater with his proper command\n\t *  return object as result\n\t * {\n\t * \terror: <boolean>, // if error detected\n\t * \tupdated: <boolean>, // if updated successfully\n\t * \tneedRestart: <boolean> // if magicmirror restart required\n\t * };\n\t */\n\tupdateProcess (module) {\n\t\tlet Result = {\n\t\t\terror: false,\n\t\t\tupdated: false,\n\t\t\tneedRestart: false\n\t\t};\n\t\tlet Command = null;\n\t\tconst Path = `${this.root_path}/modules/`;\n\t\tconst modulePath = Path + module.name;\n\n\t\tif (module.updateCommand) {\n\t\t\tCommand = module.updateCommand;\n\t\t} else {\n\t\t\tLog.warn(`Update of ${module.name} is not supported.`);\n\t\t\treturn Result;\n\t\t}\n\t\tLog.info(`Updating ${module.name}...`);\n\n\t\treturn new Promise((resolve) => {\n\t\t\tExec(Command, { cwd: modulePath, timeout: this.timeout }, (error, stdout, stderr) => {\n\t\t\t\tif (error) {\n\t\t\t\t\tLog.error(`exec error: ${error}`);\n\t\t\t\t\tResult.error = true;\n\t\t\t\t} else {\n\t\t\t\t\tLog.info(`Update logs of ${module.name}: ${stdout}`);\n\t\t\t\t\tResult.updated = true;\n\t\t\t\t\tif (this.autoRestart) {\n\t\t\t\t\t\tLog.info(\"Update done\");\n\t\t\t\t\t\tsetTimeout(() => this.restart(), 3000);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tLog.info(\"Update done, don't forget to restart MagicMirror!\");\n\t\t\t\t\t\tResult.needRestart = true;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tresolve(Result);\n\t\t\t});\n\t\t});\n\t}\n\n\t// restart rules (pm2 or node --run start)\n\trestart () {\n\t\tif (this.usePM2) this.pm2Restart();\n\t\telse this.nodeRestart();\n\t}\n\n\t// restart MagicMirror with \"pm2\": use PM2Id for restart it\n\tpm2Restart () {\n\t\tLog.info(\"[PM2] restarting MagicMirror...\");\n\t\tconst pm2 = require(\"pm2\");\n\t\tpm2.restart(this.PM2Id, (err, proc) => {\n\t\t\tif (err) {\n\t\t\t\tLog.error(\"[PM2] restart Error\", err);\n\t\t\t}\n\t\t});\n\t}\n\n\t// restart MagicMirror with \"node --run start\"\n\tnodeRestart () {\n\t\tLog.info(\"Restarting MagicMirror...\");\n\t\tconst out = process.stdout;\n\t\tconst err = process.stderr;\n\t\tconst subprocess = Spawn(\"node --run start\", { cwd: this.root_path, shell: true, detached: true, stdio: [\"ignore\", out, err] });\n\t\tsubprocess.unref(); // detach the newly launched process from the master process\n\t\tprocess.exit();\n\t}\n\n\t// Check using pm2\n\tcheck_PM2_Process () {\n\t\tLog.info(\"Checking PM2 using...\");\n\t\treturn new Promise((resolve) => {\n\t\t\tif (fs.existsSync(\"/.dockerenv\")) {\n\t\t\t\tLog.info(\"[PM2] Running in docker container, not using PM2 ...\");\n\t\t\t\tresolve(false);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (process.env.unique_id === undefined) {\n\t\t\t\tLog.info(\"[PM2] You are not using pm2\");\n\t\t\t\tresolve(false);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tLog.debug(`[PM2] Search for pm2 id: ${process.env.pm_id} -- name: ${process.env.name} -- unique_id: ${process.env.unique_id}`);\n\n\t\t\tconst pm2 = require(\"pm2\");\n\t\t\tpm2.connect((err) => {\n\t\t\t\tif (err) {\n\t\t\t\t\tLog.error(\"[PM2]\", err);\n\t\t\t\t\tresolve(false);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tpm2.list((err, list) => {\n\t\t\t\t\tif (err) {\n\t\t\t\t\t\tLog.error(\"[PM2] Can't get process List!\");\n\t\t\t\t\t\tresolve(false);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tlist.forEach((pm) => {\n\t\t\t\t\t\tLog.debug(`[PM2] found pm2 process id: ${pm.pm_id} -- name: ${pm.name} -- unique_id: ${pm.pm2_env.unique_id}`);\n\t\t\t\t\t\tif (pm.pm2_env.status === \"online\" && process.env.name === pm.name && +process.env.pm_id === +pm.pm_id && process.env.unique_id === pm.pm2_env.unique_id) {\n\t\t\t\t\t\t\tthis.PM2Id = pm.pm_id;\n\t\t\t\t\t\t\tthis.usePM2 = true;\n\t\t\t\t\t\t\tLog.info(`[PM2] You are using pm2 with id: ${this.PM2Id} (${pm.name})`);\n\t\t\t\t\t\t\tresolve(true);\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tLog.debug(`[PM2] pm2 process id: ${pm.pm_id} don't match...`);\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t\tpm2.disconnect();\n\t\t\t\t\tif (!this.usePM2) {\n\t\t\t\t\t\tLog.info(\"[PM2] You are not using pm2\");\n\t\t\t\t\t\tresolve(false);\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t});\n\t\t});\n\t}\n\n\t// check if module is MagicMirror\n\tisMagicMirror (module) {\n\t\tif (module === \"MagicMirror\") return true;\n\t\treturn false;\n\t}\n\n\t// search update module command\n\tapplyCommand (module) {\n\t\tif (this.isMagicMirror(module.module) || !this.updates.length) return null;\n\t\tlet command = null;\n\t\tthis.updates.forEach((updater) => {\n\t\t\tif (updater[module]) command = updater[module];\n\t\t});\n\t\treturn command;\n\t}\n}\n\nmodule.exports = Updater;\n"
  },
  {
    "path": "modules/default/updatenotification/updatenotification.css",
    "content": ".module.updatenotification a.difflink {\n  text-decoration: none;\n}\n"
  },
  {
    "path": "modules/default/updatenotification/updatenotification.js",
    "content": "Module.register(\"updatenotification\", {\n\tdefaults: {\n\t\tupdateInterval: 10 * 60 * 1000, // every 10 minutes\n\t\trefreshInterval: 24 * 60 * 60 * 1000, // one day\n\t\tignoreModules: [],\n\t\tsendUpdatesNotifications: false,\n\t\tupdates: [],\n\t\tupdateTimeout: 2 * 60 * 1000, // max update duration\n\t\tupdateAutorestart: false, // autoRestart MM when update done ?\n\t\tuseModulesFromConfig: true // if `false` iterate over modules directory\n\t},\n\n\tsuspended: false,\n\tmoduleList: {},\n\tneedRestart: false,\n\tupdates: [],\n\n\tstart () {\n\t\tLog.info(`Starting module: ${this.name}`);\n\t\tthis.addFilters();\n\t\tsetInterval(() => {\n\t\t\tthis.moduleList = {};\n\t\t\tthis.updateDom(2);\n\t\t}, this.config.refreshInterval);\n\t},\n\n\tsuspend () {\n\t\tthis.suspended = true;\n\t},\n\n\tresume () {\n\t\tthis.suspended = false;\n\t\tthis.updateDom(2);\n\t},\n\n\tnotificationReceived (notification) {\n\t\tswitch (notification) {\n\t\t\tcase \"DOM_OBJECTS_CREATED\":\n\t\t\t\tthis.sendSocketNotification(\"CONFIG\", this.config);\n\t\t\t\tthis.sendSocketNotification(\"MODULES\", Object.keys(Module.definitions));\n\t\t\t\tbreak;\n\t\t\tcase \"SCAN_UPDATES\":\n\t\t\t\tthis.sendSocketNotification(\"SCAN_UPDATES\");\n\t\t\t\tbreak;\n\t\t}\n\t},\n\n\tsocketNotificationReceived (notification, payload) {\n\t\tswitch (notification) {\n\t\t\tcase \"REPO_STATUS\":\n\t\t\t\tthis.updateUI(payload);\n\t\t\t\tbreak;\n\t\t\tcase \"UPDATES\":\n\t\t\t\tthis.sendNotification(\"UPDATES\", payload);\n\t\t\t\tbreak;\n\t\t\tcase \"UPDATE_STATUS\":\n\t\t\t\tthis.updatesNotifier(payload);\n\t\t\t\tbreak;\n\t\t}\n\t},\n\n\tgetStyles () {\n\t\treturn [`${this.name}.css`];\n\t},\n\n\tgetTemplate () {\n\t\treturn `${this.name}.njk`;\n\t},\n\n\tgetTemplateData () {\n\t\treturn { moduleList: this.moduleList, updatesList: this.updates, suspended: this.suspended, needRestart: this.needRestart };\n\t},\n\n\tupdateUI (payload) {\n\t\tif (payload && payload.behind > 0) {\n\t\t\t// if we haven't seen info for this module\n\t\t\tif (this.moduleList[payload.module] === undefined) {\n\t\t\t\t// save it\n\t\t\t\tthis.moduleList[payload.module] = payload;\n\t\t\t\tthis.updateDom(2);\n\t\t\t}\n\t\t} else if (payload && payload.behind === 0) {\n\t\t\t// if the module WAS in the list, but shouldn't be\n\t\t\tif (this.moduleList[payload.module] !== undefined) {\n\t\t\t\t// remove it\n\t\t\t\tdelete this.moduleList[payload.module];\n\t\t\t\tthis.updateDom(2);\n\t\t\t}\n\t\t}\n\t},\n\n\taddFilters () {\n\t\tthis.nunjucksEnvironment().addFilter(\"diffLink\", (text, status) => {\n\t\t\tif (status.module !== \"MagicMirror\") {\n\t\t\t\treturn text;\n\t\t\t}\n\n\t\t\tconst localRef = status.hash;\n\t\t\tconst remoteRef = status.tracking.replace(/.*\\//, \"\");\n\t\t\treturn `<a href=\"https://github.com/MagicMirrorOrg/MagicMirror/compare/${localRef}...${remoteRef}\" class=\"xsmall dimmed difflink\" target=\"_blank\">${text}</a>`;\n\t\t});\n\t},\n\n\tupdatesNotifier (payload, done = true) {\n\t\tif (this.updates[payload.name] === undefined) {\n\t\t\tthis.updates[payload.name] = {\n\t\t\t\tname: payload.name,\n\t\t\t\tdone: done\n\t\t\t};\n\n\t\t\tif (payload.error) {\n\t\t\t\tthis.sendSocketNotification(\"UPDATE_ERROR\", payload.name);\n\t\t\t\tthis.updates[payload.name].done = false;\n\t\t\t} else {\n\t\t\t\tif (payload.updated) {\n\t\t\t\t\tdelete this.moduleList[payload.name];\n\t\t\t\t\tthis.updates[payload.name].done = true;\n\t\t\t\t}\n\t\t\t\tif (payload.needRestart) {\n\t\t\t\t\tthis.needRestart = true;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tthis.updateDom(2);\n\t\t}\n\t}\n});\n"
  },
  {
    "path": "modules/default/updatenotification/updatenotification.njk",
    "content": "{% if not suspended %}\n  {% if needRestart %}\n    <div class=\"small bright\">\n      <i class=\"fas fa-rotate\"></i>\n      <span>\n        {% set restartTextLabel = \"UPDATE_NOTIFICATION_NEED-RESTART\" %}\n        {{ restartTextLabel | translate() | safe }}\n      </span>\n    </div>\n  {% endif %}\n  {% for name, status in moduleList %}\n    <div class=\"small bright\">\n      <i class=\"fas fa-exclamation-circle\"></i>\n      <span>\n        {% set mainTextLabel = \"UPDATE_NOTIFICATION\" if name === \"MagicMirror\" else \"UPDATE_NOTIFICATION_MODULE\" %}\n        {{ mainTextLabel | translate({MODULE_NAME: name}) }}\n      </span>\n    </div>\n    <div class=\"xsmall dimmed\">\n      {% set subTextLabel = \"UPDATE_INFO_SINGLE\" if status.behind === 1 else \"UPDATE_INFO_MULTIPLE\" %}\n      {{ subTextLabel | translate({COMMIT_COUNT: status.behind, BRANCH_NAME: status.current}) | diffLink(status) | safe }}\n    </div>\n  {% endfor %}\n  {% for name, status in updatesList %}\n    <div class=\"small bright\">\n      {% if status.done %}\n        <i class=\"fas fa-check\" style=\"color: LightGreen;\"></i>\n        <span>\n          {% set updateTextLabel = \"UPDATE_NOTIFICATION_DONE\" %}\n          {{ updateTextLabel | translate({MODULE_NAME: name}) | safe }}\n        </span>\n      {% else %}\n        <i class=\"fas fa-xmark\" style=\"color: red;\"></i>\n        <span>\n          {% set updateTextLabel = \"UPDATE_NOTIFICATION_ERROR\" %}\n          {{ updateTextLabel | translate({MODULE_NAME: name}) | safe }}\n        </span>\n      {% endif %}\n    </div>\n  {% endfor %}\n{% endif %}\n"
  },
  {
    "path": "modules/default/utils.js",
    "content": "/**\n * A function to make HTTP requests via the server to avoid CORS-errors.\n * @param {string} url the url to fetch from\n * @param {string} type what content-type to expect in the response, can be \"json\" or \"xml\"\n * @param {boolean} useCorsProxy A flag to indicate\n * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send\n * @param {Array.<string>} expectedResponseHeaders the expected HTTP headers to receive\n * @param {string} basePath The base path, default is \"/\"\n * @returns {Promise} resolved when the fetch is done. The response headers is placed in a headers-property (provided the response does not already contain a headers-property).\n */\nasync function performWebRequest (url, type = \"json\", useCorsProxy = false, requestHeaders = undefined, expectedResponseHeaders = undefined, basePath = \"/\") {\n\tconst request = {};\n\tlet requestUrl;\n\tif (useCorsProxy) {\n\t\trequestUrl = getCorsUrl(url, requestHeaders, expectedResponseHeaders, basePath);\n\t} else {\n\t\trequestUrl = url;\n\t\trequest.headers = getHeadersToSend(requestHeaders);\n\t}\n\n\ttry {\n\t\tconst response = await fetch(requestUrl, request);\n\t\tif (response.ok) {\n\t\t\tconst data = await response.text();\n\n\t\t\tif (type === \"xml\") {\n\t\t\t\treturn new DOMParser().parseFromString(data, \"text/html\");\n\t\t\t} else {\n\t\t\t\tif (!data || !data.length > 0) return undefined;\n\n\t\t\t\tconst dataResponse = JSON.parse(data);\n\t\t\t\tif (!dataResponse.headers) {\n\t\t\t\t\tdataResponse.headers = getHeadersFromResponse(expectedResponseHeaders, response);\n\t\t\t\t}\n\t\t\t\treturn dataResponse;\n\t\t\t}\n\t\t} else {\n\t\t\tthrow new Error(`Response status: ${response.status}`);\n\t\t}\n\t} catch (error) {\n\t\tLog.error(`Error fetching data from ${url}: ${error}`);\n\t\treturn undefined;\n\t}\n}\n\n/**\n * Gets a URL that will be used when calling the CORS-method on the server.\n * @param {string} url the url to fetch from\n * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send\n * @param {Array.<string>} expectedResponseHeaders the expected HTTP headers to receive\n * @param {string} basePath The base path, default is \"/\"\n * @returns {string} to be used as URL when calling CORS-method on server.\n */\nconst getCorsUrl = function (url, requestHeaders, expectedResponseHeaders, basePath = \"/\") {\n\tif (!url || url.length < 1) {\n\t\tthrow new Error(`Invalid URL: ${url}`);\n\t} else {\n\t\tlet corsUrl = `${location.protocol}//${location.host}${basePath}cors?`;\n\n\t\tconst requestHeaderString = getRequestHeaderString(requestHeaders);\n\t\tif (requestHeaderString) corsUrl = `${corsUrl}sendheaders=${requestHeaderString}`;\n\n\t\tconst expectedResponseHeadersString = getExpectedResponseHeadersString(expectedResponseHeaders);\n\t\tif (requestHeaderString && expectedResponseHeadersString) {\n\t\t\tcorsUrl = `${corsUrl}&expectedheaders=${expectedResponseHeadersString}`;\n\t\t} else if (expectedResponseHeadersString) {\n\t\t\tcorsUrl = `${corsUrl}expectedheaders=${expectedResponseHeadersString}`;\n\t\t}\n\n\t\tif (requestHeaderString || expectedResponseHeadersString) {\n\t\t\treturn `${corsUrl}&url=${url}`;\n\t\t}\n\t\treturn `${corsUrl}url=${url}`;\n\t}\n};\n\n/**\n * Gets the part of the CORS URL that represents the HTTP headers to send.\n * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send\n * @returns {string} to be used as request-headers component in CORS URL.\n */\nconst getRequestHeaderString = function (requestHeaders) {\n\tlet requestHeaderString = \"\";\n\tif (requestHeaders) {\n\t\tfor (const header of requestHeaders) {\n\t\t\tif (requestHeaderString.length === 0) {\n\t\t\t\trequestHeaderString = `${header.name}:${encodeURIComponent(header.value)}`;\n\t\t\t} else {\n\t\t\t\trequestHeaderString = `${requestHeaderString},${header.name}:${encodeURIComponent(header.value)}`;\n\t\t\t}\n\t\t}\n\t\treturn requestHeaderString;\n\t}\n\treturn undefined;\n};\n\n/**\n * Gets headers and values to attach to the web request.\n * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send\n * @returns {object} An object specifying name and value of the headers.\n */\nconst getHeadersToSend = (requestHeaders) => {\n\tconst headersToSend = {};\n\tif (requestHeaders) {\n\t\tfor (const header of requestHeaders) {\n\t\t\theadersToSend[header.name] = header.value;\n\t\t}\n\t}\n\n\treturn headersToSend;\n};\n\n/**\n * Gets the part of the CORS URL that represents the expected HTTP headers to receive.\n * @param {Array.<string>} expectedResponseHeaders the expected HTTP headers to receive\n * @returns {string} to be used as the expected HTTP-headers component in CORS URL.\n */\nconst getExpectedResponseHeadersString = function (expectedResponseHeaders) {\n\tlet expectedResponseHeadersString = \"\";\n\tif (expectedResponseHeaders) {\n\t\tfor (const header of expectedResponseHeaders) {\n\t\t\tif (expectedResponseHeadersString.length === 0) {\n\t\t\t\texpectedResponseHeadersString = `${header}`;\n\t\t\t} else {\n\t\t\t\texpectedResponseHeadersString = `${expectedResponseHeadersString},${header}`;\n\t\t\t}\n\t\t}\n\t\treturn expectedResponseHeaders;\n\t}\n\treturn undefined;\n};\n\n/**\n * Gets the values for the expected headers from the response.\n * @param {Array.<string>} expectedResponseHeaders the expected HTTP headers to receive\n * @param {Response} response the HTTP response\n * @returns {string} to be used as the expected HTTP-headers component in CORS URL.\n */\nconst getHeadersFromResponse = (expectedResponseHeaders, response) => {\n\tconst responseHeaders = [];\n\n\tif (expectedResponseHeaders) {\n\t\tfor (const header of expectedResponseHeaders) {\n\t\t\tconst headerValue = response.headers.get(header);\n\t\t\tresponseHeaders.push({ name: header, value: headerValue });\n\t\t}\n\t}\n\n\treturn responseHeaders;\n};\n\n/**\n * Format the time according to the config\n * @param {object} config The config of the module\n * @param {object} time time to format\n * @returns {string} The formatted time string\n */\nconst formatTime = (config, time) => {\n\tlet date = moment(time);\n\n\tif (config.timezone) {\n\t\tdate = date.tz(config.timezone);\n\t}\n\n\tif (config.timeFormat !== 24) {\n\t\tif (config.showPeriod) {\n\t\t\tif (config.showPeriodUpper) {\n\t\t\t\treturn date.format(\"h:mm A\");\n\t\t\t} else {\n\t\t\t\treturn date.format(\"h:mm a\");\n\t\t\t}\n\t\t} else {\n\t\t\treturn date.format(\"h:mm\");\n\t\t}\n\t}\n\n\treturn date.format(\"HH:mm\");\n};\n\nif (typeof module !== \"undefined\") module.exports = {\n\tperformWebRequest,\n\tformatTime\n};\n"
  },
  {
    "path": "modules/default/weather/README.md",
    "content": "# Weather Module\n\nThis module will be configurable to be used as a current weather view, or to show the forecast. This way the module can be used twice to fulfill both purposes.\n\nFor configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/weather.html).\n"
  },
  {
    "path": "modules/default/weather/current.njk",
    "content": "{% macro humidity() %}\n  {% if current.humidity %}\n    <span class=\"humidity\"\n      ><span>{{ current.humidity | decimalSymbol }}</span><sup>&nbsp;<i class=\"wi wi-humidity humidity-icon\"></i></sup\n    ></span>\n  {% endif %}\n{% endmacro %}\n{% if current %}\n  {% if not config.onlyTemp %}\n    <div class=\"normal medium\">\n      <span class=\"wi wi-strong-wind dimmed\"></span>\n      <span>\n        {{ current.windSpeed | unit(\"wind\") | round }}\n        {% if config.showWindDirection %}\n          <sup>\n            {% if config.showWindDirectionAsArrow %}\n              <i class=\"fas fa-long-arrow-alt-down\" style=\"transform:rotate({{ current.windFromDirection }}deg)\"></i>\n            {% else %}\n              {{ current.cardinalWindDirection() | translate }}\n            {% endif %}\n            &nbsp;\n          </sup>\n        {% endif %}\n      </span>\n      {% if config.showHumidity === \"wind\" %}\n        {{ humidity() }}\n      {% endif %}\n      {% if config.showSun %}\n        <span class=\"wi dimmed wi-{{ current.nextSunAction() }}\"></span>\n        <span>\n          {% if current.nextSunAction() === \"sunset\" %}\n            {{ current.sunset | formatTime }}\n          {% else %}\n            {{ current.sunrise | formatTime }}\n          {% endif %}\n        </span>\n      {% endif %}\n      {% if config.showUVIndex %}\n        <td class=\"align-right bright uv-index\">\n          <div class=\"wi dimmed wi-hot\"></div>\n          {{ current.uv_index }}\n        </td>\n      {% endif %}\n    </div>\n  {% endif %}\n  <div class=\"flex large type-temp\">\n    {% if config.showIndoorTemperature and indoor.temperature or config.showIndoorHumidity and indoor.humidity %}\n      <span class=\"medium fas fa-home\"></span>\n      <span style=\"display: inline-block\">\n        {% if config.showIndoorTemperature and indoor.temperature %}\n          <sup class=\"small\" style=\"position: relative; display: block; text-align: left;\">\n            <span> {{ indoor.temperature | roundValue | unit(\"temperature\") | decimalSymbol }} </span>\n          </sup>\n        {% endif %}\n        {% if config.showIndoorHumidity and indoor.humidity %}\n          <sub class=\"small\" style=\"position: relative; display: block; text-align: left;\">\n            <span> {{ indoor.humidity | roundValue | unit(\"humidity\") | decimalSymbol }} </span>\n          </sub>\n        {% endif %}\n      </span>\n    {% endif %}\n    {% if current.weatherType %}\n      <span class=\"light wi weathericon wi-{{ current.weatherType }}\"></span>\n    {% endif %}\n    <span class=\"light bright\">{{ current.temperature | roundValue | unit(\"temperature\") | decimalSymbol }}</span>\n    {% if config.showHumidity === \"temp\" %}\n      <span class=\"medium bright\">{{ humidity() }}</span>\n    {% endif %}\n  </div>\n  {% if (config.showFeelsLike or config.showPrecipitationAmount or config.showPrecipitationProbability) and not config.onlyTemp %}\n    <div class=\"normal medium feelslike\">\n      {% if config.showFeelsLike %}\n        <span class=\"dimmed\">\n          {% if config.showHumidity === \"feelslike\" %}\n            {{ humidity() }}\n          {% endif %}\n          {{ \"FEELS\" | translate({DEGREE: current.feelsLike() | roundValue | unit(\"temperature\") | decimalSymbol }) }}\n        </span>\n        <br />\n      {% endif %}\n      {% if config.showPrecipitationAmount and current.precipitationAmount %}\n        <span class=\"dimmed\"> <span class=\"precipitationLeadText\">{{ \"PRECIP_AMOUNT\" | translate }}</span> {{ current.precipitationAmount | unit(\"precip\", current.precipitationUnits) }} </span>\n        <br />\n      {% endif %}\n      {% if config.showPrecipitationProbability and current.precipitationProbability %}\n        <span class=\"dimmed\"> <span class=\"precipitationLeadText\">{{ \"PRECIP_POP\" | translate }}</span> {{ current.precipitationProbability }}% </span>\n      {% endif %}\n    </div>\n  {% endif %}\n  {% if config.showHumidity === \"below\" %}\n    <span class=\"medium dimmed\">{{ humidity() }}</span>\n  {% endif %}\n{% else %}\n  <div class=\"dimmed light small\">{{ \"LOADING\" | translate }}</div>\n{% endif %}\n<!-- Uncomment the line below to see the contents of the `current` object. -->\n<!-- <div style=\"word-wrap:break-word\" class=\"xsmall dimmed\">{{ current | dump }}</div> -->\n"
  },
  {
    "path": "modules/default/weather/forecast.njk",
    "content": "{% if forecast %}\n  {% set numSteps = forecast | calcNumSteps %}\n  {% set currentStep = 0 %}\n  <table class=\"{{ config.tableClass }}\">\n    {% if config.ignoreToday %}\n      {% set forecast = forecast.splice(1) %}\n    {% endif %}\n    {% set forecast = forecast.slice(0, numSteps) %}\n    {% for f in forecast %}\n      <tr\n        {% if config.colored %}class=\"colored\"{% endif %}\n        {% if config.fade %}style=\"opacity: {{ currentStep | opacity(numSteps) }};\"{% endif %}\n      >\n        {% if (currentStep == 0) and config.ignoreToday == false and config.absoluteDates == false %}\n          <td class=\"day\">{{ \"TODAY\" | translate }}</td>\n        {% elif (currentStep == 1) and config.ignoreToday == false and config.absoluteDates == false %}\n          <td class=\"day\">{{ \"TOMORROW\" | translate }}</td>\n        {% else %}\n          <td class=\"day\">{{ f.date.format(config.forecastDateFormat) }}</td>\n        {% endif %}\n        <td class=\"bright weather-icon\">\n          <span class=\"wi weathericon wi-{{ f.weatherType }}\"></span>\n        </td>\n        <td class=\"align-right bright max-temp\">{{ f.maxTemperature | roundValue | unit(\"temperature\") | decimalSymbol }}</td>\n        <td class=\"align-right min-temp\">{{ f.minTemperature | roundValue | unit(\"temperature\") | decimalSymbol }}</td>\n        {% if config.showPrecipitationAmount %}\n          <td class=\"align-right bright precipitation-amount\">{{ f.precipitationAmount | unit(\"precip\", f.precipitationUnits) }}</td>\n        {% endif %}\n        {% if config.showPrecipitationProbability %}\n          <td class=\"align-right bright precipitation-prob\">{{ f.precipitationProbability | unit('precip', '%') }}</td>\n        {% endif %}\n        {% if config.showUVIndex %}\n          <td class=\"align-right dimmed uv-index\">\n            {{ f.uv_index }}\n            <span class=\"wi dimmed weathericon wi-hot\"></span>\n          </td>\n        {% endif %}\n      </tr>\n      {% set currentStep = currentStep + 1 %}\n    {% endfor %}\n  </table>\n{% else %}\n  <div class=\"dimmed light small\">{{ \"LOADING\" | translate }}</div>\n{% endif %}\n<!-- Uncomment the line below to see the contents of the `forecast` object. -->\n<!-- <div style=\"word-wrap:break-word\" class=\"xsmall dimmed\">{{ forecast | dump }}</div> -->\n"
  },
  {
    "path": "modules/default/weather/hourly.njk",
    "content": "{% if hourly %}\n  {% set numSteps = hourly | calcNumEntries %}\n  {% set currentStep = 0 %}\n  <table class=\"{{ config.tableClass }}\">\n    {% set hours = hourly.slice(0, numSteps) %}\n    {% for hour in hours %}\n      <tr\n        {% if config.colored %}class=\"colored\"{% endif %}\n        {% if config.fade %}style=\"opacity: {{ currentStep | opacity(numSteps) }};\"{% endif %}\n      >\n        <td class=\"day\">{{ hour.date | formatTime }}</td>\n        <td class=\"bright weather-icon\">\n          <span class=\"wi weathericon wi-{{ hour.weatherType }}\"></span>\n        </td>\n        <td class=\"align-right bright\">{{ hour.temperature | roundValue | unit(\"temperature\") }}</td>\n        {% if config.showUVIndex %}\n          <td class=\"align-right bright uv-index\">\n            {% if hour.uv_index!=0 %}\n              {{ hour.uv_index }}\n              <span class=\"wi weathericon wi-hot\"></span>\n            {% endif %}\n          </td>\n        {% endif %}\n        {% if config.showHumidity != \"none\" %}\n          <td class=\"align-left bright humidity-hourly\">\n            {{ hour.humidity }}\n            <span class=\"wi wi-humidity humidity-icon\"></span>\n          </td>\n        {% endif %}\n        {% if config.showPrecipitationAmount %}\n          {% if (not config.hideZeroes or hour.precipitationAmount>0) %}\n            <td class=\"align-right bright precipitation-amount\">{{ hour.precipitationAmount | unit(\"precip\", hour.precipitationUnits) }}</td>\n          {% endif %}\n        {% endif %}\n        {% if config.showPrecipitationProbability %}\n          {% if (not config.hideZeroes or hour.precipitationAmount>0) %}\n            <td class=\"align-right bright precipitation-prob\">{{ hour.precipitationProbability | unit('precip', '%') }}</td>\n          {% endif %}\n        {% endif %}\n      </tr>\n      {% set currentStep = currentStep + 1 %}\n    {% endfor %}\n  </table>\n{% else %}\n  <div class=\"dimmed light small\">{{ \"LOADING\" | translate }}</div>\n{% endif %}\n<!-- Uncomment the line below to see the contents of the `hourly` object. -->\n<!-- <div style=\"word-wrap:break-word\" class=\"xsmall dimmed\">{{ hourly | dump }}</div> -->\n"
  },
  {
    "path": "modules/default/weather/providers/README.md",
    "content": "# Weather Module Weather Provider Development Documentation\n\nFor how to develop your own weather provider, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/development/weather-provider.html).\n"
  },
  {
    "path": "modules/default/weather/providers/envcanada.js",
    "content": "/* global WeatherProvider, WeatherObject, WeatherUtils */\n\n/*\n * This class is a provider for Environment Canada MSC Datamart\n * Note that this is only for Canadian locations and does not require an API key (access is anonymous)\n *\n * EC Documentation at following links:\n * \thttps://dd.weather.gc.ca/citypage_weather/schema/\n * \thttps://eccc-msc.github.io/open-data/msc-datamart/readme_en/\n *\n * This module supports Canadian locations only and requires 2 additional config parameters:\n *\n * siteCode - the city/town unique identifier for which weather is to be displayed. Format is 's0000000'.\n *\n * provCode - the 2-character province code for the selected city/town.\n *\n * Example: for Toronto, Ontario, the following parameters would be used\n *\n * siteCode: 's0000458',\n * provCode: 'ON'\n *\n * To determine the siteCode and provCode values for a Canadian city/town, look at the Environment Canada document\n * at https://dd.weather.gc.ca/citypage_weather/docs/site_list_en.csv (or site_list_fr.csv). There you will find a table\n * with locations you can search under column B (English Names), with the corresponding siteCode under\n * column A (Codes) and provCode under column C (Province).\n *\n * Acknowledgement: Some logic and code for parsing Environment Canada web pages is based on material from MMM-EnvCanada\n *\n * License to use Environment Canada (EC) data is detailed here:\n * \thttps://eccc-msc.github.io/open-data/licence/readme_en/\n */\nWeatherProvider.register(\"envcanada\", {\n\t// Set the name of the provider for debugging and alerting purposes (eg. provide eye-catcher)\n\tproviderName: \"Environment Canada\",\n\n\t// Set the default config properties that is specific to this provider\n\tdefaults: {\n\t\tuseCorsProxy: true,\n\t\tsiteCode: \"s1234567\",\n\t\tprovCode: \"ON\"\n\t},\n\n\t/*\n\t * Set config values (equates to weather module config values). Also set values pertaining to caching of\n\t * Today's temperature forecast (for use in the Forecast functions below)\n\t */\n\tsetConfig (config) {\n\t\tthis.config = config;\n\n\t\tthis.todayTempCacheMin = 0;\n\t\tthis.todayTempCacheMax = 0;\n\t\tthis.todayCached = false;\n\t\tthis.cacheCurrentTemp = 999;\n\t\tthis.lastCityPageCurrent = \" \";\n\t\tthis.lastCityPageForecast = \" \";\n\t\tthis.lastCityPageHourly = \" \";\n\t},\n\n\t/*\n\t * Called when the weather provider is started\n\t */\n\tstart () {\n\t\tLog.info(`[weatherprovider.envcanada] ${this.providerName} started.`);\n\t\tthis.setFetchedLocation(this.config.location);\n\t},\n\n\t/*\n\t * Override the fetchCurrentWeather method to query EC and construct a Current weather object\n\t */\n\tfetchCurrentWeather () {\n\t\tthis.fetchCommon(\"Current\");\n\t},\n\n\t/*\n\t * Override the fetchWeatherForecast method to query EC and construct Forecast/Daily weather objects\n\t */\n\tfetchWeatherForecast () {\n\n\t\tthis.fetchCommon(\"Forecast\");\n\n\t},\n\n\t/*\n\t * Override the fetchWeatherHourly method to query EC and construct Hourly weather objects\n\t */\n\tfetchWeatherHourly () {\n\t\tthis.fetchCommon(\"Hourly\");\n\t},\n\n\t/*\n\t * Because the process to fetch weather data is virtually the same for Current, Forecast/Daily, and Hourly weather,\n\t * a common module is used to access the EC weather data. The only customization (based on the caller of this routine)\n\t * is how the data will be parsed to satisfy the Weather module config in Config.js\n\t *\n\t * Accessing EC weather data is accomplished in 2 steps:\n\t *\n\t * 1. Query the MSC Datamart Index page, which returns a list of all the filenames for all the cities that have\n\t *    weather data currently available.\n\t *\n\t * 2. With the city filename identified, build the appropriate URL and get the weather data (XML document) for the\n\t *    city specified in the Weather module Config information\n\t */\n\tfetchCommon (target) {\n\t\tconst forecastURL = this.getUrl(); // Get the appropriate URL for the MSC Datamart Index page\n\n\t\tLog.debug(`[weatherprovider.envcanada] ${target} Index url: ${forecastURL}`);\n\n\t\tthis.fetchData(forecastURL, \"xml\") // Query the Index page URL\n\t\t\t.then((indexData) => {\n\t\t\t\tif (!indexData) {\n\t\t\t\t\t// Did not receive usable new data.\n\t\t\t\t\tLog.info(`[weatherprovider.envcanada] ${target} - did not receive usable index data`);\n\t\t\t\t\tthis.updateAvailable(); // If there were issues, update anyways to reset timer\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t/**\n\t\t\t\t * With the Index page read, we must locate the filename/link for the specified city (aka Sitecode).\n\t\t\t\t * This is done by building the city filename and searching for it on the Index page. Once found,\n\t\t\t\t * extract the full filename (a unique name that includes dat/time, filename, etc.) and then add it\n\t\t\t\t * to the Index page URL to create the proper URL pointing to the city's weather data. Finally, read the\n\t\t\t\t * URL to pull in the city's XML document so that weather data can be parsed and displayed.\n\t\t\t\t */\n\n\t\t\t\tlet forecastFile = \"\";\n\t\t\t\tlet forecastFileURL = \"\";\n\t\t\t\tconst fileSuffix = `${this.config.siteCode}_en.xml`; // Build city filename\n\t\t\t\tconst nextFile = indexData.body.innerHTML.split(fileSuffix); // Find filename on Index page\n\n\t\t\t\tif (nextFile.length > 1) { // Parse out the full unique file city filename\n\t\t\t\t\t// Find the last occurrence\n\t\t\t\t\tforecastFile = nextFile[nextFile.length - 2].slice(-41) + fileSuffix;\n\t\t\t\t\tforecastFileURL = forecastURL + forecastFile; // Create full URL to the city's weather data\n\t\t\t\t}\n\n\t\t\t\tLog.debug(`[weatherprovider.envcanada] ${target} Citypage url: ${forecastFileURL}`);\n\n\t\t\t\t/*\n\t\t\t\t * If the Citypage filename has not changed since the last Weather refresh, the forecast has not changed and\n\t\t\t\t * and therefore we can skip reading the Citypage URL.\n\t\t\t\t */\n\n\t\t\t\tif (target === \"Current\" && this.lastCityPageCurrent === forecastFileURL) {\n\t\t\t\t\tLog.debug(`[weatherprovider.envcanada] ${target} - Newest Citypage has already been seen - skipping!`);\n\t\t\t\t\tthis.updateAvailable(); // Update anyways to reset refresh timer\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tif (target === \"Forecast\" && this.lastCityPageForecast === forecastFileURL) {\n\t\t\t\t\tLog.debug(`[weatherprovider.envcanada] ${target} - Newest Citypage has already been seen - skipping!`);\n\t\t\t\t\tthis.updateAvailable(); // Update anyways to reset refresh timer\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tif (target === \"Hourly\" && this.lastCityPageHourly === forecastFileURL) {\n\t\t\t\t\tLog.debug(`[weatherprovider.envcanada] ${target} - Newest Citypage has already been seen - skipping!`);\n\t\t\t\t\tthis.updateAvailable(); // Update anyways to reset refresh timer\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tthis.fetchData(forecastFileURL, \"xml\") // Read city's URL to get weather data\n\t\t\t\t\t.then((cityData) => {\n\t\t\t\t\t\tif (!cityData) {\n\t\t\t\t\t\t\t// Did not receive usable new data.\n\t\t\t\t\t\t\tLog.info(`[weatherprovider.envcanada] ${target} - did not receive usable citypage data`);\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t/*\n\t\t\t\t\t\t * With the city's weather data read, parse the resulting XML document for the appropriate weather data\n\t\t\t\t\t\t * elements to create a weather object. Next, set Weather modules details from that object.\n\t\t\t\t\t\t */\n\t\t\t\t\t\tLog.debug(`[weatherprovider.envcanada] ${target} - Citypage has been read and will be processed for updates`);\n\n\t\t\t\t\t\tif (target === \"Current\") {\n\t\t\t\t\t\t\tconst currentWeather = this.generateWeatherObjectFromCurrentWeather(cityData);\n\t\t\t\t\t\t\tthis.setCurrentWeather(currentWeather);\n\t\t\t\t\t\t\tthis.lastCityPageCurrent = forecastFileURL;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif (target === \"Forecast\") {\n\t\t\t\t\t\t\tconst forecastWeather = this.generateWeatherObjectsFromForecast(cityData);\n\t\t\t\t\t\t\tthis.setWeatherForecast(forecastWeather);\n\t\t\t\t\t\t\tthis.lastCityPageForecast = forecastFileURL;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif (target === \"Hourly\") {\n\t\t\t\t\t\t\tconst hourlyWeather = this.generateWeatherObjectsFromHourly(cityData);\n\t\t\t\t\t\t\tthis.setWeatherHourly(hourlyWeather);\n\t\t\t\t\t\t\tthis.lastCityPageHourly = forecastFileURL;\n\t\t\t\t\t\t}\n\t\t\t\t\t})\n\t\t\t\t\t.catch(function (cityRequest) {\n\t\t\t\t\t\tLog.info(`[weatherprovider.envcanada] ${target} - could not load citypage data from: ${forecastFileURL}`);\n\t\t\t\t\t})\n\t\t\t\t\t.finally(() => this.updateAvailable()); // Update no matter what to reset weather refresh timer\n\t\t\t})\n\t\t\t.catch(function (indexRequest) {\n\t\t\t\tLog.error(`[weatherprovider.envcanada] ${target} - could not load index data ... `, indexRequest);\n\t\t\t\tthis.updateAvailable(); // If there were issues, update anyways to reset timer\n\t\t\t});\n\t},\n\n\t/*\n\t * Build the EC Index page URL based on current GMT hour. The Index page will provide a list of links for each city\n\t * that will, in turn, provide actual weather data. The URL is comprised of 3 parts:\n\t *\n\t *   Fixed value + Prov code specified in Weather module Config.js + current hour as GMT\n\t */\n\tgetUrl () {\n\t\tlet forecastURL = `https://dd.weather.gc.ca/today/citypage_weather/${this.config.provCode}`;\n\t\tconst hour = this.getCurrentHourGMT();\n\t\tforecastURL += `/${hour}/`;\n\t\treturn forecastURL;\n\t},\n\n\t/*\n\t * Get current hour-of-day in GMT context\n\t */\n\tgetCurrentHourGMT () {\n\t\tconst now = new Date();\n\t\treturn now.toISOString().substring(11, 13); // \"HH\" in GMT\n\t},\n\n\t/*\n\t * Generate a WeatherObject based on current EC weather conditions\n\t */\n\tgenerateWeatherObjectFromCurrentWeather (ECdoc) {\n\t\tconst currentWeather = new WeatherObject();\n\n\t\t/*\n\t\t * There are instances where EC will update weather data and current temperature will not be\n\t\t * provided. While this is a defect in the EC systems, we need to accommodate to avoid a current temp\n\t\t * of NaN being displayed. Therefore... whenever we get a valid current temp from EC, we will cache\n\t\t * the value. Whenever EC data is missing current temp, we will provide the cached value\n\t\t * instead. This is reasonable since the cached value will typically be accurate within the previous\n\t\t * hour. The only time this does not work as expected is when MM is restarted and the first query to\n\t\t * EC finds no current temp. In this scenario, MM will end up displaying a current temp of null;\n\t\t */\n\t\tif (ECdoc.querySelector(\"siteData currentConditions temperature\").textContent) {\n\t\t\tcurrentWeather.temperature = ECdoc.querySelector(\"siteData currentConditions temperature\").textContent;\n\t\t\tthis.cacheCurrentTemp = currentWeather.temperature;\n\t\t} else {\n\t\t\tcurrentWeather.temperature = this.cacheCurrentTemp;\n\t\t}\n\n\t\tif (ECdoc.querySelector(\"siteData currentConditions wind speed\").textContent === \"calm\") {\n\t\t\tcurrentWeather.windSpeed = \"0\";\n\t\t} else {\n\t\t\tcurrentWeather.windSpeed = WeatherUtils.convertWindToMs(ECdoc.querySelector(\"siteData currentConditions wind speed\").textContent);\n\t\t}\n\n\t\tcurrentWeather.windFromDirection = ECdoc.querySelector(\"siteData currentConditions wind bearing\").textContent;\n\n\t\tcurrentWeather.humidity = ECdoc.querySelector(\"siteData currentConditions relativeHumidity\").textContent;\n\n\t\t/*\n\t\t * Ensure showPrecipitationAmount is forced to false. EC does not really provide POP for current day\n\t\t * and this feature for the weather module (current only) is sort of broken in that it wants\n\t\t * to say POP but will display precip as an accumulated amount vs. a percentage.\n\t\t */\n\t\tthis.config.showPrecipitationAmount = false;\n\n\t\t/*\n\t\t * If the module config wants to showFeelsLike... default to the current temperature.\n\t\t * Check for EC wind chill and humidex values and overwrite the feelsLikeTemp value.\n\t\t * This assumes that the EC current conditions will never contain both a wind chill\n\t\t * and humidex temperature.\n\t\t */\n\t\tif (this.config.showFeelsLike) {\n\t\t\tcurrentWeather.feelsLikeTemp = currentWeather.temperature;\n\n\t\t\tif (ECdoc.querySelector(\"siteData currentConditions windChill\")) {\n\t\t\t\tcurrentWeather.feelsLikeTemp = ECdoc.querySelector(\"siteData currentConditions windChill\").textContent;\n\t\t\t}\n\n\t\t\tif (ECdoc.querySelector(\"siteData currentConditions humidex\")) {\n\t\t\t\tcurrentWeather.feelsLikeTemp = ECdoc.querySelector(\"siteData currentConditions humidex\").textContent;\n\t\t\t}\n\t\t}\n\n\t\t// Need to map EC weather icon to MM weatherType values\n\t\tcurrentWeather.weatherType = this.convertWeatherType(ECdoc.querySelector(\"siteData currentConditions iconCode\").textContent);\n\n\t\t// Capture the sunrise and sunset values from EC data\n\t\tconst sunList = ECdoc.querySelectorAll(\"siteData riseSet dateTime\");\n\n\t\tcurrentWeather.sunrise = moment(sunList[1].querySelector(\"timeStamp\").textContent, \"YYYYMMDDhhmmss\");\n\t\tcurrentWeather.sunset = moment(sunList[3].querySelector(\"timeStamp\").textContent, \"YYYYMMDDhhmmss\");\n\n\t\treturn currentWeather;\n\t},\n\n\t/*\n\t * Generate an array of WeatherObjects based on EC weather forecast\n\t */\n\tgenerateWeatherObjectsFromForecast (ECdoc) {\n\t\t// Declare an array to hold each day's forecast object\n\t\tconst days = [];\n\n\t\tconst weather = new WeatherObject();\n\n\t\tconst foreBaseDates = ECdoc.querySelectorAll(\"siteData forecastGroup dateTime\");\n\t\tconst baseDate = foreBaseDates[1].querySelector(\"timeStamp\").textContent;\n\n\t\tweather.date = moment(baseDate, \"YYYYMMDDhhmmss\");\n\n\t\tconst foreGroup = ECdoc.querySelectorAll(\"siteData forecastGroup forecast\");\n\n\t\tweather.precipitationAmount = null;\n\n\t\t/*\n\t\t * The EC forecast is held in a 12-element array - Elements 0 to 11 - with each day encompassing\n\t\t * 2 elements. the first element for a day details the Today (daytime) forecast while the second\n\t\t * element details the Tonight (nighttime) forecast. Element 0 is always for the current day.\n\t\t *\n\t\t * However... the forecast is somewhat 'rolling'.\n\t\t *\n\t\t * If the EC forecast is queried in the morning, then Element 0 will contain Current\n\t\t * Today and Element 1 will contain Current Tonight. From there, the next 5 days of forecast will be\n\t\t * contained in Elements 2/3, 4/5, 6/7, 8/9, and 10/11. This module will create a 6-day forecast using\n\t\t * all of these Elements.\n\t\t *\n\t\t * But, if the EC forecast is queried in late afternoon, the Current Today forecast will be rolled\n\t\t * off and Element 0 will contain Current Tonight. From there, the next 5 days will be contained in\n\t\t * Elements 1/2, 3/4, 5/6, 7/8, and 9/10. As well, Element 11 will contain a forecast for a 6th day,\n\t\t * but only for the Today portion (not Tonight). This module will create a 6-day forecast using\n\t\t * Elements 0 to 11, and will ignore the additional Today forecast in Element 11.\n\t\t *\n\t\t * We need to determine if Element 0 is showing the forecast for Current Today or Current Tonight.\n\t\t * This is required to understand how Min and Max temperature will be determined, and to understand\n\t\t * where the next day's (aka Tomorrow's) forecast is located in the forecast array.\n\t\t */\n\t\tlet nextDay = 0;\n\t\tlet lastDay = 0;\n\t\tconst currentTemp = ECdoc.querySelector(\"siteData currentConditions temperature\").textContent;\n\n\t\t// If the first Element is Current Today, look at Current Today and Current Tonight for the current day.\n\t\tif (foreGroup[0].querySelector(\"period[textForecastName='Today']\")) {\n\t\t\tthis.todaytempCacheMin = 0;\n\t\t\tthis.todaytempCacheMax = 0;\n\t\t\tthis.todayCached = true;\n\n\t\t\tthis.setMinMaxTemps(weather, foreGroup, 0, true, currentTemp);\n\n\t\t\tthis.setPrecipitation(weather, foreGroup, 0);\n\n\t\t\t/*\n\t\t\t * Set the Element number that will reflect where the next day's forecast is located. Also set\n\t\t\t * the Element number where the end of the forecast will be. This is important because of the\n\t\t\t * rolling nature of the EC forecast. In the current scenario (Today and Tonight are present\n\t\t\t * in elements 0 and 11, we know that we will have 6 full days of forecasts and we will use\n\t\t\t * them. We will set lastDay such that we iterate through all 12 elements of the forecast.\n\t\t\t */\n\t\t\tnextDay = 2;\n\t\t\tlastDay = 12;\n\t\t}\n\n\t\t// If the first Element is Current Tonight, look at Tonight only for the current day.\n\t\tif (foreGroup[0].querySelector(\"period[textForecastName='Tonight']\")) {\n\t\t\tthis.setMinMaxTemps(weather, foreGroup, 0, false, currentTemp);\n\n\t\t\tthis.setPrecipitation(weather, foreGroup, 0);\n\n\t\t\t/*\n\t\t\t * Set the Element number that will reflect where the next day's forecast is located. Also set\n\t\t\t * the Element number where the end of the forecast will be. This is important because of the\n\t\t\t * rolling nature of the EC forecast. In the current scenario (only Current Tonight is present\n\t\t\t * in Element 0, we know that we will have 6 full days of forecasts PLUS a half-day and\n\t\t\t * forecast in the final element. Because we will only use full day forecasts, we set the\n\t\t\t * lastDay number to ensure we ignore that final half-day (in forecast Element 11).\n\t\t\t */\n\t\t\tnextDay = 1;\n\t\t\tlastDay = 11;\n\t\t}\n\n\t\t/*\n\t\t * Need to map EC weather icon to MM weatherType values. Always pick the first Element's icon to\n\t\t * reflect either Today or Tonight depending on what the forecast is showing in Element 0.\n\t\t */\n\t\tweather.weatherType = this.convertWeatherType(foreGroup[0].querySelector(\"abbreviatedForecast iconCode\").textContent);\n\n\t\t// Push the weather object into the forecast array.\n\t\tdays.push(weather);\n\n\t\t/*\n\t\t * Now do the rest of the forecast starting at nextDay. We will process each day using 2 EC\n\t\t * forecast Elements. This will address the fact that the EC forecast always includes Today and\n\t\t * Tonight for each day. This is why we iterate through the forecast by a a count of 2, with each\n\t\t * iteration looking at the current Element and the next Element.\n\t\t */\n\t\tlet lastDate = moment(baseDate, \"YYYYMMDDhhmmss\");\n\n\t\tfor (let stepDay = nextDay; stepDay < lastDay; stepDay += 2) {\n\t\t\tlet weather = new WeatherObject();\n\n\t\t\t// Add 1 to the date to reflect the current forecast day we are building\n\t\t\tlastDate = lastDate.add(1, \"day\");\n\t\t\tweather.date = moment(lastDate);\n\n\t\t\t/*\n\t\t\t * Capture the temperatures for the current Element and the next Element in order to set\n\t\t\t * the Min and Max temperatures for the forecast\n\t\t\t */\n\t\t\tthis.setMinMaxTemps(weather, foreGroup, stepDay, true, currentTemp);\n\n\t\t\tweather.precipitationAmount = null;\n\n\t\t\tthis.setPrecipitation(weather, foreGroup, stepDay);\n\n\t\t\t// Need to map EC weather icon to MM weatherType values. Always pick the first Element icon.\n\t\t\tweather.weatherType = this.convertWeatherType(foreGroup[stepDay].querySelector(\"abbreviatedForecast iconCode\").textContent);\n\n\t\t\t// Push the weather object into the forecast array.\n\t\t\tdays.push(weather);\n\t\t}\n\n\t\treturn days;\n\t},\n\n\t/*\n\t * Generate an array of WeatherObjects based on EC hourly weather forecast\n\t */\n\tgenerateWeatherObjectsFromHourly (ECdoc) {\n\t\t// Declare an array to hold each hour's forecast object\n\t\tconst hours = [];\n\n\t\t// Get local timezone UTC offset so that each hourly time can be calculated properly\n\t\tconst baseHours = ECdoc.querySelectorAll(\"siteData hourlyForecastGroup dateTime\");\n\t\tconst hourOffset = baseHours[1].getAttribute(\"UTCOffset\");\n\n\t\t/*\n\t\t * The EC hourly forecast is held in a 24-element array - Elements 0 to 23 - with Element 0 holding\n\t\t * the forecast for the next 'on the hour' time slot. This means the array is a rolling 24 hours.\n\t\t */\n\t\tconst hourGroup = ECdoc.querySelectorAll(\"siteData hourlyForecastGroup hourlyForecast\");\n\n\t\tfor (let stepHour = 0; stepHour < 24; stepHour += 1) {\n\t\t\tconst weather = new WeatherObject();\n\n\t\t\t// Determine local time by applying UTC offset to the forecast timestamp\n\t\t\tconst foreTime = moment(hourGroup[stepHour].getAttribute(\"dateTimeUTC\"), \"YYYYMMDDhhmmss\");\n\t\t\tconst currTime = foreTime.add(hourOffset, \"hours\");\n\t\t\tweather.date = moment(currTime);\n\n\t\t\t// Capture the temperature\n\t\t\tweather.temperature = hourGroup[stepHour].querySelector(\"temperature\").textContent;\n\n\t\t\t// Capture Likelihood of Precipitation (LOP) and unit-of-measure values\n\t\t\tconst precipLOP = hourGroup[stepHour].querySelector(\"lop\").textContent * 1.0;\n\n\t\t\tif (precipLOP > 0) {\n\t\t\t\tweather.precipitationProbability = precipLOP;\n\t\t\t}\n\n\t\t\t// Need to map EC weather icon to MM weatherType values. Always pick the first Element icon.\n\t\t\tweather.weatherType = this.convertWeatherType(hourGroup[stepHour].querySelector(\"iconCode\").textContent);\n\n\t\t\t// Push the weather object into the forecast array.\n\t\t\thours.push(weather);\n\t\t}\n\n\t\treturn hours;\n\t},\n\n\t/*\n\t * Determine Min and Max temp based on a supplied Forecast Element index and a boolean that denotes if\n\t * the next Forecast element should be considered - i.e. look at Today *and* Tonight vs.Tonight-only\n\t */\n\tsetMinMaxTemps (weather, foreGroup, today, fullDay, currentTemp) {\n\t\tconst todayTemp = foreGroup[today].querySelector(\"temperatures temperature\").textContent;\n\n\t\tconst todayClass = foreGroup[today].querySelector(\"temperatures temperature\").getAttribute(\"class\");\n\n\t\t/*\n\t\t * The following logic is largely aimed at accommodating the Current day's forecast whereby we\n\t\t * can have either Current Today+Current Tonight or only Current Tonight.\n\t\t *\n\t\t * If fullDay is false, then we only have Tonight for the current day's forecast - meaning we have\n\t\t * lost a min or max temp value for the day. Therefore, we will see if we were able to cache the the\n\t\t * Today forecast for the current day. If we have, we will use them. If we do not have the cached values,\n\t\t * it means that MM or the Computer has been restarted since the time EC rolled off Today from the\n\t\t * forecast. In this scenario, we will simply default to the Current Conditions temperature and then\n\t\t * check the Tonight temperature.x\n\t\t */\n\t\tif (fullDay === false) {\n\t\t\tif (this.todayCached === true) {\n\t\t\t\tweather.minTemperature = this.todayTempCacheMin;\n\t\t\t\tweather.maxTemperature = this.todayTempCacheMax;\n\t\t\t} else {\n\t\t\t\tweather.minTemperature = currentTemp;\n\t\t\t\tweather.maxTemperature = weather.minTemperature;\n\t\t\t}\n\t\t}\n\n\t\t/*\n\t\t * We will check to see if the current Element's temperature is Low or High and set weather values\n\t\t * accordingly. We will also check the condition where fullDay is true *and* we are looking at forecast\n\t\t * element 0. This is a special case where we will cache temperature values so that we have them later\n\t\t * in the current day when the Current Today element rolls off and we have Current Tonight only.\n\t\t */\n\t\tif (todayClass === \"low\") {\n\t\t\tweather.minTemperature = todayTemp;\n\t\t\tif (today === 0 && fullDay === true) {\n\t\t\t\tthis.todayTempCacheMin = weather.minTemperature;\n\t\t\t}\n\t\t}\n\n\t\tif (todayClass === \"high\") {\n\t\t\tweather.maxTemperature = todayTemp;\n\t\t\tif (today === 0 && fullDay === true) {\n\t\t\t\tthis.todayTempCacheMax = weather.maxTemperature;\n\t\t\t}\n\t\t}\n\n\t\tconst nextTemp = foreGroup[today + 1].querySelector(\"temperatures temperature\").textContent;\n\n\t\tconst nextClass = foreGroup[today + 1].querySelector(\"temperatures temperature\").getAttribute(\"class\");\n\n\t\tif (fullDay === true) {\n\t\t\tif (nextClass === \"low\") {\n\t\t\t\tweather.minTemperature = nextTemp;\n\t\t\t}\n\n\t\t\tif (nextClass === \"high\") {\n\t\t\t\tweather.maxTemperature = nextTemp;\n\t\t\t}\n\t\t}\n\t},\n\n\t/*\n\t * Check for a Precipitation forecast. EC can provide a forecast in 2 ways: either an accumulation figure\n\t * or a POP percentage. If there is a POP, then that is what the module will show. If there is an accumulation,\n\t * then it will be displayed ONLY if no POP is present.\n\t *\n\t * POP Logic: By default, we want to show the POP for 'daytime' since we are presuming that is what\n\t * people are more interested in seeing. While EC provides a separate POP for daytime and nighttime portions\n\t * of each day, the weather module does not really allow for that view of a daily forecast. There we will\n\t * ignore any nighttime portion. There is an exception however! For the Current day, the EC data will only show\n\t * the nighttime forecast after a certain point in the afternoon. As such, we will be showing the nighttime POP\n\t * (if one exists) in that specific scenario.\n\t *\n\t * Accumulation Logic: Similar to POP, we want to show accumulation for 'daytime' since we presume that is what\n\t * people are interested in seeing. While EC provides a separate accumulation for daytime and nighttime portions\n\t * of each day, the weather module does not really allow for that view of a daily forecast. There we will\n\t * ignore any nighttime portion. There is an exception however! For the Current day, the EC data will only show\n\t * the nighttime forecast after a certain point in that specific scenario.\n\t */\n\tsetPrecipitation (weather, foreGroup, today) {\n\t\tif (foreGroup[today].querySelector(\"precipitation accumulation\")) {\n\t\t\tweather.precipitationAmount = foreGroup[today].querySelector(\"precipitation accumulation amount\").textContent * 1.0;\n\t\t\tweather.precipitationUnits = foreGroup[today].querySelector(\"precipitation accumulation amount\").getAttribute(\"units\");\n\t\t}\n\n\t\t// Check Today element for POP\n\t\tconst precipPOP = foreGroup[today].querySelector(\"abbreviatedForecast pop\").textContent * 1.0;\n\t\tif (precipPOP > 0) {\n\t\t\tweather.precipitationProbability = precipPOP;\n\t\t}\n\t},\n\n\t/*\n\t * Convert the icons to a more usable name.\n\t */\n\tconvertWeatherType (weatherType) {\n\t\tconst weatherTypes = {\n\t\t\t\"00\": \"day-sunny\",\n\t\t\t\"01\": \"day-sunny\",\n\t\t\t\"02\": \"day-sunny-overcast\",\n\t\t\t\"03\": \"day-cloudy\",\n\t\t\t\"04\": \"day-cloudy\",\n\t\t\t\"05\": \"day-cloudy\",\n\t\t\t\"06\": \"day-sprinkle\",\n\t\t\t\"07\": \"day-showers\",\n\t\t\t\"08\": \"day-snow\",\n\t\t\t\"09\": \"day-thunderstorm\",\n\t\t\t10: \"cloud\",\n\t\t\t11: \"showers\",\n\t\t\t12: \"rain\",\n\t\t\t13: \"rain\",\n\t\t\t14: \"sleet\",\n\t\t\t15: \"sleet\",\n\t\t\t16: \"snow\",\n\t\t\t17: \"snow\",\n\t\t\t18: \"snow\",\n\t\t\t19: \"thunderstorm\",\n\t\t\t20: \"cloudy\",\n\t\t\t21: \"cloudy\",\n\t\t\t22: \"day-cloudy\",\n\t\t\t23: \"day-haze\",\n\t\t\t24: \"fog\",\n\t\t\t25: \"snow-wind\",\n\t\t\t26: \"sleet\",\n\t\t\t27: \"sleet\",\n\t\t\t28: \"rain\",\n\t\t\t29: \"na\",\n\t\t\t30: \"night-clear\",\n\t\t\t31: \"night-clear\",\n\t\t\t32: \"night-partly-cloudy\",\n\t\t\t33: \"night-alt-cloudy\",\n\t\t\t34: \"night-alt-cloudy\",\n\t\t\t35: \"night-partly-cloudy\",\n\t\t\t36: \"night-alt-showers\",\n\t\t\t37: \"night-rain-mix\",\n\t\t\t38: \"night-alt-snow\",\n\t\t\t39: \"night-thunderstorm\",\n\t\t\t40: \"snow-wind\",\n\t\t\t41: \"tornado\",\n\t\t\t42: \"tornado\",\n\t\t\t43: \"windy\",\n\t\t\t44: \"smoke\",\n\t\t\t45: \"sandstorm\",\n\t\t\t46: \"thunderstorm\",\n\t\t\t47: \"thunderstorm\",\n\t\t\t48: \"tornado\"\n\t\t};\n\n\t\treturn weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null;\n\t}\n});\n"
  },
  {
    "path": "modules/default/weather/providers/openmeteo.js",
    "content": "/* global WeatherProvider, WeatherObject */\n\n/*\n * This class is a provider for Open-Meteo,\n * see https://open-meteo.com/\n */\n\n// https://www.bigdatacloud.com/docs/api/free-reverse-geocode-to-city-api\nconst GEOCODE_BASE = \"https://api.bigdatacloud.net/data/reverse-geocode-client\";\nconst OPEN_METEO_BASE = \"https://api.open-meteo.com/v1\";\n\nWeatherProvider.register(\"openmeteo\", {\n\n\t/*\n\t * Set the name of the provider.\n\t * Not strictly required but helps for debugging.\n\t */\n\tproviderName: \"Open-Meteo\",\n\n\t// Set the default config properties that is specific to this provider\n\tdefaults: {\n\t\tapiBase: OPEN_METEO_BASE,\n\t\tlat: 0,\n\t\tlon: 0,\n\t\tpastDays: 0,\n\t\ttype: \"current\"\n\t},\n\n\t// https://open-meteo.com/en/docs\n\thourlyParams: [\n\t\t// Air temperature at 2 meters above ground\n\t\t\"temperature_2m\",\n\t\t// Relative humidity at 2 meters above ground\n\t\t\"relativehumidity_2m\",\n\t\t// Dew point temperature at 2 meters above ground\n\t\t\"dewpoint_2m\",\n\t\t// Apparent temperature is the perceived feels-like temperature combining wind chill factor, relative humidity and solar radiation\n\t\t\"apparent_temperature\",\n\t\t// Atmospheric air pressure reduced to mean sea level (msl) or pressure at surface. Typically pressure on mean sea level is used in meteorology. Surface pressure gets lower with increasing elevation.\n\t\t\"pressure_msl\",\n\t\t\"surface_pressure\",\n\t\t// Total cloud cover as an area fraction\n\t\t\"cloudcover\",\n\t\t// Low level clouds and fog up to 3 km altitude\n\t\t\"cloudcover_low\",\n\t\t// Mid level clouds from 3 to 8 km altitude\n\t\t\"cloudcover_mid\",\n\t\t// High level clouds from 8 km altitude\n\t\t\"cloudcover_high\",\n\t\t// Wind speed at 10, 80, 120 or 180 meters above ground. Wind speed on 10 meters is the standard level.\n\t\t\"windspeed_10m\",\n\t\t\"windspeed_80m\",\n\t\t\"windspeed_120m\",\n\t\t\"windspeed_180m\",\n\t\t// Wind direction at 10, 80, 120 or 180 meters above ground\n\t\t\"winddirection_10m\",\n\t\t\"winddirection_80m\",\n\t\t\"winddirection_120m\",\n\t\t\"winddirection_180m\",\n\t\t// Gusts at 10 meters above ground as a maximum of the preceding hour\n\t\t\"windgusts_10m\",\n\t\t// Shortwave solar radiation as average of the preceding hour. This is equal to the total global horizontal irradiation\n\t\t\"shortwave_radiation\",\n\t\t// Direct solar radiation as average of the preceding hour on the horizontal plane and the normal plane (perpendicular to the sun)\n\t\t\"direct_radiation\",\n\t\t\"direct_normal_irradiance\",\n\t\t// Diffuse solar radiation as average of the preceding hour\n\t\t\"diffuse_radiation\",\n\t\t// Vapor Pressure Deificit (VPD) in kilopascal (kPa). For high VPD (>1.6), water transpiration of plants increases. For low VPD (<0.4), transpiration decreases\n\t\t\"vapor_pressure_deficit\",\n\t\t// Evapotranspration from land surface and plants that weather models assumes for this location. Available soil water is considered. 1 mm evapotranspiration per hour equals 1 liter of water per spare meter.\n\t\t\"evapotranspiration\",\n\t\t// ET₀ Reference Evapotranspiration of a well watered grass field. Based on FAO-56 Penman-Monteith equations ET₀ is calculated from temperature, wind speed, humidity and solar radiation. Unlimited soil water is assumed. ET₀ is commonly used to estimate the required irrigation for plants.\n\t\t\"et0_fao_evapotranspiration\",\n\t\t// Total precipitation (rain, showers, snow) sum of the preceding hour\n\t\t\"precipitation\",\n\t\t// Precipitation Probability\n\t\t\"precipitation_probability\",\n\t\t// UV index\n\t\t\"uv_index\",\n\t\t// Snowfall amount of the preceding hour in centimeters. For the water equivalent in millimeter, divide by 7. E.g. 7 cm snow = 10 mm precipitation water equivalent\n\t\t\"snowfall\",\n\t\t// Rain from large scale weather systems of the preceding hour in millimeter\n\t\t\"rain\",\n\t\t// Showers from convective precipitation in millimeters from the preceding hour\n\t\t\"showers\",\n\t\t// Weather condition as a numeric code. Follow WMO weather interpretation codes.\n\t\t\"weathercode\",\n\t\t// Snow depth on the ground\n\t\t\"snow_depth\",\n\t\t// Altitude above sea level of the 0°C level\n\t\t\"freezinglevel_height\",\n\t\t// Temperature in the soil at 0, 6, 18 and 54 cm depths. 0 cm is the surface temperature on land or water surface temperature on water.\n\t\t\"soil_temperature_0cm\",\n\t\t\"soil_temperature_6cm\",\n\t\t\"soil_temperature_18cm\",\n\t\t\"soil_temperature_54cm\",\n\t\t// Average soil water content as volumetric mixing ratio at 0-1, 1-3, 3-9, 9-27 and 27-81 cm depths.\n\t\t\"soil_moisture_0_1cm\",\n\t\t\"soil_moisture_1_3cm\",\n\t\t\"soil_moisture_3_9cm\",\n\t\t\"soil_moisture_9_27cm\",\n\t\t\"soil_moisture_27_81cm\"\n\t],\n\n\tdailyParams: [\n\t\t// Maximum and minimum daily air temperature at 2 meters above ground\n\t\t\"temperature_2m_max\",\n\t\t\"temperature_2m_min\",\n\t\t// Maximum and minimum daily apparent temperature\n\t\t\"apparent_temperature_min\",\n\t\t\"apparent_temperature_max\",\n\t\t// Sum of daily precipitation (including rain, showers and snowfall)\n\t\t\"precipitation_sum\",\n\t\t// Sum of daily rain\n\t\t\"rain_sum\",\n\t\t// Sum of daily showers\n\t\t\"showers_sum\",\n\t\t// Sum of daily snowfall\n\t\t\"snowfall_sum\",\n\t\t// The number of hours with rain\n\t\t\"precipitation_hours\",\n\t\t// The most severe weather condition on a given day\n\t\t\"weathercode\",\n\t\t// Sun rise and set times\n\t\t\"sunrise\",\n\t\t\"sunset\",\n\t\t// Maximum wind speed and gusts on a day\n\t\t\"windspeed_10m_max\",\n\t\t\"windgusts_10m_max\",\n\t\t// Dominant wind direction\n\t\t\"winddirection_10m_dominant\",\n\t\t// The sum of solar radiation on a given day in Megajoules\n\t\t\"shortwave_radiation_sum\",\n\t\t//UV Index\n\t\t\"uv_index_max\",\n\t\t// Daily sum of ET₀ Reference Evapotranspiration of a well watered grass field\n\t\t\"et0_fao_evapotranspiration\"\n\t],\n\n\tfetchedLocation () {\n\t\treturn this.fetchedLocationName || \"\";\n\t},\n\n\tfetchCurrentWeather () {\n\t\tthis.fetchData(this.getUrl())\n\t\t\t.then((data) => this.parseWeatherApiResponse(data))\n\t\t\t.then((parsedData) => {\n\t\t\t\tif (!parsedData) {\n\t\t\t\t\t// No usable data?\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tconst currentWeather = this.generateWeatherDayFromCurrentWeather(parsedData);\n\t\t\t\tthis.setCurrentWeather(currentWeather);\n\t\t\t})\n\t\t\t.catch(function (request) {\n\t\t\t\tLog.error(\"[weatherprovider.openmeteo] Could not load data ... \", request);\n\t\t\t})\n\t\t\t.finally(() => this.updateAvailable());\n\t},\n\n\tfetchWeatherForecast () {\n\t\tthis.fetchData(this.getUrl())\n\t\t\t.then((data) => this.parseWeatherApiResponse(data))\n\t\t\t.then((parsedData) => {\n\t\t\t\tif (!parsedData) {\n\t\t\t\t\t// No usable data?\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tconst dailyForecast = this.generateWeatherObjectsFromForecast(parsedData);\n\t\t\t\tthis.setWeatherForecast(dailyForecast);\n\t\t\t})\n\t\t\t.catch(function (request) {\n\t\t\t\tLog.error(\"[weatherprovider.openmeteo] Could not load data ... \", request);\n\t\t\t})\n\t\t\t.finally(() => this.updateAvailable());\n\t},\n\n\tfetchWeatherHourly () {\n\t\tthis.fetchData(this.getUrl())\n\t\t\t.then((data) => this.parseWeatherApiResponse(data))\n\t\t\t.then((parsedData) => {\n\t\t\t\tif (!parsedData) {\n\t\t\t\t\t// No usable data?\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tconst hourlyForecast = this.generateWeatherObjectsFromHourly(parsedData);\n\t\t\t\tthis.setWeatherHourly(hourlyForecast);\n\t\t\t})\n\t\t\t.catch(function (request) {\n\t\t\t\tLog.error(\"[weatherprovider.openmeteo] Could not load data ... \", request);\n\t\t\t})\n\t\t\t.finally(() => this.updateAvailable());\n\t},\n\n\t/**\n\t * Overrides method for setting config to check if endpoint is correct for hourly\n\t * @param {object} config The configuration object\n\t */\n\tsetConfig (config) {\n\t\tthis.config = {\n\t\t\tlang: config.lang ?? \"en\",\n\t\t\t...this.defaults,\n\t\t\t...config\n\t\t};\n\n\t\t// Set properly maxNumberOfDays and max Entries properties according to config and value ranges allowed in the documentation\n\t\tconst maxEntriesLimit = [\"daily\", \"forecast\"].includes(this.config.type) ? 7 : this.config.type === \"hourly\" ? 48 : 0;\n\t\tif (this.config.hasOwnProperty(\"maxNumberOfDays\") && !isNaN(parseFloat(this.config.maxNumberOfDays))) {\n\t\t\tconst daysFactor = [\"daily\", \"forecast\"].includes(this.config.type) ? 1 : this.config.type === \"hourly\" ? 24 : 0;\n\t\t\tthis.config.maxEntries = Math.max(1, Math.min(Math.round(parseFloat(this.config.maxNumberOfDays)) * daysFactor, maxEntriesLimit));\n\t\t\tthis.config.maxNumberOfDays = Math.ceil(this.config.maxEntries / Math.max(1, daysFactor));\n\t\t}\n\t\tthis.config.maxEntries = Math.max(1, Math.min(this.config.maxEntries, maxEntriesLimit));\n\n\t\tif (!this.config.type) {\n\t\t\tLog.error(\"[weatherprovider.openmeteo] type not configured and could not resolve it\");\n\t\t}\n\n\t\tthis.fetchLocation();\n\t},\n\n\t// Generate valid query params to perform the request\n\tgetQueryParameters () {\n\t\tlet params = {\n\t\t\tlatitude: this.config.lat,\n\t\t\tlongitude: this.config.lon,\n\t\t\ttimeformat: \"unixtime\",\n\t\t\ttimezone: \"auto\",\n\t\t\tpast_days: this.config.pastDays ?? 0,\n\t\t\tdaily: this.dailyParams,\n\t\t\thourly: this.hourlyParams,\n\t\t\t// Fixed units as metric\n\t\t\ttemperature_unit: \"celsius\",\n\t\t\twindspeed_unit: \"ms\",\n\t\t\tprecipitation_unit: \"mm\"\n\t\t};\n\n\t\tconst startDate = moment().startOf(\"day\");\n\t\tconst endDate = moment(startDate)\n\t\t\t.add(Math.max(0, Math.min(7, this.config.maxNumberOfDays)), \"days\")\n\t\t\t.endOf(\"day\");\n\n\t\tparams.start_date = startDate.format(\"YYYY-MM-DD\");\n\n\t\tswitch (this.config.type) {\n\t\t\tcase \"hourly\":\n\t\t\tcase \"daily\":\n\t\t\tcase \"forecast\":\n\t\t\t\tparams.end_date = endDate.format(\"YYYY-MM-DD\");\n\t\t\t\tbreak;\n\t\t\tcase \"current\":\n\t\t\t\tparams.current_weather = true;\n\t\t\t\tparams.end_date = params.start_date;\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\t// Failsafe\n\t\t\t\treturn \"\";\n\t\t}\n\n\t\treturn Object.keys(params)\n\t\t\t.filter((key) => (!!params[key]))\n\t\t\t.map((key) => {\n\t\t\t\tswitch (key) {\n\t\t\t\t\tcase \"hourly\":\n\t\t\t\t\tcase \"daily\":\n\t\t\t\t\t\treturn `${encodeURIComponent(key)}=${params[key].join(\",\")}`;\n\t\t\t\t\tdefault:\n\t\t\t\t\t\treturn `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`;\n\t\t\t\t}\n\t\t\t})\n\t\t\t.join(\"&\");\n\t},\n\n\t// Create a URL from the config and base URL.\n\tgetUrl () {\n\t\treturn `${this.config.apiBase}/forecast?${this.getQueryParameters()}`;\n\t},\n\n\t// fix daylight-saving-time differences\n\tcheckDST (dt) {\n\t\tconst uxdt = moment.unix(dt);\n\t\tconst nowDST = moment().isDST();\n\t\tif (nowDST === moment(uxdt).isDST()) {\n\t\t\treturn uxdt;\n\t\t} else {\n\t\t\treturn uxdt.add(nowDST ? +1 : -1, \"hour\");\n\t\t}\n\t},\n\n\t// Transpose hourly and daily data matrices\n\ttransposeDataMatrix (data) {\n\t\treturn data.time.map((_, index) => Object.keys(data).reduce((row, key) => {\n\t\t\treturn {\n\t\t\t\t...row,\n\t\t\t\t// Parse time values as moment.js instances\n\t\t\t\t[key]: [\"time\", \"sunrise\", \"sunset\"].includes(key) ? this.checkDST(data[key][index]) : data[key][index]\n\t\t\t};\n\t\t}, {}));\n\t},\n\n\t// Sanitize and validate API response\n\tparseWeatherApiResponse (data) {\n\t\tconst validByType = {\n\t\t\tcurrent: data.current_weather && data.current_weather.time,\n\t\t\thourly: data.hourly && data.hourly.time && Array.isArray(data.hourly.time) && data.hourly.time.length > 0,\n\t\t\tdaily: data.daily && data.daily.time && Array.isArray(data.daily.time) && data.daily.time.length > 0\n\t\t};\n\t\t// backwards compatibility\n\t\tconst type = [\"daily\", \"forecast\"].includes(this.config.type) ? \"daily\" : this.config.type;\n\n\t\tif (!validByType[type]) return;\n\n\t\tswitch (type) {\n\t\t\tcase \"current\":\n\t\t\t\tif (!validByType.daily && !validByType.hourly) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\tcase \"hourly\":\n\t\t\tcase \"daily\":\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\treturn;\n\t\t}\n\n\t\tfor (const key of [\"hourly\", \"daily\"]) {\n\t\t\tif (typeof data[key] === \"object\") {\n\t\t\t\tdata[key] = this.transposeDataMatrix(data[key]);\n\t\t\t}\n\t\t}\n\n\t\tif (data.current_weather) {\n\t\t\tdata.current_weather.time = moment.unix(data.current_weather.time);\n\t\t}\n\n\t\treturn data;\n\t},\n\n\t// Reverse geocoding from latitude and longitude provided\n\tfetchLocation () {\n\t\tthis.fetchData(`${GEOCODE_BASE}?latitude=${this.config.lat}&longitude=${this.config.lon}&localityLanguage=${this.config.lang}`)\n\t\t\t.then((data) => {\n\t\t\t\tif (!data || !data.city) {\n\t\t\t\t\t// No usable data?\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tthis.fetchedLocationName = `${data.city}, ${data.principalSubdivisionCode}`;\n\t\t\t})\n\t\t\t.catch((request) => {\n\t\t\t\tLog.error(\"[weatherprovider.openmeteo] Could not load data ... \", request);\n\t\t\t});\n\t},\n\n\t// Implement WeatherDay generator.\n\tgenerateWeatherDayFromCurrentWeather (weather) {\n\n\t\t/**\n\t\t * Since some units come from API response \"splitted\" into daily, hourly and current_weather\n\t\t * every time you request it, you have to ensure to get the data from the right place every time.\n\t\t * For the current weather case, the response have the following structure (after transposing):\n\t\t * ```\n\t\t * {\n\t\t *   current_weather: { ...<some current weather here> },\n\t\t * \t hourly: [\n\t\t * \t   0: {...<data for hour zero here> },\n\t\t * \t   1: {...<data for hour one here> },\n\t\t *     ...\n\t\t *   ],\n\t\t *   daily: [\n\t\t * \t   {...<summary data for current day here> },\n\t\t *   ]\n\t\t * }\n\t\t * ```\n\t\t * Some data should be returned from `hourly` array data when the index matches the current hour,\n\t\t * some data from the first and only one object received in `daily` array and some from the\n\t\t * `current_weather` object.\n\t\t */\n\t\tconst h = moment().hour();\n\t\tconst currentWeather = new WeatherObject();\n\n\t\tcurrentWeather.date = weather.current_weather.time;\n\t\tcurrentWeather.windSpeed = weather.current_weather.windspeed;\n\t\tcurrentWeather.windFromDirection = weather.current_weather.winddirection;\n\t\tcurrentWeather.sunrise = weather.daily[0].sunrise;\n\t\tcurrentWeather.sunset = weather.daily[0].sunset;\n\t\tcurrentWeather.temperature = parseFloat(weather.current_weather.temperature);\n\t\tcurrentWeather.minTemperature = parseFloat(weather.daily[0].temperature_2m_min);\n\t\tcurrentWeather.maxTemperature = parseFloat(weather.daily[0].temperature_2m_max);\n\t\tcurrentWeather.weatherType = this.convertWeatherType(weather.current_weather.weathercode, currentWeather.isDayTime());\n\t\tcurrentWeather.humidity = parseFloat(weather.hourly[h].relativehumidity_2m);\n\t\tcurrentWeather.feelsLikeTemp = parseFloat(weather.hourly[h].apparent_temperature);\n\t\tcurrentWeather.rain = parseFloat(weather.hourly[h].rain);\n\t\tcurrentWeather.snow = parseFloat(weather.hourly[h].snowfall * 10);\n\t\tcurrentWeather.precipitationAmount = parseFloat(weather.hourly[h].precipitation);\n\t\tcurrentWeather.precipitationProbability = parseFloat(weather.hourly[h].precipitation_probability);\n\t\tcurrentWeather.uv_index = parseFloat(weather.hourly[h].uv_index);\n\n\t\treturn currentWeather;\n\t},\n\n\t// Implement WeatherForecast generator.\n\tgenerateWeatherObjectsFromForecast (weathers) {\n\t\tconst days = [];\n\n\t\tweathers.daily.forEach((weather) => {\n\t\t\tconst currentWeather = new WeatherObject();\n\n\t\t\tcurrentWeather.date = weather.time;\n\t\t\tcurrentWeather.windSpeed = weather.windspeed_10m_max;\n\t\t\tcurrentWeather.windFromDirection = weather.winddirection_10m_dominant;\n\t\t\tcurrentWeather.sunrise = weather.sunrise;\n\t\t\tcurrentWeather.sunset = weather.sunset;\n\t\t\tcurrentWeather.temperature = parseFloat((weather.temperature_2m_max + weather.temperature_2m_min) / 2);\n\t\t\tcurrentWeather.minTemperature = parseFloat(weather.temperature_2m_min);\n\t\t\tcurrentWeather.maxTemperature = parseFloat(weather.temperature_2m_max);\n\t\t\tcurrentWeather.weatherType = this.convertWeatherType(weather.weathercode, true);\n\t\t\tcurrentWeather.rain = parseFloat(weather.rain_sum);\n\t\t\tcurrentWeather.snow = parseFloat(weather.snowfall_sum * 10);\n\t\t\tcurrentWeather.precipitationAmount = parseFloat(weather.precipitation_sum);\n\t\t\tcurrentWeather.precipitationProbability = parseFloat(weather.precipitation_hours * 100 / 24);\n\t\t\tcurrentWeather.uv_index = parseFloat(weather.uv_index_max);\n\n\t\t\tdays.push(currentWeather);\n\t\t});\n\n\t\treturn days;\n\t},\n\n\t// Implement WeatherHourly generator.\n\tgenerateWeatherObjectsFromHourly (weathers) {\n\t\tconst hours = [];\n\t\tconst now = moment();\n\n\t\tweathers.hourly.forEach((weather, i) => {\n\t\t\tif ((hours.length === 0 && weather.time <= now) || hours.length >= this.config.maxEntries) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst currentWeather = new WeatherObject();\n\t\t\tconst h = Math.ceil((i + 1) / 24) - 1;\n\n\t\t\tcurrentWeather.date = weather.time;\n\t\t\tcurrentWeather.windSpeed = weather.windspeed_10m;\n\t\t\tcurrentWeather.windFromDirection = weather.winddirection_10m;\n\t\t\tcurrentWeather.sunrise = weathers.daily[h].sunrise;\n\t\t\tcurrentWeather.sunset = weathers.daily[h].sunset;\n\t\t\tcurrentWeather.temperature = parseFloat(weather.temperature_2m);\n\t\t\tcurrentWeather.minTemperature = parseFloat(weathers.daily[h].temperature_2m_min);\n\t\t\tcurrentWeather.maxTemperature = parseFloat(weathers.daily[h].temperature_2m_max);\n\t\t\tcurrentWeather.weatherType = this.convertWeatherType(weather.weathercode, currentWeather.isDayTime());\n\t\t\tcurrentWeather.humidity = parseFloat(weather.relativehumidity_2m);\n\t\t\tcurrentWeather.rain = parseFloat(weather.rain);\n\t\t\tcurrentWeather.snow = parseFloat(weather.snowfall * 10);\n\t\t\tcurrentWeather.precipitationAmount = parseFloat(weather.precipitation);\n\t\t\tcurrentWeather.precipitationProbability = parseFloat(weather.precipitation_probability);\n\t\t\tcurrentWeather.uv_index = parseFloat(weather.uv_index);\n\n\t\t\thours.push(currentWeather);\n\t\t});\n\n\t\treturn hours;\n\t},\n\n\t// Map icons from Dark Sky to our icons.\n\tconvertWeatherType (weathercode, isDayTime) {\n\t\tconst weatherConditions = {\n\t\t\t0: \"clear\",\n\t\t\t1: \"mainly-clear\",\n\t\t\t2: \"partly-cloudy\",\n\t\t\t3: \"overcast\",\n\t\t\t45: \"fog\",\n\t\t\t48: \"depositing-rime-fog\",\n\t\t\t51: \"drizzle-light-intensity\",\n\t\t\t53: \"drizzle-moderate-intensity\",\n\t\t\t55: \"drizzle-dense-intensity\",\n\t\t\t56: \"freezing-drizzle-light-intensity\",\n\t\t\t57: \"freezing-drizzle-dense-intensity\",\n\t\t\t61: \"rain-slight-intensity\",\n\t\t\t63: \"rain-moderate-intensity\",\n\t\t\t65: \"rain-heavy-intensity\",\n\t\t\t66: \"freezing-rain-light-intensity\",\n\t\t\t67: \"freezing-rain-heavy-intensity\",\n\t\t\t71: \"snow-fall-slight-intensity\",\n\t\t\t73: \"snow-fall-moderate-intensity\",\n\t\t\t75: \"snow-fall-heavy-intensity\",\n\t\t\t77: \"snow-grains\",\n\t\t\t80: \"rain-showers-slight\",\n\t\t\t81: \"rain-showers-moderate\",\n\t\t\t82: \"rain-showers-violent\",\n\t\t\t85: \"snow-showers-slight\",\n\t\t\t86: \"snow-showers-heavy\",\n\t\t\t95: \"thunderstorm\",\n\t\t\t96: \"thunderstorm-slight-hail\",\n\t\t\t99: \"thunderstorm-heavy-hail\"\n\t\t};\n\n\t\tif (!Object.keys(weatherConditions).includes(`${weathercode}`)) return null;\n\n\t\tswitch (weatherConditions[`${weathercode}`]) {\n\t\t\tcase \"clear\":\n\t\t\t\treturn isDayTime ? \"day-sunny\" : \"night-clear\";\n\t\t\tcase \"mainly-clear\":\n\t\t\tcase \"partly-cloudy\":\n\t\t\t\treturn isDayTime ? \"day-cloudy\" : \"night-alt-cloudy\";\n\t\t\tcase \"overcast\":\n\t\t\t\treturn isDayTime ? \"day-sunny-overcast\" : \"night-alt-partly-cloudy\";\n\t\t\tcase \"fog\":\n\t\t\tcase \"depositing-rime-fog\":\n\t\t\t\treturn isDayTime ? \"day-fog\" : \"night-fog\";\n\t\t\tcase \"drizzle-light-intensity\":\n\t\t\tcase \"rain-slight-intensity\":\n\t\t\tcase \"rain-showers-slight\":\n\t\t\t\treturn isDayTime ? \"day-sprinkle\" : \"night-sprinkle\";\n\t\t\tcase \"drizzle-moderate-intensity\":\n\t\t\tcase \"rain-moderate-intensity\":\n\t\t\tcase \"rain-showers-moderate\":\n\t\t\t\treturn isDayTime ? \"day-showers\" : \"night-showers\";\n\t\t\tcase \"drizzle-dense-intensity\":\n\t\t\tcase \"rain-heavy-intensity\":\n\t\t\tcase \"rain-showers-violent\":\n\t\t\t\treturn isDayTime ? \"day-thunderstorm\" : \"night-thunderstorm\";\n\t\t\tcase \"freezing-rain-light-intensity\":\n\t\t\t\treturn isDayTime ? \"day-rain-mix\" : \"night-rain-mix\";\n\t\t\tcase \"freezing-drizzle-light-intensity\":\n\t\t\tcase \"freezing-drizzle-dense-intensity\":\n\t\t\t\treturn \"snowflake-cold\";\n\t\t\tcase \"snow-grains\":\n\t\t\t\treturn isDayTime ? \"day-sleet\" : \"night-sleet\";\n\t\t\tcase \"snow-fall-slight-intensity\":\n\t\t\tcase \"snow-fall-moderate-intensity\":\n\t\t\t\treturn isDayTime ? \"day-snow-wind\" : \"night-snow-wind\";\n\t\t\tcase \"snow-fall-heavy-intensity\":\n\t\t\tcase \"freezing-rain-heavy-intensity\":\n\t\t\t\treturn isDayTime ? \"day-snow-thunderstorm\" : \"night-snow-thunderstorm\";\n\t\t\tcase \"snow-showers-slight\":\n\t\t\tcase \"snow-showers-heavy\":\n\t\t\t\treturn isDayTime ? \"day-rain-mix\" : \"night-rain-mix\";\n\t\t\tcase \"thunderstorm\":\n\t\t\t\treturn isDayTime ? \"day-thunderstorm\" : \"night-thunderstorm\";\n\t\t\tcase \"thunderstorm-slight-hail\":\n\t\t\t\treturn isDayTime ? \"day-sleet\" : \"night-sleet\";\n\t\t\tcase \"thunderstorm-heavy-hail\":\n\t\t\t\treturn isDayTime ? \"day-sleet-storm\" : \"night-sleet-storm\";\n\t\t\tdefault:\n\t\t\t\treturn \"na\";\n\t\t}\n\t},\n\n\t// Define required scripts.\n\tgetScripts () {\n\t\treturn [\"moment.js\"];\n\t}\n});\n"
  },
  {
    "path": "modules/default/weather/providers/openweathermap.js",
    "content": "/* global WeatherProvider, WeatherObject */\n\n/*\n * This class is a provider for Openweathermap,\n * see https://openweathermap.org/\n */\nWeatherProvider.register(\"openweathermap\", {\n\n\t/*\n\t * Set the name of the provider.\n\t * This isn't strictly necessary, since it will fallback to the provider identifier\n\t * But for debugging (and future alerts) it would be nice to have the real name.\n\t */\n\tproviderName: \"OpenWeatherMap\",\n\n\t// Set the default config properties that is specific to this provider\n\tdefaults: {\n\t\tapiVersion: \"3.0\",\n\t\tapiBase: \"https://api.openweathermap.org/data/\",\n\t\t// weatherEndpoint is \"/onecall\" since API 3.0\n\t\t// \"/onecall\", \"/forecast\" or \"/weather\" only for pro customers\n\t\tweatherEndpoint: \"/onecall\",\n\t\tlocationID: false,\n\t\tlocation: false,\n\t\t// the /onecall endpoint needs lat / lon values, it doesn't support the locationId\n\t\tlat: 0,\n\t\tlon: 0,\n\t\tapiKey: \"\"\n\t},\n\n\t// Overwrite the fetchCurrentWeather method.\n\tfetchCurrentWeather () {\n\t\tthis.fetchData(this.getUrl())\n\t\t\t.then((data) => {\n\t\t\t\tlet currentWeather;\n\t\t\t\tif (this.config.weatherEndpoint === \"/onecall\") {\n\t\t\t\t\tcurrentWeather = this.generateWeatherObjectsFromOnecall(data).current;\n\t\t\t\t\tthis.setFetchedLocation(`${data.timezone}`);\n\t\t\t\t} else {\n\t\t\t\t\tcurrentWeather = this.generateWeatherObjectFromCurrentWeather(data);\n\t\t\t\t}\n\t\t\t\tthis.setCurrentWeather(currentWeather);\n\t\t\t})\n\t\t\t.catch(function (request) {\n\t\t\t\tLog.error(\"[weatherprovider.openweathermap] Could not load data ... \", request);\n\t\t\t})\n\t\t\t.finally(() => this.updateAvailable());\n\t},\n\n\t// Overwrite the fetchWeatherForecast method.\n\tfetchWeatherForecast () {\n\t\tthis.fetchData(this.getUrl())\n\t\t\t.then((data) => {\n\t\t\t\tlet forecast;\n\t\t\t\tlet location;\n\t\t\t\tif (this.config.weatherEndpoint === \"/onecall\") {\n\t\t\t\t\tforecast = this.generateWeatherObjectsFromOnecall(data).days;\n\t\t\t\t\tlocation = `${data.timezone}`;\n\t\t\t\t} else {\n\t\t\t\t\tforecast = this.generateWeatherObjectsFromForecast(data.list);\n\t\t\t\t\tlocation = `${data.city.name}, ${data.city.country}`;\n\t\t\t\t}\n\t\t\t\tthis.setWeatherForecast(forecast);\n\t\t\t\tthis.setFetchedLocation(location);\n\t\t\t})\n\t\t\t.catch(function (request) {\n\t\t\t\tLog.error(\"[weatherprovider.openweathermap] Could not load data ... \", request);\n\t\t\t})\n\t\t\t.finally(() => this.updateAvailable());\n\t},\n\n\t// Overwrite the fetchWeatherHourly method.\n\tfetchWeatherHourly () {\n\t\tthis.fetchData(this.getUrl())\n\t\t\t.then((data) => {\n\t\t\t\tif (!data) {\n\n\t\t\t\t\t/*\n\t\t\t\t\t * Did not receive usable new data.\n\t\t\t\t\t * Maybe this needs a better check?\n\t\t\t\t\t */\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tthis.setFetchedLocation(`(${data.lat},${data.lon})`);\n\n\t\t\t\tconst weatherData = this.generateWeatherObjectsFromOnecall(data);\n\t\t\t\tthis.setWeatherHourly(weatherData.hours);\n\t\t\t})\n\t\t\t.catch(function (request) {\n\t\t\t\tLog.error(\"[weatherprovider.openweathermap] Could not load data ... \", request);\n\t\t\t})\n\t\t\t.finally(() => this.updateAvailable());\n\t},\n\n\t/** OpenWeatherMap Specific Methods - These are not part of the default provider methods */\n\t/*\n\t * Gets the complete url for the request\n\t */\n\tgetUrl () {\n\t\treturn this.config.apiBase + this.config.apiVersion + this.config.weatherEndpoint + this.getParams();\n\t},\n\n\t/*\n\t * Generate a WeatherObject based on currentWeatherInformation\n\t */\n\tgenerateWeatherObjectFromCurrentWeather (currentWeatherData) {\n\t\tconst currentWeather = new WeatherObject();\n\n\t\tcurrentWeather.date = moment.unix(currentWeatherData.dt);\n\t\tcurrentWeather.humidity = currentWeatherData.main.humidity;\n\t\tcurrentWeather.temperature = currentWeatherData.main.temp;\n\t\tcurrentWeather.feelsLikeTemp = currentWeatherData.main.feels_like;\n\t\tcurrentWeather.windSpeed = currentWeatherData.wind.speed;\n\t\tcurrentWeather.windFromDirection = currentWeatherData.wind.deg;\n\t\tcurrentWeather.weatherType = this.convertWeatherType(currentWeatherData.weather[0].icon);\n\t\tcurrentWeather.sunrise = moment.unix(currentWeatherData.sys.sunrise);\n\t\tcurrentWeather.sunset = moment.unix(currentWeatherData.sys.sunset);\n\n\t\treturn currentWeather;\n\t},\n\n\t/*\n\t * Generate WeatherObjects based on forecast information\n\t */\n\tgenerateWeatherObjectsFromForecast (forecasts) {\n\t\tif (this.config.weatherEndpoint === \"/forecast\") {\n\t\t\treturn this.generateForecastHourly(forecasts);\n\t\t} else if (this.config.weatherEndpoint === \"/forecast/daily\") {\n\t\t\treturn this.generateForecastDaily(forecasts);\n\t\t}\n\t\t// if weatherEndpoint does not match forecast or forecast/daily, what should be returned?\n\t\treturn [new WeatherObject()];\n\t},\n\n\t/*\n\t * Generate WeatherObjects based on One Call forecast information\n\t */\n\tgenerateWeatherObjectsFromOnecall (data) {\n\t\tif (this.config.weatherEndpoint === \"/onecall\") {\n\t\t\treturn this.fetchOnecall(data);\n\t\t}\n\t\t// if weatherEndpoint does not match onecall, what should be returned?\n\t\treturn { current: new WeatherObject(), hours: [], days: [] };\n\t},\n\n\t/*\n\t * Generate forecast information for 3-hourly forecast (available for free\n\t * subscription).\n\t */\n\tgenerateForecastHourly (forecasts) {\n\t\t// initial variable declaration\n\t\tconst days = [];\n\t\t// variables for temperature range and rain\n\t\tlet minTemp = [];\n\t\tlet maxTemp = [];\n\t\tlet rain = 0;\n\t\tlet snow = 0;\n\t\t// variable for date\n\t\tlet date = \"\";\n\t\tlet weather = new WeatherObject();\n\n\t\tfor (const forecast of forecasts) {\n\t\t\tif (date !== moment.unix(forecast.dt).format(\"YYYY-MM-DD\")) {\n\t\t\t\t// calculate minimum/maximum temperature, specify rain amount\n\t\t\t\tweather.minTemperature = Math.min.apply(null, minTemp);\n\t\t\t\tweather.maxTemperature = Math.max.apply(null, maxTemp);\n\t\t\t\tweather.rain = rain;\n\t\t\t\tweather.snow = snow;\n\t\t\t\tweather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0);\n\t\t\t\t// push weather information to days array\n\t\t\t\tdays.push(weather);\n\t\t\t\t// create new weather-object\n\t\t\t\tweather = new WeatherObject();\n\n\t\t\t\tminTemp = [];\n\t\t\t\tmaxTemp = [];\n\t\t\t\train = 0;\n\t\t\t\tsnow = 0;\n\n\t\t\t\t// set new date\n\t\t\t\tdate = moment.unix(forecast.dt).format(\"YYYY-MM-DD\");\n\n\t\t\t\t// specify date\n\t\t\t\tweather.date = moment.unix(forecast.dt);\n\n\t\t\t\t// If the first value of today is later than 17:00, we have an icon at least!\n\t\t\t\tweather.weatherType = this.convertWeatherType(forecast.weather[0].icon);\n\t\t\t}\n\n\t\t\tif (moment.unix(forecast.dt).format(\"H\") >= 8 && moment.unix(forecast.dt).format(\"H\") <= 17) {\n\t\t\t\tweather.weatherType = this.convertWeatherType(forecast.weather[0].icon);\n\t\t\t}\n\n\t\t\t/*\n\t\t\t * the same day as before\n\t\t\t * add values from forecast to corresponding variables\n\t\t\t */\n\t\t\tminTemp.push(forecast.main.temp_min);\n\t\t\tmaxTemp.push(forecast.main.temp_max);\n\n\t\t\tif (forecast.hasOwnProperty(\"rain\") && !isNaN(forecast.rain[\"3h\"])) {\n\t\t\t\train += forecast.rain[\"3h\"];\n\t\t\t}\n\n\t\t\tif (forecast.hasOwnProperty(\"snow\") && !isNaN(forecast.snow[\"3h\"])) {\n\t\t\t\tsnow += forecast.snow[\"3h\"];\n\t\t\t}\n\t\t}\n\n\t\t/*\n\t\t * last day\n\t\t * calculate minimum/maximum temperature, specify rain amount\n\t\t */\n\t\tweather.minTemperature = Math.min.apply(null, minTemp);\n\t\tweather.maxTemperature = Math.max.apply(null, maxTemp);\n\t\tweather.rain = rain;\n\t\tweather.snow = snow;\n\t\tweather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0);\n\t\t// push weather information to days array\n\t\tdays.push(weather);\n\t\treturn days.slice(1);\n\t},\n\n\t/*\n\t * Generate forecast information for daily forecast (available for paid\n\t * subscription or old apiKey).\n\t */\n\tgenerateForecastDaily (forecasts) {\n\t\t// initial variable declaration\n\t\tconst days = [];\n\n\t\tfor (const forecast of forecasts) {\n\t\t\tconst weather = new WeatherObject();\n\n\t\t\tweather.date = moment.unix(forecast.dt);\n\t\t\tweather.minTemperature = forecast.temp.min;\n\t\t\tweather.maxTemperature = forecast.temp.max;\n\t\t\tweather.weatherType = this.convertWeatherType(forecast.weather[0].icon);\n\t\t\tweather.rain = 0;\n\t\t\tweather.snow = 0;\n\n\t\t\t/*\n\t\t\t * forecast.rain not available if amount is zero\n\t\t\t * The API always returns in millimeters\n\t\t\t */\n\t\t\tif (forecast.hasOwnProperty(\"rain\") && !isNaN(forecast.rain)) {\n\t\t\t\tweather.rain = forecast.rain;\n\t\t\t}\n\n\t\t\t/*\n\t\t\t * forecast.snow not available if amount is zero\n\t\t\t * The API always returns in millimeters\n\t\t\t */\n\t\t\tif (forecast.hasOwnProperty(\"snow\") && !isNaN(forecast.snow)) {\n\t\t\t\tweather.snow = forecast.snow;\n\t\t\t}\n\n\t\t\tweather.precipitationAmount = weather.rain + weather.snow;\n\t\t\tweather.precipitationProbability = forecast.pop ? forecast.pop * 100 : undefined;\n\n\t\t\tdays.push(weather);\n\t\t}\n\n\t\treturn days;\n\t},\n\n\t/*\n\t * Fetch One Call forecast information (available for free subscription).\n\t * Factors in timezone offsets.\n\t * Minutely forecasts are excluded for the moment, see getParams().\n\t */\n\tfetchOnecall (data) {\n\t\tlet precip = false;\n\n\t\t// get current weather, if requested\n\t\tconst current = new WeatherObject();\n\t\tif (data.hasOwnProperty(\"current\")) {\n\t\t\tcurrent.date = moment.unix(data.current.dt).utcOffset(data.timezone_offset / 60);\n\t\t\tcurrent.windSpeed = data.current.wind_speed;\n\t\t\tcurrent.windFromDirection = data.current.wind_deg;\n\t\t\tcurrent.sunrise = moment.unix(data.current.sunrise).utcOffset(data.timezone_offset / 60);\n\t\t\tcurrent.sunset = moment.unix(data.current.sunset).utcOffset(data.timezone_offset / 60);\n\t\t\tcurrent.temperature = data.current.temp;\n\t\t\tcurrent.weatherType = this.convertWeatherType(data.current.weather[0].icon);\n\t\t\tcurrent.humidity = data.current.humidity;\n\t\t\tcurrent.uv_index = data.current.uvi;\n\t\t\tif (data.current.hasOwnProperty(\"rain\") && !isNaN(data.current.rain[\"1h\"])) {\n\t\t\t\tcurrent.rain = data.current.rain[\"1h\"];\n\t\t\t\tprecip = true;\n\t\t\t}\n\t\t\tif (data.current.hasOwnProperty(\"snow\") && !isNaN(data.current.snow[\"1h\"])) {\n\t\t\t\tcurrent.snow = data.current.snow[\"1h\"];\n\t\t\t\tprecip = true;\n\t\t\t}\n\t\t\tif (precip) {\n\t\t\t\tcurrent.precipitationAmount = (current.rain ?? 0) + (current.snow ?? 0);\n\t\t\t}\n\t\t\tcurrent.feelsLikeTemp = data.current.feels_like;\n\t\t}\n\n\t\tlet weather = new WeatherObject();\n\n\t\t// get hourly weather, if requested\n\t\tconst hours = [];\n\t\tif (data.hasOwnProperty(\"hourly\")) {\n\t\t\tfor (const hour of data.hourly) {\n\t\t\t\tweather.date = moment.unix(hour.dt).utcOffset(data.timezone_offset / 60);\n\t\t\t\tweather.temperature = hour.temp;\n\t\t\t\tweather.feelsLikeTemp = hour.feels_like;\n\t\t\t\tweather.humidity = hour.humidity;\n\t\t\t\tweather.windSpeed = hour.wind_speed;\n\t\t\t\tweather.windFromDirection = hour.wind_deg;\n\t\t\t\tweather.weatherType = this.convertWeatherType(hour.weather[0].icon);\n\t\t\t\tweather.precipitationProbability = hour.pop ? hour.pop * 100 : undefined;\n\t\t\t\tweather.uv_index = hour.uvi;\n\t\t\t\tprecip = false;\n\t\t\t\tif (hour.hasOwnProperty(\"rain\") && !isNaN(hour.rain[\"1h\"])) {\n\t\t\t\t\tweather.rain = hour.rain[\"1h\"];\n\t\t\t\t\tprecip = true;\n\t\t\t\t}\n\t\t\t\tif (hour.hasOwnProperty(\"snow\") && !isNaN(hour.snow[\"1h\"])) {\n\t\t\t\t\tweather.snow = hour.snow[\"1h\"];\n\t\t\t\t\tprecip = true;\n\t\t\t\t}\n\t\t\t\tif (precip) {\n\t\t\t\t\tweather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0);\n\t\t\t\t}\n\n\t\t\t\thours.push(weather);\n\t\t\t\tweather = new WeatherObject();\n\t\t\t}\n\t\t}\n\n\t\t// get daily weather, if requested\n\t\tconst days = [];\n\t\tif (data.hasOwnProperty(\"daily\")) {\n\t\t\tfor (const day of data.daily) {\n\t\t\t\tweather.date = moment.unix(day.dt).utcOffset(data.timezone_offset / 60);\n\t\t\t\tweather.sunrise = moment.unix(day.sunrise).utcOffset(data.timezone_offset / 60);\n\t\t\t\tweather.sunset = moment.unix(day.sunset).utcOffset(data.timezone_offset / 60);\n\t\t\t\tweather.minTemperature = day.temp.min;\n\t\t\t\tweather.maxTemperature = day.temp.max;\n\t\t\t\tweather.humidity = day.humidity;\n\t\t\t\tweather.windSpeed = day.wind_speed;\n\t\t\t\tweather.windFromDirection = day.wind_deg;\n\t\t\t\tweather.weatherType = this.convertWeatherType(day.weather[0].icon);\n\t\t\t\tweather.precipitationProbability = day.pop ? day.pop * 100 : undefined;\n\t\t\t\tweather.uv_index = day.uvi;\n\t\t\t\tprecip = false;\n\t\t\t\tif (!isNaN(day.rain)) {\n\t\t\t\t\tweather.rain = day.rain;\n\t\t\t\t\tprecip = true;\n\t\t\t\t}\n\t\t\t\tif (!isNaN(day.snow)) {\n\t\t\t\t\tweather.snow = day.snow;\n\t\t\t\t\tprecip = true;\n\t\t\t\t}\n\t\t\t\tif (precip) {\n\t\t\t\t\tweather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0);\n\t\t\t\t}\n\n\t\t\t\tdays.push(weather);\n\t\t\t\tweather = new WeatherObject();\n\t\t\t}\n\t\t}\n\n\t\treturn { current: current, hours: hours, days: days };\n\t},\n\n\t/*\n\t * Convert the OpenWeatherMap icons to a more usable name.\n\t */\n\tconvertWeatherType (weatherType) {\n\t\tconst weatherTypes = {\n\t\t\t\"01d\": \"day-sunny\",\n\t\t\t\"02d\": \"day-cloudy\",\n\t\t\t\"03d\": \"cloudy\",\n\t\t\t\"04d\": \"cloudy-windy\",\n\t\t\t\"09d\": \"showers\",\n\t\t\t\"10d\": \"rain\",\n\t\t\t\"11d\": \"thunderstorm\",\n\t\t\t\"13d\": \"snow\",\n\t\t\t\"50d\": \"fog\",\n\t\t\t\"01n\": \"night-clear\",\n\t\t\t\"02n\": \"night-cloudy\",\n\t\t\t\"03n\": \"night-cloudy\",\n\t\t\t\"04n\": \"night-cloudy\",\n\t\t\t\"09n\": \"night-showers\",\n\t\t\t\"10n\": \"night-rain\",\n\t\t\t\"11n\": \"night-thunderstorm\",\n\t\t\t\"13n\": \"night-snow\",\n\t\t\t\"50n\": \"night-alt-cloudy-windy\"\n\t\t};\n\n\t\treturn weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null;\n\t},\n\n\t/*\n\t * getParams(compliments)\n\t * Generates an url with api parameters based on the config.\n\t *\n\t * return String - URL params.\n\t */\n\tgetParams () {\n\t\tlet params = \"?\";\n\t\tif (this.config.weatherEndpoint === \"/onecall\") {\n\t\t\tparams += `lat=${this.config.lat}`;\n\t\t\tparams += `&lon=${this.config.lon}`;\n\t\t\tif (this.config.type === \"current\") {\n\t\t\t\tparams += \"&exclude=minutely,hourly,daily\";\n\t\t\t} else if (this.config.type === \"hourly\") {\n\t\t\t\tparams += \"&exclude=current,minutely,daily\";\n\t\t\t} else if (this.config.type === \"daily\" || this.config.type === \"forecast\") {\n\t\t\t\tparams += \"&exclude=current,minutely,hourly\";\n\t\t\t} else {\n\t\t\t\tparams += \"&exclude=minutely\";\n\t\t\t}\n\t\t} else if (this.config.lat && this.config.lon) {\n\t\t\tparams += `lat=${this.config.lat}&lon=${this.config.lon}`;\n\t\t} else if (this.config.locationID) {\n\t\t\tparams += `id=${this.config.locationID}`;\n\t\t} else if (this.config.location) {\n\t\t\tparams += `q=${this.config.location}`;\n\t\t} else if (this.firstEvent && this.firstEvent.geo) {\n\t\t\tparams += `lat=${this.firstEvent.geo.lat}&lon=${this.firstEvent.geo.lon}`;\n\t\t} else if (this.firstEvent && this.firstEvent.location) {\n\t\t\tparams += `q=${this.firstEvent.location}`;\n\t\t} else {\n\t\t\t// TODO hide doesn't exist!\n\t\t\tthis.hide(this.config.animationSpeed, { lockString: this.identifier });\n\t\t\treturn;\n\t\t}\n\n\t\tparams += \"&units=metric\"; // WeatherProviders should use metric internally and use the units only for when displaying data\n\t\tparams += `&lang=${this.config.lang}`;\n\t\tparams += `&APPID=${this.config.apiKey}`;\n\n\t\treturn params;\n\t}\n});\n"
  },
  {
    "path": "modules/default/weather/providers/overrideWrapper.js",
    "content": "/* global Class, WeatherObject */\n\n/*\n * Wrapper class to enable overrides of currentOverrideWeatherObject.\n *\n * Sits between the weather.js module and the provider implementations to allow us to\n * combine the incoming data from the CURRENT_WEATHER_OVERRIDE notification with the\n * existing data received from the current api provider. If no notifications have\n * been received then the api provider's data is used.\n *\n * The intent is to allow partial WeatherObjects from local sensors to augment or\n * replace the WeatherObjects from the api providers.\n *\n * This class shares the signature of WeatherProvider, and passes any methods not\n * concerning the current weather directly to the api provider implementation that\n * is currently in use.\n */\nconst OverrideWrapper = Class.extend({\n\tbaseProvider: null,\n\tproviderName: \"localWrapper\",\n\tnotificationWeatherObject: null,\n\tcurrentOverrideWeatherObject: null,\n\n\tinit (baseProvider) {\n\t\tthis.baseProvider = baseProvider;\n\n\t\t// Binding the scope of current weather functions so any fetchData calls with\n\t\t// setCurrentWeather nested in them call this classes implementation instead\n\t\t// of the provider's default\n\t\tthis.baseProvider.setCurrentWeather = this.setCurrentWeather.bind(this);\n\t\tthis.baseProvider.currentWeather = this.currentWeather.bind(this);\n\t},\n\n\t/* Unchanged Api Provider Methods */\n\n\tsetConfig (config) {\n\t\tthis.baseProvider.setConfig(config);\n\t},\n\tstart () {\n\t\tthis.baseProvider.start();\n\t},\n\tfetchCurrentWeather () {\n\t\tthis.baseProvider.fetchCurrentWeather();\n\t},\n\tfetchWeatherForecast () {\n\t\tthis.baseProvider.fetchWeatherForecast();\n\t},\n\tfetchWeatherHourly () {\n\t\tthis.baseProvider.fetchWeatherHourly();\n\t},\n\tweatherForecast () {\n\t\tthis.baseProvider.weatherForecast();\n\t},\n\tweatherHourly () {\n\t\tthis.baseProvider.weatherHourly();\n\t},\n\tfetchedLocation () {\n\t\tthis.baseProvider.fetchedLocation();\n\t},\n\tsetWeatherForecast (weatherForecastArray) {\n\t\tthis.baseProvider.setWeatherForecast(weatherForecastArray);\n\t},\n\tsetWeatherHourly (weatherHourlyArray) {\n\t\tthis.baseProvider.setWeatherHourly(weatherHourlyArray);\n\t},\n\tsetFetchedLocation (name) {\n\t\tthis.baseProvider.setFetchedLocation(name);\n\t},\n\tupdateAvailable () {\n\t\tthis.baseProvider.updateAvailable();\n\t},\n\tasync fetchData (url, type = \"json\", requestHeaders = undefined, expectedResponseHeaders = undefined) {\n\t\tthis.baseProvider.fetchData(url, type, requestHeaders, expectedResponseHeaders);\n\t},\n\n\t/* Override Methods */\n\n\t/**\n\t * Override to return this scope's\n\t * @returns {WeatherObject} The current weather object. May or may not contain overridden data.\n\t */\n\tcurrentWeather () {\n\t\treturn this.currentOverrideWeatherObject;\n\t},\n\n\t/**\n\t * Override to combine the overrideWeatherObject provided in the\n\t * notificationReceived method with the currentOverrideWeatherObject provided by the\n\t * api provider fetchData implementation.\n\t * @param {WeatherObject} currentWeatherObject - the api provider weather object\n\t */\n\tsetCurrentWeather (currentWeatherObject) {\n\t\tthis.currentOverrideWeatherObject = Object.assign(currentWeatherObject, this.notificationWeatherObject);\n\t},\n\n\t/**\n\t * Updates the overrideWeatherObject, calls setCurrentWeather to combine it with\n\t * the existing current weather object provided by the base provider, and signals\n\t * that an update is ready.\n\t * @param {WeatherObject} payload - the weather object received from the CURRENT_WEATHER_OVERRIDE\n\t *                                  notification. Represents information to augment the\n\t *                                  existing currentOverrideWeatherObject with.\n\t */\n\tnotificationReceived (payload) {\n\t\tthis.notificationWeatherObject = payload;\n\n\t\t// setCurrentWeather combines the newly received notification weather with\n\t\t// the existing weather object we return for current weather\n\t\tthis.setCurrentWeather(this.currentOverrideWeatherObject);\n\t\tthis.updateAvailable();\n\t}\n});\n"
  },
  {
    "path": "modules/default/weather/providers/pirateweather.js",
    "content": "/* global WeatherProvider, WeatherObject */\n\n/*\n * This class is a provider for Pirate Weather, it is a replacement for Dark Sky (same api),\n * see http://pirateweather.net/en/latest/\n */\nWeatherProvider.register(\"pirateweather\", {\n\n\t/*\n\t * Set the name of the provider.\n\t * Not strictly required, but helps for debugging.\n\t */\n\tproviderName: \"pirateweather\",\n\n\t// Set the default config properties that is specific to this provider\n\tdefaults: {\n\t\tuseCorsProxy: true,\n\t\tapiBase: \"https://api.pirateweather.net\",\n\t\tweatherEndpoint: \"/forecast\",\n\t\tapiKey: \"\",\n\t\tlat: 0,\n\t\tlon: 0\n\t},\n\n\tasync fetchCurrentWeather () {\n\t\ttry {\n\t\t\tconst data = await this.fetchData(this.getUrl());\n\t\t\tif (!data || !data.currently || typeof data.currently.temperature === \"undefined\") {\n\t\t\t\tthrow new Error(\"No usable data received from Pirate Weather API.\");\n\t\t\t}\n\n\t\t\tconst currentWeather = this.generateWeatherDayFromCurrentWeather(data);\n\t\t\tthis.setCurrentWeather(currentWeather);\n\t\t} catch (error) {\n\t\t\tLog.error(\"Could not load data ... \", error);\n\t\t} finally {\n\t\t\tthis.updateAvailable();\n\t\t}\n\t},\n\n\tasync fetchWeatherForecast () {\n\t\ttry {\n\t\t\tconst data = await this.fetchData(this.getUrl());\n\t\t\tif (!data || !data.daily || !data.daily.data.length) {\n\t\t\t\tthrow new Error(\"No usable data received from Pirate Weather API.\");\n\t\t\t}\n\n\t\t\tconst forecast = this.generateWeatherObjectsFromForecast(data.daily.data);\n\t\t\tthis.setWeatherForecast(forecast);\n\t\t} catch (error) {\n\t\t\tLog.error(\"Could not load data ... \", error);\n\t\t} finally {\n\t\t\tthis.updateAvailable();\n\t\t}\n\t},\n\n\t// Create a URL from the config and base URL.\n\tgetUrl () {\n\t\treturn `${this.config.apiBase}${this.config.weatherEndpoint}/${this.config.apiKey}/${this.config.lat},${this.config.lon}?units=si&lang=${this.config.lang}`;\n\t},\n\n\t// Implement WeatherDay generator.\n\tgenerateWeatherDayFromCurrentWeather (currentWeatherData) {\n\t\tconst currentWeather = new WeatherObject();\n\n\t\tcurrentWeather.date = moment();\n\t\tcurrentWeather.humidity = parseFloat(currentWeatherData.currently.humidity);\n\t\tcurrentWeather.temperature = parseFloat(currentWeatherData.currently.temperature);\n\t\tcurrentWeather.windSpeed = parseFloat(currentWeatherData.currently.windSpeed);\n\t\tcurrentWeather.windFromDirection = currentWeatherData.currently.windBearing;\n\t\tcurrentWeather.weatherType = this.convertWeatherType(currentWeatherData.currently.icon);\n\t\tcurrentWeather.sunrise = moment.unix(currentWeatherData.daily.data[0].sunriseTime);\n\t\tcurrentWeather.sunset = moment.unix(currentWeatherData.daily.data[0].sunsetTime);\n\n\t\treturn currentWeather;\n\t},\n\n\tgenerateWeatherObjectsFromForecast (forecasts) {\n\t\tconst days = [];\n\n\t\tfor (const forecast of forecasts) {\n\t\t\tconst weather = new WeatherObject();\n\n\t\t\tweather.date = moment.unix(forecast.time);\n\t\t\tweather.minTemperature = forecast.temperatureMin;\n\t\t\tweather.maxTemperature = forecast.temperatureMax;\n\t\t\tweather.weatherType = this.convertWeatherType(forecast.icon);\n\t\t\tweather.snow = 0;\n\t\t\tweather.rain = 0;\n\n\t\t\tlet precip = 0;\n\t\t\tif (forecast.hasOwnProperty(\"precipAccumulation\")) {\n\t\t\t\tprecip = forecast.precipAccumulation * 10;\n\t\t\t}\n\n\t\t\tweather.precipitationAmount = precip;\n\t\t\tif (forecast.hasOwnProperty(\"precipType\")) {\n\t\t\t\tif (forecast.precipType === \"snow\") {\n\t\t\t\t\tweather.snow = precip;\n\t\t\t\t} else {\n\t\t\t\t\tweather.rain = precip;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tdays.push(weather);\n\t\t}\n\n\t\treturn days;\n\t},\n\n\t// Map icons from Pirate Weather to our icons.\n\tconvertWeatherType (weatherType) {\n\t\tconst weatherTypes = {\n\t\t\t\"clear-day\": \"day-sunny\",\n\t\t\t\"clear-night\": \"night-clear\",\n\t\t\train: \"rain\",\n\t\t\tsnow: \"snow\",\n\t\t\tsleet: \"snow\",\n\t\t\twind: \"windy\",\n\t\t\tfog: \"fog\",\n\t\t\tcloudy: \"cloudy\",\n\t\t\t\"partly-cloudy-day\": \"day-cloudy\",\n\t\t\t\"partly-cloudy-night\": \"night-cloudy\"\n\t\t};\n\n\t\treturn weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null;\n\t}\n});\n"
  },
  {
    "path": "modules/default/weather/providers/smhi.js",
    "content": "/* global WeatherProvider, WeatherObject */\n\n/*\n * This class is a provider for SMHI (Sweden only).\n * Metric system is the only supported unit,\n * see https://www.smhi.se/\n */\nWeatherProvider.register(\"smhi\", {\n\tproviderName: \"SMHI\",\n\n\t// Set the default config properties that is specific to this provider\n\tdefaults: {\n\t\tlat: 0, // Cant have more than 6 digits\n\t\tlon: 0, // Cant have more than 6 digits\n\t\tprecipitationValue: \"pmedian\",\n\t\tlocation: false\n\t},\n\n\t/**\n\t * Implements method in interface for fetching current weather.\n\t */\n\tfetchCurrentWeather () {\n\t\tthis.fetchData(this.getURL())\n\t\t\t.then((data) => {\n\t\t\t\tconst closest = this.getClosestToCurrentTime(data.timeSeries);\n\t\t\t\tconst coordinates = this.resolveCoordinates(data);\n\t\t\t\tconst weatherObject = this.convertWeatherDataToObject(closest, coordinates);\n\t\t\t\tthis.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`);\n\t\t\t\tthis.setCurrentWeather(weatherObject);\n\t\t\t})\n\t\t\t.catch((error) => Log.error(`[weatherprovider.smhi] Could not load data: ${error.message}`))\n\t\t\t.finally(() => this.updateAvailable());\n\t},\n\n\t/**\n\t * Implements method in interface for fetching a multi-day forecast.\n\t */\n\tfetchWeatherForecast () {\n\t\tthis.fetchData(this.getURL())\n\t\t\t.then((data) => {\n\t\t\t\tconst coordinates = this.resolveCoordinates(data);\n\t\t\t\tconst weatherObjects = this.convertWeatherDataGroupedBy(data.timeSeries, coordinates);\n\t\t\t\tthis.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`);\n\t\t\t\tthis.setWeatherForecast(weatherObjects);\n\t\t\t})\n\t\t\t.catch((error) => Log.error(`[weatherprovider.smhi] Could not load data: ${error.message}`))\n\t\t\t.finally(() => this.updateAvailable());\n\t},\n\n\t/**\n\t * Implements method in interface for fetching hourly forecasts.\n\t */\n\tfetchWeatherHourly () {\n\t\tthis.fetchData(this.getURL())\n\t\t\t.then((data) => {\n\t\t\t\tconst coordinates = this.resolveCoordinates(data);\n\t\t\t\tconst weatherObjects = this.convertWeatherDataGroupedBy(data.timeSeries, coordinates, \"hour\");\n\t\t\t\tthis.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`);\n\t\t\t\tthis.setWeatherHourly(weatherObjects);\n\t\t\t})\n\t\t\t.catch((error) => Log.error(`[weatherprovider.smhi] Could not load data: ${error.message}`))\n\t\t\t.finally(() => this.updateAvailable());\n\t},\n\n\t/**\n\t * Overrides method for setting config with checks for the precipitationValue being unset or invalid\n\t * @param {object} config The configuration object\n\t */\n\tsetConfig (config) {\n\t\tthis.config = config;\n\t\tif (!config.precipitationValue || [\"pmin\", \"pmean\", \"pmedian\", \"pmax\"].indexOf(config.precipitationValue) === -1) {\n\t\t\tLog.log(`[weatherprovider.smhi] invalid or not set: ${config.precipitationValue}`);\n\t\t\tconfig.precipitationValue = this.defaults.precipitationValue;\n\t\t}\n\t},\n\n\t/**\n\t * Of all the times returned find out which one is closest to the current time, should be the first if the data isn't old.\n\t * @param {object[]} times Array of time objects\n\t * @returns {object} The weatherdata closest to the current time\n\t */\n\tgetClosestToCurrentTime (times) {\n\t\tlet now = moment();\n\t\tlet minDiff = undefined;\n\t\tfor (const time of times) {\n\t\t\tlet diff = Math.abs(moment(time.validTime).diff(now));\n\t\t\tif (!minDiff || diff < Math.abs(moment(minDiff.validTime).diff(now))) {\n\t\t\t\tminDiff = time;\n\t\t\t}\n\t\t}\n\t\treturn minDiff;\n\t},\n\n\t/**\n\t * Get the forecast url for the configured coordinates\n\t * @returns {string} the url for the specified coordinates\n\t */\n\tgetURL () {\n\t\tconst formatter = new Intl.NumberFormat(\"en-US\", {\n\t\t\tminimumFractionDigits: 6,\n\t\t\tmaximumFractionDigits: 6\n\t\t});\n\t\tconst lon = formatter.format(this.config.lon);\n\t\tconst lat = formatter.format(this.config.lat);\n\t\treturn `https://opendata-download-metfcst.smhi.se/api/category/pmp3g/version/2/geotype/point/lon/${lon}/lat/${lat}/data.json`;\n\t},\n\n\t/**\n\t * Calculates the apparent temperature based on known atmospheric data.\n\t * @param {object} weatherData Weatherdata to use for the calculation\n\t * @returns {number} The apparent temperature\n\t */\n\tcalculateApparentTemperature (weatherData) {\n\t\tconst Ta = this.paramValue(weatherData, \"t\");\n\t\tconst rh = this.paramValue(weatherData, \"r\");\n\t\tconst ws = this.paramValue(weatherData, \"ws\");\n\t\tconst p = (rh / 100) * 6.105 * Math.E * ((17.27 * Ta) / (237.7 + Ta));\n\n\t\treturn Ta + 0.33 * p - 0.7 * ws - 4;\n\t},\n\n\t/**\n\t * Converts the returned data into a WeatherObject with required properties set for both current weather and forecast.\n\t * The returned units is always in metric system.\n\t * Requires coordinates to determine if its daytime or nighttime to know which icon to use and also to set sunrise and sunset.\n\t * @param {object} weatherData Weatherdata to convert\n\t * @param {object} coordinates Coordinates of the locations of the weather\n\t * @returns {WeatherObject} The converted weatherdata at the specified location\n\t */\n\tconvertWeatherDataToObject (weatherData, coordinates) {\n\t\tlet currentWeather = new WeatherObject();\n\n\t\tcurrentWeather.date = moment(weatherData.validTime);\n\t\tcurrentWeather.updateSunTime(coordinates.lat, coordinates.lon);\n\t\tcurrentWeather.humidity = this.paramValue(weatherData, \"r\");\n\t\tcurrentWeather.temperature = this.paramValue(weatherData, \"t\");\n\t\tcurrentWeather.windSpeed = this.paramValue(weatherData, \"ws\");\n\t\tcurrentWeather.windFromDirection = this.paramValue(weatherData, \"wd\");\n\t\tcurrentWeather.weatherType = this.convertWeatherType(this.paramValue(weatherData, \"Wsymb2\"), currentWeather.isDayTime());\n\t\tcurrentWeather.feelsLikeTemp = this.calculateApparentTemperature(weatherData);\n\n\t\t/*\n\t\t * Determine the precipitation amount and category and update the\n\t\t * weatherObject with it, the value type to use can be configured or uses\n\t\t * median as default.\n\t\t */\n\t\tlet precipitationValue = this.paramValue(weatherData, this.config.precipitationValue);\n\t\tswitch (this.paramValue(weatherData, \"pcat\")) {\n\t\t\t// 0 = No precipitation\n\t\t\tcase 1: // Snow\n\t\t\t\tcurrentWeather.snow += precipitationValue;\n\t\t\t\tcurrentWeather.precipitationAmount += precipitationValue;\n\t\t\t\tbreak;\n\t\t\tcase 2: // Snow and rain, treat it as 50/50 snow and rain\n\t\t\t\tcurrentWeather.snow += precipitationValue / 2;\n\t\t\t\tcurrentWeather.rain += precipitationValue / 2;\n\t\t\t\tcurrentWeather.precipitationAmount += precipitationValue;\n\t\t\t\tbreak;\n\t\t\tcase 3: // Rain\n\t\t\tcase 4: // Drizzle\n\t\t\tcase 5: // Freezing rain\n\t\t\tcase 6: // Freezing drizzle\n\t\t\t\tcurrentWeather.rain += precipitationValue;\n\t\t\t\tcurrentWeather.precipitationAmount += precipitationValue;\n\t\t\t\tbreak;\n\t\t}\n\n\t\treturn currentWeather;\n\t},\n\n\t/**\n\t * Takes all the data points and converts it to one WeatherObject per day.\n\t * @param {object[]} allWeatherData Array of weatherdata\n\t * @param {object} coordinates Coordinates of the locations of the weather\n\t * @param {string} groupBy The interval to use for grouping the data (day, hour)\n\t * @returns {WeatherObject[]} Array of weather objects\n\t */\n\tconvertWeatherDataGroupedBy (allWeatherData, coordinates, groupBy = \"day\") {\n\t\tlet currentWeather;\n\t\tlet result = [];\n\n\t\tlet allWeatherObjects = this.fillInGaps(allWeatherData).map((weatherData) => this.convertWeatherDataToObject(weatherData, coordinates));\n\t\tlet dayWeatherTypes = [];\n\n\t\tfor (const weatherObject of allWeatherObjects) {\n\t\t\t//If its the first object or if a day/hour change we need to reset the summary object\n\t\t\tif (!currentWeather || !currentWeather.date.isSame(weatherObject.date, groupBy)) {\n\t\t\t\tcurrentWeather = new WeatherObject();\n\t\t\t\tdayWeatherTypes = [];\n\t\t\t\tcurrentWeather.temperature = weatherObject.temperature;\n\t\t\t\tcurrentWeather.date = weatherObject.date;\n\t\t\t\tcurrentWeather.minTemperature = Infinity;\n\t\t\t\tcurrentWeather.maxTemperature = -Infinity;\n\t\t\t\tcurrentWeather.snow = 0;\n\t\t\t\tcurrentWeather.rain = 0;\n\t\t\t\tcurrentWeather.precipitationAmount = 0;\n\t\t\t\tresult.push(currentWeather);\n\t\t\t}\n\n\t\t\t//Keep track of what icons have been used for each hour of daytime and use the middle one for the forecast\n\t\t\tif (weatherObject.isDayTime()) {\n\t\t\t\tdayWeatherTypes.push(weatherObject.weatherType);\n\t\t\t}\n\t\t\tif (dayWeatherTypes.length > 0) {\n\t\t\t\tcurrentWeather.weatherType = dayWeatherTypes[Math.floor(dayWeatherTypes.length / 2)];\n\t\t\t} else {\n\t\t\t\tcurrentWeather.weatherType = weatherObject.weatherType;\n\t\t\t}\n\n\t\t\t//All other properties is either a sum, min or max of each hour\n\t\t\tcurrentWeather.minTemperature = Math.min(currentWeather.minTemperature, weatherObject.temperature);\n\t\t\tcurrentWeather.maxTemperature = Math.max(currentWeather.maxTemperature, weatherObject.temperature);\n\t\t\tcurrentWeather.snow += weatherObject.snow;\n\t\t\tcurrentWeather.rain += weatherObject.rain;\n\t\t\tcurrentWeather.precipitationAmount += weatherObject.precipitationAmount;\n\t\t}\n\n\t\treturn result;\n\t},\n\n\t/**\n\t * Resolve coordinates from the response data (probably preferably to use\n\t * this if it's not matching the config values exactly)\n\t * @param {object} data Response data from the weather service\n\t * @returns {{lon, lat}} the lat/long coordinates of the data\n\t */\n\tresolveCoordinates (data) {\n\t\treturn { lat: data.geometry.coordinates[0][1], lon: data.geometry.coordinates[0][0] };\n\t},\n\n\t/**\n\t * The distance between the data points is increasing in the data the more distant the prediction is.\n\t * Find these gaps and fill them with the previous hours data to make the data returned a complete set.\n\t * @param {object[]} data Response data from the weather service\n\t * @returns {object[]} Given data with filled gaps\n\t */\n\tfillInGaps (data) {\n\t\tlet result = [];\n\t\tfor (let i = 1; i < data.length; i++) {\n\t\t\tlet to = moment(data[i].validTime);\n\t\t\tlet from = moment(data[i - 1].validTime);\n\t\t\tlet hours = moment.duration(to.diff(from)).asHours();\n\t\t\t// For each hour add a datapoint but change the validTime\n\t\t\tfor (let j = 0; j < hours; j++) {\n\t\t\t\tlet current = Object.assign({}, data[i]);\n\t\t\t\tcurrent.validTime = from.clone().add(j, \"hours\").toISOString();\n\t\t\t\tresult.push(current);\n\t\t\t}\n\t\t}\n\t\treturn result;\n\t},\n\n\t/**\n\t * Helper method to get a property from the returned data set.\n\t * @param {object} currentWeatherData Weatherdata to get from\n\t * @param {string} name The name of the property\n\t * @returns {string} The value of the property in the weatherdata\n\t */\n\tparamValue (currentWeatherData, name) {\n\t\treturn currentWeatherData.parameters.filter((p) => p.name === name).flatMap((p) => p.values)[0];\n\t},\n\n\t/**\n\t * Map the icon value from SMHI to an icon that MagicMirror² understands.\n\t * Uses different icons depending on if its daytime or nighttime.\n\t * SMHI's description of what the numeric value means is the comment after the case.\n\t * @param {number} input The SMHI icon value\n\t * @param {boolean} isDayTime True if the icon should be for daytime, false for nighttime\n\t * @returns {string} The icon name for the MagicMirror\n\t */\n\tconvertWeatherType (input, isDayTime) {\n\t\tswitch (input) {\n\t\t\tcase 1:\n\t\t\t\treturn isDayTime ? \"day-sunny\" : \"night-clear\"; // Clear sky\n\t\t\tcase 2:\n\t\t\t\treturn isDayTime ? \"day-sunny-overcast\" : \"night-partly-cloudy\"; // Nearly clear sky\n\t\t\tcase 3:\n\t\t\t\treturn isDayTime ? \"day-cloudy\" : \"night-cloudy\"; // Variable cloudiness\n\t\t\tcase 4:\n\t\t\t\treturn isDayTime ? \"day-cloudy\" : \"night-cloudy\"; // Halfclear sky\n\t\t\tcase 5:\n\t\t\t\treturn \"cloudy\"; // Cloudy sky\n\t\t\tcase 6:\n\t\t\t\treturn \"cloudy\"; // Overcast\n\t\t\tcase 7:\n\t\t\t\treturn \"fog\"; // Fog\n\t\t\tcase 8:\n\t\t\t\treturn \"showers\"; // Light rain showers\n\t\t\tcase 9:\n\t\t\t\treturn \"showers\"; // Moderate rain showers\n\t\t\tcase 10:\n\t\t\t\treturn \"showers\"; // Heavy rain showers\n\t\t\tcase 11:\n\t\t\t\treturn \"thunderstorm\"; // Thunderstorm\n\t\t\tcase 12:\n\t\t\t\treturn \"sleet\"; // Light sleet showers\n\t\t\tcase 13:\n\t\t\t\treturn \"sleet\"; // Moderate sleet showers\n\t\t\tcase 14:\n\t\t\t\treturn \"sleet\"; // Heavy sleet showers\n\t\t\tcase 15:\n\t\t\t\treturn \"snow\"; // Light snow showers\n\t\t\tcase 16:\n\t\t\t\treturn \"snow\"; // Moderate snow showers\n\t\t\tcase 17:\n\t\t\t\treturn \"snow\"; // Heavy snow showers\n\t\t\tcase 18:\n\t\t\t\treturn \"rain\"; // Light rain\n\t\t\tcase 19:\n\t\t\t\treturn \"rain\"; // Moderate rain\n\t\t\tcase 20:\n\t\t\t\treturn \"rain\"; // Heavy rain\n\t\t\tcase 21:\n\t\t\t\treturn \"thunderstorm\"; // Thunder\n\t\t\tcase 22:\n\t\t\t\treturn \"sleet\"; // Light sleet\n\t\t\tcase 23:\n\t\t\t\treturn \"sleet\"; // Moderate sleet\n\t\t\tcase 24:\n\t\t\t\treturn \"sleet\"; // Heavy sleet\n\t\t\tcase 25:\n\t\t\t\treturn \"snow\"; // Light snowfall\n\t\t\tcase 26:\n\t\t\t\treturn \"snow\"; // Moderate snowfall\n\t\t\tcase 27:\n\t\t\t\treturn \"snow\"; // Heavy snowfall\n\t\t\tdefault:\n\t\t\t\treturn \"\";\n\t\t}\n\t}\n});\n"
  },
  {
    "path": "modules/default/weather/providers/ukmetofficedatahub.js",
    "content": "/* global WeatherProvider, WeatherObject */\n\n/*\n * This class is a provider for UK Met Office Data Hub (the replacement for their Data Point services).\n * For more information on Data Hub, see https://www.metoffice.gov.uk/services/data/datapoint/notifications/weather-datahub\n * Data available:\n * \t\tHourly data for next 2 days (\"hourly\") - https://www.metoffice.gov.uk/binaries/content/assets/metofficegovuk/pdf/data/global-spot-data-hourly.pdf\n * \t\t3-hourly data for the next 7 days (\"3hourly\") - https://www.metoffice.gov.uk/binaries/content/assets/metofficegovuk/pdf/data/global-spot-data-3-hourly.pdf\n * \t\tDaily data for the next 7 days (\"daily\") - https://www.metoffice.gov.uk/binaries/content/assets/metofficegovuk/pdf/data/global-spot-data-daily.pdf\n *\n * NOTES\n * This provider requires longitude/latitude coordinates, rather than a location ID (as with the previous Met Office provider)\n * Provide the following in your config.js file:\n * \t\tweatherProvider: \"ukmetofficedatahub\",\n * \t\tapiBase: \"https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/\",\n * \t\tapiKey: \"[YOUR API KEY]\",\n * \t\tlat: [LATITUDE (DECIMAL)],\n * \t\tlon: [LONGITUDE (DECIMAL)]\n *\n * At time of writing, free accounts are limited to 360 requests a day per service (hourly, 3hourly, daily); take this in mind when\n * setting your update intervals. For reference, 360 requests per day is once every 4 minutes.\n *\n * Pay attention to the units of the supplied data from the Met Office - it is given in SI/metric units where applicable:\n * \t- Temperatures are in degrees Celsius (°C)\n * \t- Wind speeds are in metres per second (m/s)\n * \t- Wind direction given in degrees (°)\n * \t- Pressures are in Pascals (Pa)\n * \t- Distances are in metres (m)\n * \t- Probabilities and humidity are given as percentages (%)\n * \t- Precipitation is measured in millimeters (mm) with rates per hour (mm/h)\n *\n * See the PDFs linked above for more information on the data their corresponding units.\n */\n\nWeatherProvider.register(\"ukmetofficedatahub\", {\n\t// Set the name of the provider.\n\tproviderName: \"UK Met Office (DataHub)\",\n\n\t// Set the default config properties that is specific to this provider\n\tdefaults: {\n\t\tapiBase: \"https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/\",\n\t\tapiKey: \"\",\n\t\tlat: 0,\n\t\tlon: 0\n\t},\n\n\t// Build URL with query strings according to DataHub API (https://datahub.metoffice.gov.uk/docs/f/category/site-specific/type/site-specific/api-documentation#get-/point/hourly)\n\tgetUrl (forecastType) {\n\t\tlet queryStrings = \"?\";\n\t\tqueryStrings += `latitude=${this.config.lat}`;\n\t\tqueryStrings += `&longitude=${this.config.lon}`;\n\t\tqueryStrings += `&includeLocationName=${true}`;\n\n\t\t// Return URL, making sure there is a trailing \"/\" in the base URL.\n\t\treturn this.config.apiBase + (this.config.apiBase.endsWith(\"/\") ? \"\" : \"/\") + forecastType + queryStrings;\n\t},\n\n\t/*\n\t * Build the list of headers for the request\n\t * For DataHub requests, the API key/secret are sent in the headers rather than as query strings.\n\t * Headers defined according to Data Hub API (https://datahub.metoffice.gov.uk/docs/f/category/site-specific/type/site-specific/api-documentation#get-/point/hourly)\n\t */\n\tgetHeaders () {\n\t\treturn {\n\t\t\taccept: \"application/json\",\n\t\t\tapikey: this.config.apiKey\n\t\t};\n\t},\n\n\t// Fetch data using supplied URL and request headers\n\tasync fetchWeather (url, headers) {\n\t\tconst response = await fetch(url, { headers: headers });\n\n\t\t// Return JSON data\n\t\treturn response.json();\n\t},\n\n\t// Fetch hourly forecast data (to use for current weather)\n\tfetchCurrentWeather () {\n\t\tthis.fetchWeather(this.getUrl(\"hourly\"), this.getHeaders())\n\t\t\t.then((data) => {\n\t\t\t\t// Check data is usable\n\t\t\t\tif (!data || !data.features || !data.features[0].properties || !data.features[0].properties.timeSeries || data.features[0].properties.timeSeries.length === 0) {\n\n\t\t\t\t\t/*\n\t\t\t\t\t * Did not receive usable new data.\n\t\t\t\t\t * Maybe this needs a better check?\n\t\t\t\t\t */\n\t\t\t\t\tLog.error(\"[weatherprovider.ukmetofficedatahub] Possibly bad current/hourly data?\", data);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// Set location name\n\t\t\t\tthis.setFetchedLocation(`${data.features[0].properties.location.name}`);\n\n\t\t\t\t// Generate current weather data\n\t\t\t\tconst currentWeather = this.generateWeatherObjectFromCurrentWeather(data);\n\t\t\t\tthis.setCurrentWeather(currentWeather);\n\t\t\t})\n\n\t\t\t// Catch any error(s)\n\t\t\t.catch((error) => Log.error(`[weatherprovider.ukmetofficedatahub] Could not load data: ${error.message}`))\n\n\t\t\t// Let the module know there is data available\n\t\t\t.finally(() => this.updateAvailable());\n\t},\n\n\t// Create a WeatherObject using current weather data (data for the current hour)\n\tgenerateWeatherObjectFromCurrentWeather (currentWeatherData) {\n\t\tconst currentWeather = new WeatherObject();\n\n\t\t// Extract the actual forecasts\n\t\tlet forecastDataHours = currentWeatherData.features[0].properties.timeSeries;\n\n\t\t// Define now\n\t\tlet nowUtc = moment.utc();\n\n\t\t// Find hour that contains the current time\n\t\tfor (let hour in forecastDataHours) {\n\t\t\tlet forecastTime = moment.utc(forecastDataHours[hour].time);\n\t\t\tif (nowUtc.isSameOrAfter(forecastTime) && nowUtc.isBefore(moment(forecastTime.add(1, \"h\")))) {\n\t\t\t\tcurrentWeather.date = forecastTime;\n\t\t\t\tcurrentWeather.windSpeed = forecastDataHours[hour].windSpeed10m;\n\t\t\t\tcurrentWeather.windFromDirection = forecastDataHours[hour].windDirectionFrom10m;\n\t\t\t\tcurrentWeather.temperature = forecastDataHours[hour].screenTemperature;\n\t\t\t\tcurrentWeather.minTemperature = forecastDataHours[hour].minScreenAirTemp;\n\t\t\t\tcurrentWeather.maxTemperature = forecastDataHours[hour].maxScreenAirTemp;\n\t\t\t\tcurrentWeather.weatherType = this.convertWeatherType(forecastDataHours[hour].significantWeatherCode);\n\t\t\t\tcurrentWeather.humidity = forecastDataHours[hour].screenRelativeHumidity;\n\t\t\t\tcurrentWeather.rain = forecastDataHours[hour].totalPrecipAmount;\n\t\t\t\tcurrentWeather.snow = forecastDataHours[hour].totalSnowAmount;\n\t\t\t\tcurrentWeather.precipitationProbability = forecastDataHours[hour].probOfPrecipitation;\n\t\t\t\tcurrentWeather.feelsLikeTemp = forecastDataHours[hour].feelsLikeTemperature;\n\n\t\t\t\t/*\n\t\t\t\t * Pass on full details, so they can be used in custom templates\n\t\t\t\t * Note the units of the supplied data when using this (see top of file)\n\t\t\t\t */\n\t\t\t\tcurrentWeather.rawData = forecastDataHours[hour];\n\t\t\t}\n\t\t}\n\n\t\t/*\n\t\t * Determine the sunrise/sunset times - (still) not supplied in UK Met Office data\n\t\t * Passes {longitude, latitude} to SunCalc, could pass height to, but\n\t\t * SunCalc.getTimes doesn't take that into account\n\t\t */\n\t\tcurrentWeather.updateSunTime(this.config.lat, this.config.lon);\n\n\t\treturn currentWeather;\n\t},\n\n\t// Fetch daily forecast data\n\tfetchWeatherForecast () {\n\t\tthis.fetchWeather(this.getUrl(\"daily\"), this.getHeaders())\n\t\t\t.then((data) => {\n\t\t\t\t// Check data is usable\n\t\t\t\tif (!data || !data.features || !data.features[0].properties || !data.features[0].properties.timeSeries || data.features[0].properties.timeSeries.length === 0) {\n\n\t\t\t\t\t/*\n\t\t\t\t\t * Did not receive usable new data.\n\t\t\t\t\t * Maybe this needs a better check?\n\t\t\t\t\t */\n\t\t\t\t\tLog.error(\"[weatherprovider.ukmetofficedatahub] Possibly bad forecast data?\", data);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\t// Set location name\n\t\t\t\tthis.setFetchedLocation(`${data.features[0].properties.location.name}`);\n\n\t\t\t\t// Generate the forecast data\n\t\t\t\tconst forecast = this.generateWeatherObjectsFromForecast(data);\n\t\t\t\tthis.setWeatherForecast(forecast);\n\t\t\t})\n\n\t\t\t// Catch any error(s)\n\t\t\t.catch((error) => Log.error(`[weatherprovider.ukmetofficedatahub] Could not load data: ${error.message}`))\n\n\t\t\t// Let the module know there is new data available\n\t\t\t.finally(() => this.updateAvailable());\n\t},\n\n\t// Create a WeatherObject for each day using daily forecast data\n\tgenerateWeatherObjectsFromForecast (forecasts) {\n\t\tconst dailyForecasts = [];\n\n\t\t// Extract the actual forecasts\n\t\tlet forecastDataDays = forecasts.features[0].properties.timeSeries;\n\n\t\t// Define today\n\t\tlet today = moment.utc().startOf(\"date\");\n\n\t\t// Go through each day in the forecasts\n\t\tfor (let day in forecastDataDays) {\n\t\t\tconst forecastWeather = new WeatherObject();\n\n\t\t\t// Get date of forecast\n\t\t\tlet forecastDate = moment.utc(forecastDataDays[day].time);\n\n\t\t\t// Check if forecast is for today or in the future (i.e., ignore yesterday's forecast)\n\t\t\tif (forecastDate.isSameOrAfter(today)) {\n\t\t\t\tforecastWeather.date = forecastDate;\n\t\t\t\tforecastWeather.minTemperature = forecastDataDays[day].nightMinScreenTemperature;\n\t\t\t\tforecastWeather.maxTemperature = forecastDataDays[day].dayMaxScreenTemperature;\n\n\t\t\t\t// Using daytime forecast values\n\t\t\t\tforecastWeather.windSpeed = forecastDataDays[day].midday10MWindSpeed;\n\t\t\t\tforecastWeather.windFromDirection = forecastDataDays[day].midday10MWindDirection;\n\t\t\t\tforecastWeather.weatherType = this.convertWeatherType(forecastDataDays[day].daySignificantWeatherCode);\n\t\t\t\tforecastWeather.precipitationProbability = forecastDataDays[day].dayProbabilityOfPrecipitation;\n\t\t\t\tforecastWeather.temperature = forecastDataDays[day].dayMaxScreenTemperature;\n\t\t\t\tforecastWeather.humidity = forecastDataDays[day].middayRelativeHumidity;\n\t\t\t\tforecastWeather.rain = forecastDataDays[day].dayProbabilityOfRain;\n\t\t\t\tforecastWeather.snow = forecastDataDays[day].dayProbabilityOfSnow;\n\t\t\t\tforecastWeather.feelsLikeTemp = forecastDataDays[day].dayMaxFeelsLikeTemp;\n\n\t\t\t\t/*\n\t\t\t\t * Pass on full details, so they can be used in custom templates\n\t\t\t\t * Note the units of the supplied data when using this (see top of file)\n\t\t\t\t */\n\t\t\t\tforecastWeather.rawData = forecastDataDays[day];\n\n\t\t\t\tdailyForecasts.push(forecastWeather);\n\t\t\t}\n\t\t}\n\n\t\treturn dailyForecasts;\n\t},\n\n\t// Set the fetched location name.\n\tsetFetchedLocation (name) {\n\t\tthis.fetchedLocationName = name;\n\t},\n\n\t/*\n\t * Match the Met Office \"significant weather code\" to a weathericons.css icon\n\t * Use: https://metoffice.apiconnect.ibmcloud.com/metoffice/production/node/264\n\t * and: https://erikflowers.github.io/weather-icons/\n\t */\n\tconvertWeatherType (weatherType) {\n\t\tconst weatherTypes = {\n\t\t\t0: \"night-clear\",\n\t\t\t1: \"day-sunny\",\n\t\t\t2: \"night-alt-cloudy\",\n\t\t\t3: \"day-cloudy\",\n\t\t\t5: \"fog\",\n\t\t\t6: \"fog\",\n\t\t\t7: \"cloudy\",\n\t\t\t8: \"cloud\",\n\t\t\t9: \"night-sprinkle\",\n\t\t\t10: \"day-sprinkle\",\n\t\t\t11: \"raindrops\",\n\t\t\t12: \"sprinkle\",\n\t\t\t13: \"night-alt-showers\",\n\t\t\t14: \"day-showers\",\n\t\t\t15: \"rain\",\n\t\t\t16: \"night-alt-sleet\",\n\t\t\t17: \"day-sleet\",\n\t\t\t18: \"sleet\",\n\t\t\t19: \"night-alt-hail\",\n\t\t\t20: \"day-hail\",\n\t\t\t21: \"hail\",\n\t\t\t22: \"night-alt-snow\",\n\t\t\t23: \"day-snow\",\n\t\t\t24: \"snow\",\n\t\t\t25: \"night-alt-snow\",\n\t\t\t26: \"day-snow\",\n\t\t\t27: \"snow\",\n\t\t\t28: \"night-alt-thunderstorm\",\n\t\t\t29: \"day-thunderstorm\",\n\t\t\t30: \"thunderstorm\"\n\t\t};\n\n\t\treturn weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null;\n\t}\n});\n"
  },
  {
    "path": "modules/default/weather/providers/weatherbit.js",
    "content": "/* global WeatherProvider, WeatherObject */\n\n/*\n * This class is a provider for Weatherbit,\n * see https://www.weatherbit.io/\n */\nWeatherProvider.register(\"weatherbit\", {\n\n\t/*\n\t * Set the name of the provider.\n\t * Not strictly required, but helps for debugging.\n\t */\n\tproviderName: \"Weatherbit\",\n\n\t// Set the default config properties that is specific to this provider\n\tdefaults: {\n\t\tapiBase: \"https://api.weatherbit.io/v2.0\",\n\t\tapiKey: \"\",\n\t\tlat: 0,\n\t\tlon: 0\n\t},\n\n\tfetchedLocation () {\n\t\treturn this.fetchedLocationName || \"\";\n\t},\n\n\tfetchCurrentWeather () {\n\t\tthis.fetchData(this.getUrl())\n\t\t\t.then((data) => {\n\t\t\t\tif (!data || !data.data[0] || typeof data.data[0].temp === \"undefined\") {\n\t\t\t\t\t// No usable data?\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tconst currentWeather = this.generateWeatherDayFromCurrentWeather(data);\n\t\t\t\tthis.setCurrentWeather(currentWeather);\n\t\t\t})\n\t\t\t.catch(function (request) {\n\t\t\t\tLog.error(\"[weatherprovider.weatherbit] Could not load data ... \", request);\n\t\t\t})\n\t\t\t.finally(() => this.updateAvailable());\n\t},\n\n\tfetchWeatherForecast () {\n\t\tthis.fetchData(this.getUrl())\n\t\t\t.then((data) => {\n\t\t\t\tif (!data || !data.data) {\n\t\t\t\t\t// No usable data?\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tconst forecast = this.generateWeatherObjectsFromForecast(data.data);\n\t\t\t\tthis.setWeatherForecast(forecast);\n\n\t\t\t\tthis.fetchedLocationName = `${data.city_name}, ${data.state_code}`;\n\t\t\t})\n\t\t\t.catch(function (request) {\n\t\t\t\tLog.error(\"[weatherprovider.weatherbit] Could not load data ... \", request);\n\t\t\t})\n\t\t\t.finally(() => this.updateAvailable());\n\t},\n\n\t/**\n\t * Overrides method for setting config to check if endpoint is correct for hourly\n\t * @param {object} config The configuration object\n\t */\n\tsetConfig (config) {\n\t\tthis.config = config;\n\t\tif (!this.config.weatherEndpoint) {\n\t\t\tswitch (this.config.type) {\n\t\t\t\tcase \"hourly\":\n\t\t\t\t\tthis.config.weatherEndpoint = \"/forecast/hourly\";\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"daily\":\n\t\t\t\tcase \"forecast\":\n\t\t\t\t\tthis.config.weatherEndpoint = \"/forecast/daily\";\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"current\":\n\t\t\t\t\tthis.config.weatherEndpoint = \"/current\";\n\t\t\t\t\tbreak;\n\t\t\t\tdefault:\n\t\t\t\t\tLog.error(\"[weatherprovider.weatherbit] weatherEndpoint not configured and could not resolve it based on type\");\n\t\t\t}\n\t\t}\n\t},\n\n\t// Create a URL from the config and base URL.\n\tgetUrl () {\n\t\treturn `${this.config.apiBase}${this.config.weatherEndpoint}?lat=${this.config.lat}&lon=${this.config.lon}&units=M&key=${this.config.apiKey}`;\n\t},\n\n\t// Implement WeatherDay generator.\n\tgenerateWeatherDayFromCurrentWeather (currentWeatherData) {\n\t\t//Calculate TZ Offset and invert to convert Sunrise/Sunset times to Local\n\t\tconst d = new Date();\n\t\tlet tzOffset = d.getTimezoneOffset();\n\t\ttzOffset = tzOffset * -1;\n\n\t\tconst currentWeather = new WeatherObject();\n\n\t\tcurrentWeather.date = moment.unix(currentWeatherData.data[0].ts);\n\t\tcurrentWeather.humidity = parseFloat(currentWeatherData.data[0].rh);\n\t\tcurrentWeather.temperature = parseFloat(currentWeatherData.data[0].temp);\n\t\tcurrentWeather.windSpeed = parseFloat(currentWeatherData.data[0].wind_spd);\n\t\tcurrentWeather.windFromDirection = currentWeatherData.data[0].wind_dir;\n\t\tcurrentWeather.weatherType = this.convertWeatherType(currentWeatherData.data[0].weather.icon);\n\t\tcurrentWeather.sunrise = moment(currentWeatherData.data[0].sunrise, \"HH:mm\").add(tzOffset, \"m\");\n\t\tcurrentWeather.sunset = moment(currentWeatherData.data[0].sunset, \"HH:mm\").add(tzOffset, \"m\");\n\n\t\tthis.fetchedLocationName = `${currentWeatherData.data[0].city_name}, ${currentWeatherData.data[0].state_code}`;\n\n\t\treturn currentWeather;\n\t},\n\n\tgenerateWeatherObjectsFromForecast (forecasts) {\n\t\tconst days = [];\n\n\t\tfor (const forecast of forecasts) {\n\t\t\tconst weather = new WeatherObject();\n\n\t\t\tweather.date = moment(forecast.datetime, \"YYYY-MM-DD\");\n\t\t\tweather.minTemperature = forecast.min_temp;\n\t\t\tweather.maxTemperature = forecast.max_temp;\n\t\t\tweather.precipitationAmount = forecast.precip;\n\t\t\tweather.precipitationProbability = forecast.pop;\n\t\t\tweather.weatherType = this.convertWeatherType(forecast.weather.icon);\n\n\t\t\tdays.push(weather);\n\t\t}\n\n\t\treturn days;\n\t},\n\n\t// Map icons from Dark Sky to our icons.\n\tconvertWeatherType (weatherType) {\n\t\tconst weatherTypes = {\n\t\t\tt01d: \"day-thunderstorm\",\n\t\t\tt01n: \"night-alt-thunderstorm\",\n\t\t\tt02d: \"day-thunderstorm\",\n\t\t\tt02n: \"night-alt-thunderstorm\",\n\t\t\tt03d: \"thunderstorm\",\n\t\t\tt03n: \"thunderstorm\",\n\t\t\tt04d: \"day-thunderstorm\",\n\t\t\tt04n: \"night-alt-thunderstorm\",\n\t\t\tt05d: \"day-sleet-storm\",\n\t\t\tt05n: \"night-alt-sleet-storm\",\n\t\t\td01d: \"day-sprinkle\",\n\t\t\td01n: \"night-alt-sprinkle\",\n\t\t\td02d: \"day-sprinkle\",\n\t\t\td02n: \"night-alt-sprinkle\",\n\t\t\td03d: \"day-shower\",\n\t\t\td03n: \"night-alt-shower\",\n\t\t\tr01d: \"day-shower\",\n\t\t\tr01n: \"night-alt-shower\",\n\t\t\tr02d: \"day-rain\",\n\t\t\tr02n: \"night-alt-rain\",\n\t\t\tr03d: \"day-rain\",\n\t\t\tr03n: \"night-alt-rain\",\n\t\t\tr04d: \"day-sprinkle\",\n\t\t\tr04n: \"night-alt-sprinkle\",\n\t\t\tr05d: \"day-shower\",\n\t\t\tr05n: \"night-alt-shower\",\n\t\t\tr06d: \"day-shower\",\n\t\t\tr06n: \"night-alt-shower\",\n\t\t\tf01d: \"day-sleet\",\n\t\t\tf01n: \"night-alt-sleet\",\n\t\t\ts01d: \"day-snow\",\n\t\t\ts01n: \"night-alt-snow\",\n\t\t\ts02d: \"day-snow-wind\",\n\t\t\ts02n: \"night-alt-snow-wind\",\n\t\t\ts03d: \"snowflake-cold\",\n\t\t\ts03n: \"snowflake-cold\",\n\t\t\ts04d: \"day-rain-mix\",\n\t\t\ts04n: \"night-alt-rain-mix\",\n\t\t\ts05d: \"day-sleet\",\n\t\t\ts05n: \"night-alt-sleet\",\n\t\t\ts06d: \"day-snow\",\n\t\t\ts06n: \"night-alt-snow\",\n\t\t\ta01d: \"day-haze\",\n\t\t\ta01n: \"dust\",\n\t\t\ta02d: \"smoke\",\n\t\t\ta02n: \"smoke\",\n\t\t\ta03d: \"day-haze\",\n\t\t\ta03n: \"dust\",\n\t\t\ta04d: \"dust\",\n\t\t\ta04n: \"dust\",\n\t\t\ta05d: \"day-fog\",\n\t\t\ta05n: \"night-fog\",\n\t\t\ta06d: \"fog\",\n\t\t\ta06n: \"fog\",\n\t\t\tc01d: \"day-sunny\",\n\t\t\tc01n: \"night-clear\",\n\t\t\tc02d: \"day-sunny-overcast\",\n\t\t\tc02n: \"night-alt-partly-cloudy\",\n\t\t\tc03d: \"day-cloudy\",\n\t\t\tc03n: \"night-alt-cloudy\",\n\t\t\tc04d: \"cloudy\",\n\t\t\tc04n: \"cloudy\",\n\t\t\tu00d: \"rain-mix\",\n\t\t\tu00n: \"rain-mix\"\n\t\t};\n\n\t\treturn weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null;\n\t}\n});\n"
  },
  {
    "path": "modules/default/weather/providers/weatherflow.js",
    "content": "/* global WeatherProvider, WeatherObject, WeatherUtils */\n\n/*\n * This class is a provider for Weatherflow.\n * Note that the Weatherflow API does not provide snowfall.\n */\nWeatherProvider.register(\"weatherflow\", {\n\n\t/*\n\t * Set the name of the provider.\n\t * Not strictly required, but helps for debugging\n\t */\n\tproviderName: \"WeatherFlow\",\n\n\t// Set the default config properties that is specific to this provider\n\tdefaults: {\n\t\tapiBase: \"https://swd.weatherflow.com/swd/rest/\",\n\t\ttoken: \"\",\n\t\tstationid: \"\"\n\t},\n\n\tfetchCurrentWeather () {\n\t\tthis.fetchData(this.getUrl())\n\t\t\t.then((data) => {\n\t\t\t\tconst currentWeather = new WeatherObject();\n\t\t\t\tcurrentWeather.date = moment();\n\n\t\t\t\t// Other available values: air_density, brightness, delta_t, dew_point,\n\t\t\t\t// pressure_trend (i.e. rising/falling), sea_level_pressure, wind gust, and more.\n\n\t\t\t\tcurrentWeather.humidity = data.current_conditions.relative_humidity;\n\t\t\t\tcurrentWeather.temperature = data.current_conditions.air_temperature;\n\t\t\t\tcurrentWeather.feelsLikeTemp = data.current_conditions.feels_like;\n\t\t\t\tcurrentWeather.windSpeed = WeatherUtils.convertWindToMs(data.current_conditions.wind_avg);\n\t\t\t\tcurrentWeather.windFromDirection = data.current_conditions.wind_direction;\n\t\t\t\tcurrentWeather.weatherType = this.convertWeatherType(data.current_conditions.icon);\n\t\t\t\tcurrentWeather.uv_index = data.current_conditions.uv;\n\t\t\t\tcurrentWeather.sunrise = moment.unix(data.forecast.daily[0].sunrise);\n\t\t\t\tcurrentWeather.sunset = moment.unix(data.forecast.daily[0].sunset);\n\t\t\t\tthis.setCurrentWeather(currentWeather);\n\t\t\t\tthis.fetchedLocationName = data.location_name;\n\t\t\t})\n\t\t\t.catch(function (request) {\n\t\t\t\tLog.error(\"[weatherprovider.weatherflow] Could not load data ... \", request);\n\t\t\t})\n\t\t\t.finally(() => this.updateAvailable());\n\t},\n\n\tfetchWeatherForecast () {\n\t\tthis.fetchData(this.getUrl())\n\t\t\t.then((data) => {\n\t\t\t\tconst days = [];\n\n\t\t\t\tfor (const forecast of data.forecast.daily) {\n\t\t\t\t\tconst weather = new WeatherObject();\n\n\t\t\t\t\tweather.date = moment.unix(forecast.day_start_local);\n\t\t\t\t\tweather.minTemperature = forecast.air_temp_low;\n\t\t\t\t\tweather.maxTemperature = forecast.air_temp_high;\n\t\t\t\t\tweather.precipitationProbability = forecast.precip_probability;\n\t\t\t\t\tweather.weatherType = this.convertWeatherType(forecast.icon);\n\n\t\t\t\t\t// Must manually build UV and Precipitation from hourly\n\t\t\t\t\tweather.precipitationAmount = 0.0; // This will sum up rain and snow\n\t\t\t\t\tweather.precipitationUnits = \"mm\";\n\t\t\t\t\tweather.uv_index = 0;\n\n\t\t\t\t\tfor (const hour of data.forecast.hourly) {\n\t\t\t\t\t\tconst hour_time = moment.unix(hour.time);\n\t\t\t\t\t\tif (hour_time.day() === weather.date.day()) { // Iterate though until day is reached\n\t\t\t\t\t\t\t// Get data from today\n\t\t\t\t\t\t\tweather.uv_index = Math.max(weather.uv_index, hour.uv);\n\t\t\t\t\t\t\tweather.precipitationAmount += (hour.precip ?? 0);\n\t\t\t\t\t\t} else if (hour_time.diff(weather.date) >= 86400) {\n\t\t\t\t\t\t\tbreak; // No more data to be found\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tdays.push(weather);\n\t\t\t\t}\n\t\t\t\tthis.setWeatherForecast(days);\n\t\t\t\tthis.fetchedLocationName = data.location_name;\n\t\t\t})\n\t\t\t.catch(function (request) {\n\t\t\t\tLog.error(\"[weatherprovider.weatherflow] Could not load data ... \", request);\n\t\t\t})\n\t\t\t.finally(() => this.updateAvailable());\n\t},\n\n\tfetchWeatherHourly () {\n\t\tthis.fetchData(this.getUrl())\n\t\t\t.then((data) => {\n\t\t\t\tconst hours = [];\n\t\t\t\tfor (const hour of data.forecast.hourly) {\n\t\t\t\t\tconst weather = new WeatherObject();\n\n\t\t\t\t\tweather.date = moment.unix(hour.time);\n\t\t\t\t\tweather.temperature = hour.air_temperature;\n\t\t\t\t\tweather.feelsLikeTemp = hour.feels_like;\n\t\t\t\t\tweather.humidity = hour.relative_humidity;\n\t\t\t\t\tweather.windSpeed = hour.wind_avg;\n\t\t\t\t\tweather.windFromDirection = hour.wind_direction;\n\t\t\t\t\tweather.weatherType = this.convertWeatherType(hour.icon);\n\t\t\t\t\tweather.precipitationProbability = hour.precip_probability;\n\t\t\t\t\tweather.precipitationAmount = hour.precip; // NOTE: precipitation type is available\n\t\t\t\t\tweather.precipitationUnits = \"mm\"; // Hardcoded via request, TODO: Add conversion\n\t\t\t\t\tweather.uv_index = hour.uv;\n\n\t\t\t\t\thours.push(weather);\n\t\t\t\t\tif (hours.length >= 48) break; // 10 days of hours are available, best to trim down.\n\t\t\t\t}\n\t\t\t\tthis.setWeatherHourly(hours);\n\t\t\t\tthis.fetchedLocationName = data.location_name;\n\t\t\t})\n\t\t\t.catch(function (request) {\n\t\t\t\tLog.error(\"[weatherprovider.weatherflow] Could not load data ... \", request);\n\t\t\t})\n\t\t\t.finally(() => this.updateAvailable());\n\t},\n\n\tconvertWeatherType (weatherType) {\n\t\tconst weatherTypes = {\n\t\t\t\"clear-day\": \"day-sunny\",\n\t\t\t\"clear-night\": \"night-clear\",\n\t\t\tcloudy: \"cloudy\",\n\t\t\tfoggy: \"fog\",\n\t\t\t\"partly-cloudy-day\": \"day-cloudy\",\n\t\t\t\"partly-cloudy-night\": \"night-alt-cloudy\",\n\t\t\t\"possibly-rainy-day\": \"day-rain\",\n\t\t\t\"possibly-rainy-night\": \"night-alt-rain\",\n\t\t\t\"possibly-sleet-day\": \"day-sleet\",\n\t\t\t\"possibly-sleet-night\": \"night-alt-sleet\",\n\t\t\t\"possibly-snow-day\": \"day-snow\",\n\t\t\t\"possibly-snow-night\": \"night-alt-snow\",\n\t\t\t\"possibly-thunderstorm-day\": \"day-thunderstorm\",\n\t\t\t\"possibly-thunderstorm-night\": \"night-alt-thunderstorm\",\n\t\t\trainy: \"rain\",\n\t\t\tsleet: \"sleet\",\n\t\t\tsnow: \"snow\",\n\t\t\tthunderstorm: \"thunderstorm\",\n\t\t\twindy: \"strong-wind\"\n\t\t};\n\n\t\treturn weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null;\n\t},\n\n\t// Create a URL from the config and base URL.\n\tgetUrl () {\n\t\treturn `${this.config.apiBase}better_forecast?station_id=${this.config.stationid}&units_temp=c&units_wind=kph&units_pressure=mb&units_precip=mm&units_distance=km&token=${this.config.token}`;\n\t}\n});\n"
  },
  {
    "path": "modules/default/weather/providers/weathergov.js",
    "content": "/* global WeatherProvider, WeatherObject, WeatherUtils */\n\n/*\n * Provider: weather.gov\n * https://weather-gov.github.io/api/general-faqs\n *\n * This class is a provider for weather.gov.\n * Note that this is only for US locations (lat and lon) and does not require an API key\n * Since it is free, there are some items missing - like sunrise, sunset\n */\n\nWeatherProvider.register(\"weathergov\", {\n\n\t/*\n\t * Set the name of the provider.\n\t * This isn't strictly necessary, since it will fallback to the provider identifier\n\t * But for debugging (and future alerts) it would be nice to have the real name.\n\t */\n\tproviderName: \"Weather.gov\",\n\n\t// Set the default config properties that is specific to this provider\n\tdefaults: {\n\t\tapiBase: \"https://api.weather.gov/points/\",\n\t\tlat: 0,\n\t\tlon: 0\n\t},\n\n\t// Flag all needed URLs availability\n\tconfigURLs: false,\n\n\t//This API has multiple urls involved\n\tforecastURL: \"tbd\",\n\tforecastHourlyURL: \"tbd\",\n\tforecastGridDataURL: \"tbd\",\n\tobservationStationsURL: \"tbd\",\n\tstationObsURL: \"tbd\",\n\n\t// Called to set the config, this config is the same as the weather module's config.\n\tsetConfig (config) {\n\t\tthis.config = config;\n\t\tthis.fetchWxGovURLs(this.config);\n\t},\n\n\t// This returns the name of the fetched location or an empty string.\n\tfetchedLocation () {\n\t\treturn this.fetchedLocationName || \"\";\n\t},\n\n\t// Overwrite the fetchCurrentWeather method.\n\tfetchCurrentWeather () {\n\t\tif (!this.configURLs) {\n\t\t\tLog.info(\"[weatherprovider.weathergov] fetchCurrentWeather: fetch wx waiting on config URLs\");\n\t\t\treturn;\n\t\t}\n\t\tthis.fetchData(this.stationObsURL)\n\t\t\t.then((data) => {\n\t\t\t\tif (!data || !data.properties) {\n\t\t\t\t\t// Did not receive usable new data.\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst currentWeather = this.generateWeatherObjectFromCurrentWeather(data.properties);\n\t\t\t\tthis.setCurrentWeather(currentWeather);\n\t\t\t})\n\t\t\t.catch(function (request) {\n\t\t\t\tLog.error(\"[weatherprovider.weathergov] Could not load station obs data ... \", request);\n\t\t\t})\n\t\t\t.finally(() => this.updateAvailable());\n\t},\n\n\t// Overwrite the fetchWeatherForecast method.\n\tfetchWeatherForecast () {\n\t\tif (!this.configURLs) {\n\t\t\tLog.info(\"[weatherprovider.weathergov] fetchWeatherForecast: fetch wx waiting on config URLs\");\n\t\t\treturn;\n\t\t}\n\t\tthis.fetchData(this.forecastURL)\n\t\t\t.then((data) => {\n\t\t\t\tif (!data || !data.properties || !data.properties.periods || !data.properties.periods.length) {\n\t\t\t\t\t// Did not receive usable new data.\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst forecast = this.generateWeatherObjectsFromForecast(data.properties.periods);\n\t\t\t\tthis.setWeatherForecast(forecast);\n\t\t\t})\n\t\t\t.catch(function (request) {\n\t\t\t\tLog.error(\"[weatherprovider.weathergov] Could not load forecast hourly data ... \", request);\n\t\t\t})\n\t\t\t.finally(() => this.updateAvailable());\n\t},\n\n\t// Overwrite the fetchWeatherHourly method.\n\tfetchWeatherHourly () {\n\t\tif (!this.configURLs) {\n\t\t\tLog.info(\"[weatherprovider.weathergov] fetchWeatherHourly: fetch wx waiting on config URLs\");\n\t\t\treturn;\n\t\t}\n\t\tthis.fetchData(this.forecastHourlyURL)\n\t\t\t.then((data) => {\n\t\t\t\tif (!data) {\n\n\t\t\t\t\t/*\n\t\t\t\t\t * Did not receive usable new data.\n\t\t\t\t\t * Maybe this needs a better check?\n\t\t\t\t\t */\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst hourly = this.generateWeatherObjectsFromHourly(data.properties.periods);\n\t\t\t\tthis.setWeatherHourly(hourly);\n\t\t\t})\n\t\t\t.catch(function (request) {\n\t\t\t\tLog.error(\"[weatherprovider.weathergov] Could not load data ... \", request);\n\t\t\t})\n\t\t\t.finally(() => this.updateAvailable());\n\t},\n\n\t/** Weather.gov Specific Methods - These are not part of the default provider methods */\n\n\t/*\n\t * Get specific URLs\n\t */\n\tfetchWxGovURLs (config) {\n\t\tthis.fetchData(`${config.apiBase}/${config.lat},${config.lon}`)\n\t\t\t.then((data) => {\n\t\t\t\tif (!data || !data.properties) {\n\t\t\t\t\t// points URL did not respond with usable data.\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tthis.fetchedLocationName = `${data.properties.relativeLocation.properties.city}, ${data.properties.relativeLocation.properties.state}`;\n\t\t\t\tLog.log(`[weatherprovider.weathergov] Forecast location is ${this.fetchedLocationName}`);\n\t\t\t\tthis.forecastURL = `${data.properties.forecast}?units=si`;\n\t\t\t\tthis.forecastHourlyURL = `${data.properties.forecastHourly}?units=si`;\n\t\t\t\tthis.forecastGridDataURL = data.properties.forecastGridData;\n\t\t\t\tthis.observationStationsURL = data.properties.observationStations;\n\t\t\t\t// with this URL, we chain another promise for the station obs URL\n\t\t\t\treturn this.fetchData(data.properties.observationStations);\n\t\t\t})\n\t\t\t.then((obsData) => {\n\t\t\t\tif (!obsData || !obsData.features) {\n\t\t\t\t\t// obs station URL did not respond with usable data.\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tthis.stationObsURL = `${obsData.features[0].id}/observations/latest`;\n\t\t\t})\n\t\t\t.catch((err) => {\n\t\t\t\tLog.error(\"[weatherprovider.weathergov] fetchWxGovURLs error: \", err);\n\t\t\t})\n\t\t\t.finally(() => {\n\t\t\t\t// excellent, let's fetch some actual wx data\n\t\t\t\tthis.configURLs = true;\n\n\t\t\t\t// handle 'forecast' config, fall back to 'current'\n\t\t\t\tif (config.type === \"forecast\") {\n\t\t\t\t\tthis.fetchWeatherForecast();\n\t\t\t\t} else if (config.type === \"hourly\") {\n\t\t\t\t\tthis.fetchWeatherHourly();\n\t\t\t\t} else {\n\t\t\t\t\tthis.fetchCurrentWeather();\n\t\t\t\t}\n\t\t\t});\n\t},\n\n\t/*\n\t * Generate a WeatherObject based on hourlyWeatherInformation\n\t * Weather.gov API uses specific units; API does not include choice of units\n\t * ... object needs data in units based on config!\n\t */\n\tgenerateWeatherObjectsFromHourly (forecasts) {\n\t\tconst days = [];\n\n\t\t// variable for date\n\t\tlet weather = new WeatherObject();\n\t\tfor (const forecast of forecasts) {\n\t\t\tweather.date = moment(forecast.startTime.slice(0, 19));\n\t\t\tif (forecast.windSpeed.search(\" \") < 0) {\n\t\t\t\tweather.windSpeed = forecast.windSpeed;\n\t\t\t} else {\n\t\t\t\tweather.windSpeed = forecast.windSpeed.slice(0, forecast.windSpeed.search(\" \"));\n\t\t\t}\n\t\t\tweather.windSpeed = WeatherUtils.convertWindToMs(weather.windSpeed);\n\t\t\tweather.windFromDirection = forecast.windDirection;\n\t\t\tweather.temperature = forecast.temperature;\n\t\t\t//assign probability of precipitation\n\t\t\tif (forecast.probabilityOfPrecipitation.value === null) {\n\t\t\t\tweather.precipitationProbability = 0;\n\t\t\t} else {\n\t\t\t\tweather.precipitationProbability = forecast.probabilityOfPrecipitation.value;\n\t\t\t}\n\t\t\t// use the forecast isDayTime attribute to help build the weatherType label\n\t\t\tweather.weatherType = this.convertWeatherType(forecast.shortForecast, forecast.isDaytime);\n\n\t\t\tdays.push(weather);\n\n\t\t\tweather = new WeatherObject();\n\t\t}\n\n\t\t// push weather information to days array\n\t\tdays.push(weather);\n\t\treturn days;\n\t},\n\n\t/*\n\t * Generate a WeatherObject based on currentWeatherInformation\n\t * Weather.gov API uses specific units; API does not include choice of units\n\t * ... object needs data in units based on config!\n\t */\n\tgenerateWeatherObjectFromCurrentWeather (currentWeatherData) {\n\t\tconst currentWeather = new WeatherObject();\n\n\t\tcurrentWeather.date = moment(currentWeatherData.timestamp);\n\t\tcurrentWeather.temperature = currentWeatherData.temperature.value;\n\t\tcurrentWeather.windSpeed = WeatherUtils.convertWindToMs(currentWeatherData.windSpeed.value);\n\t\tcurrentWeather.windFromDirection = currentWeatherData.windDirection.value;\n\t\tcurrentWeather.minTemperature = currentWeatherData.minTemperatureLast24Hours.value;\n\t\tcurrentWeather.maxTemperature = currentWeatherData.maxTemperatureLast24Hours.value;\n\t\tcurrentWeather.humidity = Math.round(currentWeatherData.relativeHumidity.value);\n\t\tcurrentWeather.precipitationAmount = currentWeatherData.precipitationLastHour?.value ?? currentWeatherData.precipitationLast3Hours?.value;\n\t\tif (currentWeatherData.heatIndex.value !== null) {\n\t\t\tcurrentWeather.feelsLikeTemp = currentWeatherData.heatIndex.value;\n\t\t} else if (currentWeatherData.windChill.value !== null) {\n\t\t\tcurrentWeather.feelsLikeTemp = currentWeatherData.windChill.value;\n\t\t} else {\n\t\t\tcurrentWeather.feelsLikeTemp = currentWeatherData.temperature.value;\n\t\t}\n\t\t// determine the sunrise/sunset times - not supplied in weather.gov data\n\t\tcurrentWeather.updateSunTime(this.config.lat, this.config.lon);\n\n\t\t// update weatherType\n\t\tcurrentWeather.weatherType = this.convertWeatherType(currentWeatherData.textDescription, currentWeather.isDayTime());\n\n\t\treturn currentWeather;\n\t},\n\n\t/*\n\t * Generate WeatherObjects based on forecast information\n\t */\n\tgenerateWeatherObjectsFromForecast (forecasts) {\n\t\treturn this.fetchForecastDaily(forecasts);\n\t},\n\n\t/*\n\t * fetch forecast information for daily forecast.\n\t */\n\tfetchForecastDaily (forecasts) {\n\t\t// initial variable declaration\n\t\tconst days = [];\n\t\t// variables for temperature range and rain\n\t\tlet minTemp = [];\n\t\tlet maxTemp = [];\n\t\t// variable for date\n\t\tlet date = \"\";\n\t\tlet weather = new WeatherObject();\n\n\t\tfor (const forecast of forecasts) {\n\t\t\tif (date !== moment(forecast.startTime).format(\"YYYY-MM-DD\")) {\n\t\t\t\t// calculate minimum/maximum temperature, specify rain amount\n\t\t\t\tweather.minTemperature = Math.min.apply(null, minTemp);\n\t\t\t\tweather.maxTemperature = Math.max.apply(null, maxTemp);\n\n\t\t\t\t// push weather information to days array\n\t\t\t\tdays.push(weather);\n\t\t\t\t// create new weather-object\n\t\t\t\tweather = new WeatherObject();\n\n\t\t\t\tminTemp = [];\n\t\t\t\tmaxTemp = [];\n\t\t\t\t//assign probability of precipitation\n\t\t\t\tif (forecast.probabilityOfPrecipitation.value === null) {\n\t\t\t\t\tweather.precipitationProbability = 0;\n\t\t\t\t} else {\n\t\t\t\t\tweather.precipitationProbability = forecast.probabilityOfPrecipitation.value;\n\t\t\t\t}\n\n\t\t\t\t// set new date\n\t\t\t\tdate = moment(forecast.startTime).format(\"YYYY-MM-DD\");\n\n\t\t\t\t// specify date\n\t\t\t\tweather.date = moment(forecast.startTime);\n\n\t\t\t\t// use the forecast isDayTime attribute to help build the weatherType label\n\t\t\t\tweather.weatherType = this.convertWeatherType(forecast.shortForecast, forecast.isDaytime);\n\t\t\t}\n\n\t\t\tif (moment(forecast.startTime).format(\"H\") >= 8 && moment(forecast.startTime).format(\"H\") <= 17) {\n\t\t\t\tweather.weatherType = this.convertWeatherType(forecast.shortForecast, forecast.isDaytime);\n\t\t\t}\n\n\t\t\t/*\n\t\t\t * the same day as before\n\t\t\t * add values from forecast to corresponding variables\n\t\t\t */\n\t\t\tminTemp.push(forecast.temperature);\n\t\t\tmaxTemp.push(forecast.temperature);\n\t\t}\n\n\t\t/*\n\t\t * last day\n\t\t * calculate minimum/maximum temperature\n\t\t */\n\t\tweather.minTemperature = Math.min.apply(null, minTemp);\n\t\tweather.maxTemperature = Math.max.apply(null, maxTemp);\n\n\t\t// push weather information to days array\n\t\tdays.push(weather);\n\t\treturn days.slice(1);\n\t},\n\n\t/*\n\t * Convert the icons to a more usable name.\n\t */\n\tconvertWeatherType (weatherType, isDaytime) {\n\n\t\t/*\n\t\t * https://w1.weather.gov/xml/current_obs/weather.php\n\t\t *  There are way too many types to create, so lets just look for certain strings\n\t\t */\n\n\t\tif (weatherType.includes(\"Cloudy\") || weatherType.includes(\"Partly\")) {\n\t\t\tif (isDaytime) {\n\t\t\t\treturn \"day-cloudy\";\n\t\t\t}\n\n\t\t\treturn \"night-cloudy\";\n\t\t} else if (weatherType.includes(\"Overcast\")) {\n\t\t\tif (isDaytime) {\n\t\t\t\treturn \"cloudy\";\n\t\t\t}\n\n\t\t\treturn \"night-cloudy\";\n\t\t} else if (weatherType.includes(\"Freezing\") || weatherType.includes(\"Ice\")) {\n\t\t\treturn \"rain-mix\";\n\t\t} else if (weatherType.includes(\"Snow\")) {\n\t\t\tif (isDaytime) {\n\t\t\t\treturn \"snow\";\n\t\t\t}\n\n\t\t\treturn \"night-snow\";\n\t\t} else if (weatherType.includes(\"Thunderstorm\")) {\n\t\t\tif (isDaytime) {\n\t\t\t\treturn \"thunderstorm\";\n\t\t\t}\n\n\t\t\treturn \"night-thunderstorm\";\n\t\t} else if (weatherType.includes(\"Showers\")) {\n\t\t\tif (isDaytime) {\n\t\t\t\treturn \"showers\";\n\t\t\t}\n\n\t\t\treturn \"night-showers\";\n\t\t} else if (weatherType.includes(\"Rain\") || weatherType.includes(\"Drizzle\")) {\n\t\t\tif (isDaytime) {\n\t\t\t\treturn \"rain\";\n\t\t\t}\n\n\t\t\treturn \"night-rain\";\n\t\t} else if (weatherType.includes(\"Breezy\") || weatherType.includes(\"Windy\")) {\n\t\t\tif (isDaytime) {\n\t\t\t\treturn \"cloudy-windy\";\n\t\t\t}\n\n\t\t\treturn \"night-alt-cloudy-windy\";\n\t\t} else if (weatherType.includes(\"Fair\") || weatherType.includes(\"Clear\") || weatherType.includes(\"Few\") || weatherType.includes(\"Sunny\")) {\n\t\t\tif (isDaytime) {\n\t\t\t\treturn \"day-sunny\";\n\t\t\t}\n\n\t\t\treturn \"night-clear\";\n\t\t} else if (weatherType.includes(\"Dust\") || weatherType.includes(\"Sand\")) {\n\t\t\treturn \"dust\";\n\t\t} else if (weatherType.includes(\"Fog\")) {\n\t\t\treturn \"fog\";\n\t\t} else if (weatherType.includes(\"Smoke\")) {\n\t\t\treturn \"smoke\";\n\t\t} else if (weatherType.includes(\"Haze\")) {\n\t\t\treturn \"day-haze\";\n\t\t}\n\n\t\treturn null;\n\t}\n});\n"
  },
  {
    "path": "modules/default/weather/providers/yr.js",
    "content": "/* global WeatherProvider, WeatherObject */\n\n/*\n * This class is a provider for Yr.no, a norwegian weather service.\n * Terms of service: https://developer.yr.no/doc/TermsOfService/\n */\nWeatherProvider.register(\"yr\", {\n\tproviderName: \"Yr\",\n\n\t// Set the default config properties that is specific to this provider\n\tdefaults: {\n\t\tuseCorsProxy: true,\n\t\tapiBase: \"https://api.met.no/weatherapi\",\n\t\tforecastApiVersion: \"2.0\",\n\t\tsunriseApiVersion: \"3.0\",\n\t\taltitude: 0,\n\t\tcurrentForecastHours: 1 //1, 6 or 12\n\t},\n\n\tstart () {\n\t\tif (typeof Storage === \"undefined\") {\n\t\t\t//local storage unavailable\n\t\t\tLog.error(\"[weatherprovider.yr] The Yr weather provider requires local storage.\");\n\t\t\tthrow new Error(\"Local storage not available\");\n\t\t}\n\t\tif (this.config.updateInterval < 600000) {\n\t\t\tLog.warn(\"[weatherprovider.yr] The Yr weather provider requires a minimum update interval of 10 minutes (600 000 ms). The configuration has been adjusted to meet this requirement.\");\n\t\t\tthis.delegate.config.updateInterval = 600000;\n\t\t}\n\t\tLog.info(`[weatherprovider.yr] ${this.providerName} started.`);\n\t},\n\n\tfetchCurrentWeather () {\n\t\tthis.getCurrentWeather()\n\t\t\t.then((currentWeather) => {\n\t\t\t\tthis.setCurrentWeather(currentWeather);\n\t\t\t\tthis.updateAvailable();\n\t\t\t})\n\t\t\t.catch((error) => {\n\t\t\t\tLog.error(\"[weatherprovider.yr] fetchCurrentWeather error:\", error);\n\t\t\t\tthis.updateAvailable();\n\t\t\t});\n\t},\n\n\tasync getCurrentWeather () {\n\t\tconst [weatherData, stellarData] = await Promise.all([this.getWeatherData(), this.getStellarData()]);\n\t\tif (!stellarData) {\n\t\t\tLog.warn(\"[weatherprovider.yr] No stellar data available.\");\n\t\t}\n\t\tif (!weatherData.properties.timeseries || !weatherData.properties.timeseries[0]) {\n\t\t\tLog.error(\"[weatherprovider.yr] No weather data available.\");\n\t\t\treturn;\n\t\t}\n\t\tconst currentTime = moment();\n\t\tlet forecast = weatherData.properties.timeseries[0];\n\t\tlet closestTimeInPast = currentTime.diff(moment(forecast.time));\n\t\tfor (const forecastTime of weatherData.properties.timeseries) {\n\t\t\tconst comparison = currentTime.diff(moment(forecastTime.time));\n\t\t\tif (0 < comparison && comparison < closestTimeInPast) {\n\t\t\t\tclosestTimeInPast = comparison;\n\t\t\t\tforecast = forecastTime;\n\t\t\t}\n\t\t}\n\t\tconst forecastXHours = this.getForecastForXHoursFrom(forecast.data);\n\t\tforecast.weatherType = this.convertWeatherType(forecastXHours.summary.symbol_code, forecast.time);\n\t\tforecast.precipitationAmount = forecastXHours.details?.precipitation_amount;\n\t\tforecast.precipitationProbability = forecastXHours.details?.probability_of_precipitation;\n\t\tforecast.minTemperature = forecastXHours.details?.air_temperature_min;\n\t\tforecast.maxTemperature = forecastXHours.details?.air_temperature_max;\n\t\treturn this.getWeatherDataFrom(forecast, stellarData, weatherData.properties.meta.units);\n\t},\n\n\tgetWeatherData () {\n\t\treturn new Promise((resolve, reject) => {\n\n\t\t\t/*\n\t\t\t * If a user has several Yr-modules, for instance one current and one forecast, the API calls must be synchronized across classes.\n\t\t\t * This is to avoid multiple similar calls to the API.\n\t\t\t */\n\t\t\tlet shouldWait = localStorage.getItem(\"yrIsFetchingWeatherData\");\n\t\t\tif (shouldWait) {\n\t\t\t\tconst checkForGo = setInterval(function () {\n\t\t\t\t\tshouldWait = localStorage.getItem(\"yrIsFetchingWeatherData\");\n\t\t\t\t}, 100);\n\t\t\t\tsetTimeout(function () {\n\t\t\t\t\tclearInterval(checkForGo);\n\t\t\t\t\tshouldWait = false;\n\t\t\t\t}, 5000); //Assume other fetch finished but failed to remove lock\n\t\t\t\tconst attemptFetchWeather = setInterval(() => {\n\t\t\t\t\tif (!shouldWait) {\n\t\t\t\t\t\tclearInterval(checkForGo);\n\t\t\t\t\t\tclearInterval(attemptFetchWeather);\n\t\t\t\t\t\tthis.getWeatherDataFromYrOrCache(resolve, reject);\n\t\t\t\t\t}\n\t\t\t\t}, 100);\n\t\t\t} else {\n\t\t\t\tthis.getWeatherDataFromYrOrCache(resolve, reject);\n\t\t\t}\n\t\t});\n\t},\n\n\tgetWeatherDataFromYrOrCache (resolve, reject) {\n\t\tlocalStorage.setItem(\"yrIsFetchingWeatherData\", \"true\");\n\n\t\tlet weatherData = this.getWeatherDataFromCache();\n\t\tif (this.weatherDataIsValid(weatherData)) {\n\t\t\tlocalStorage.removeItem(\"yrIsFetchingWeatherData\");\n\t\t\tLog.debug(\"[weatherprovider.yr] Weather data found in cache.\");\n\t\t\tresolve(weatherData);\n\t\t} else {\n\t\t\tthis.getWeatherDataFromYr(weatherData?.downloadedAt)\n\t\t\t\t.then((weatherData) => {\n\t\t\t\t\tLog.debug(\"[weatherprovider.yr] Got weather data from yr.\");\n\t\t\t\t\tlet data;\n\t\t\t\t\tif (weatherData) {\n\t\t\t\t\t\tthis.cacheWeatherData(weatherData);\n\t\t\t\t\t\tdata = weatherData;\n\t\t\t\t\t} else {\n\t\t\t\t\t\t//Undefined if unchanged\n\t\t\t\t\t\tdata = this.getWeatherDataFromCache();\n\t\t\t\t\t}\n\t\t\t\t\tresolve(data);\n\t\t\t\t})\n\t\t\t\t.catch((err) => {\n\t\t\t\t\tLog.error(\"[weatherprovider.yr] getWeatherDataFromYr error: \", err);\n\t\t\t\t\tif (weatherData) {\n\t\t\t\t\t\tLog.warn(\"[weatherprovider.yr] Using outdated cached weather data.\");\n\t\t\t\t\t\tresolve(weatherData);\n\t\t\t\t\t} else {\n\t\t\t\t\t\treject(\"Unable to get weather data from Yr.\");\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.finally(() => {\n\t\t\t\t\tlocalStorage.removeItem(\"yrIsFetchingWeatherData\");\n\t\t\t\t});\n\t\t}\n\t},\n\n\tweatherDataIsValid (weatherData) {\n\t\treturn (\n\t\t\tweatherData\n\t\t\t&& weatherData.timeout\n\t\t\t&& 0 < moment(weatherData.timeout).diff(moment())\n\t\t\t&& (!weatherData.geometry || !weatherData.geometry.coordinates || !weatherData.geometry.coordinates.length < 2 || (weatherData.geometry.coordinates[0] === this.config.lat && weatherData.geometry.coordinates[1] === this.config.lon))\n\t\t);\n\t},\n\n\tgetWeatherDataFromCache () {\n\t\tconst weatherData = localStorage.getItem(\"weatherData\");\n\t\tif (weatherData) {\n\t\t\treturn JSON.parse(weatherData);\n\t\t} else {\n\t\t\treturn undefined;\n\t\t}\n\t},\n\n\tgetWeatherDataFromYr (currentDataFetchedAt) {\n\t\tconst requestHeaders = [{ name: \"Accept\", value: \"application/json\" }];\n\t\tif (currentDataFetchedAt) {\n\t\t\trequestHeaders.push({ name: \"If-Modified-Since\", value: currentDataFetchedAt });\n\t\t}\n\n\t\tconst expectedResponseHeaders = [\"expires\", \"date\"];\n\n\t\treturn this.fetchData(this.getForecastUrl(), \"json\", requestHeaders, expectedResponseHeaders)\n\t\t\t.then((data) => {\n\t\t\t\tif (!data || !data.headers) return data;\n\t\t\t\tdata.timeout = data.headers.find((header) => header.name === \"expires\").value;\n\t\t\t\tdata.downloadedAt = data.headers.find((header) => header.name === \"date\").value;\n\t\t\t\tdata.headers = undefined;\n\t\t\t\treturn data;\n\t\t\t})\n\t\t\t.catch((err) => {\n\t\t\t\tLog.error(\"[weatherprovider.yr] Could not load weather data.\", err);\n\t\t\t\tthrow new Error(err);\n\t\t\t});\n\t},\n\n\tgetConfigOptions () {\n\t\tif (!this.config.lat) {\n\t\t\tLog.error(\"[weatherprovider.yr] Latitude not provided.\");\n\t\t\tthrow new Error(\"Latitude not provided.\");\n\t\t}\n\t\tif (!this.config.lon) {\n\t\t\tLog.error(\"[weatherprovider.yr] Longitude not provided.\");\n\t\t\tthrow new Error(\"Longitude not provided.\");\n\t\t}\n\n\t\tlet lat = this.config.lat.toString();\n\t\tlet lon = this.config.lon.toString();\n\t\tconst altitude = this.config.altitude ?? 0;\n\t\treturn { lat, lon, altitude };\n\t},\n\n\tgetForecastUrl () {\n\t\tlet { lat, lon, altitude } = this.getConfigOptions();\n\n\t\tif (lat.includes(\".\") && lat.split(\".\")[1].length > 4) {\n\t\t\tLog.warn(\"[weatherprovider.yr] Latitude is too specific for weather data. Do not use more than four decimals. Trimming to maximum length.\");\n\t\t\tconst latParts = lat.split(\".\");\n\t\t\tlat = `${latParts[0]}.${latParts[1].substring(0, 4)}`;\n\t\t}\n\t\tif (lon.includes(\".\") && lon.split(\".\")[1].length > 4) {\n\t\t\tLog.warn(\"[weatherprovider.yr] Longitude is too specific for weather data. Do not use more than four decimals. Trimming to maximum length.\");\n\t\t\tconst lonParts = lon.split(\".\");\n\t\t\tlon = `${lonParts[0]}.${lonParts[1].substring(0, 4)}`;\n\t\t}\n\n\t\treturn `${this.config.apiBase}/locationforecast/${this.config.forecastApiVersion}/complete?&altitude=${altitude}&lat=${lat}&lon=${lon}`;\n\t},\n\n\tcacheWeatherData (weatherData) {\n\t\tlocalStorage.setItem(\"weatherData\", JSON.stringify(weatherData));\n\t},\n\n\tgetStellarData () {\n\n\t\t/*\n\t\t * If a user has several Yr-modules, for instance one current and one forecast, the API calls must be synchronized across classes.\n\t\t * This is to avoid multiple similar calls to the API.\n\t\t */\n\t\treturn new Promise((resolve, reject) => {\n\t\t\tlet shouldWait = localStorage.getItem(\"yrIsFetchingStellarData\");\n\t\t\tif (shouldWait) {\n\t\t\t\tconst checkForGo = setInterval(function () {\n\t\t\t\t\tshouldWait = localStorage.getItem(\"yrIsFetchingStellarData\");\n\t\t\t\t}, 100);\n\t\t\t\tsetTimeout(function () {\n\t\t\t\t\tclearInterval(checkForGo);\n\t\t\t\t\tshouldWait = false;\n\t\t\t\t}, 5000); //Assume other fetch finished but failed to remove lock\n\t\t\t\tconst attemptFetchWeather = setInterval(() => {\n\t\t\t\t\tif (!shouldWait) {\n\t\t\t\t\t\tclearInterval(checkForGo);\n\t\t\t\t\t\tclearInterval(attemptFetchWeather);\n\t\t\t\t\t\tthis.getStellarDataFromYrOrCache(resolve, reject);\n\t\t\t\t\t}\n\t\t\t\t}, 100);\n\t\t\t} else {\n\t\t\t\tthis.getStellarDataFromYrOrCache(resolve, reject);\n\t\t\t}\n\t\t});\n\t},\n\n\tgetStellarDataFromYrOrCache (resolve, reject) {\n\t\tlocalStorage.setItem(\"yrIsFetchingStellarData\", \"true\");\n\n\t\tlet stellarData = this.getStellarDataFromCache();\n\t\tconst today = moment().format(\"YYYY-MM-DD\");\n\t\tconst tomorrow = moment().add(1, \"days\").format(\"YYYY-MM-DD\");\n\t\tif (stellarData && stellarData.today && stellarData.today.date === today && stellarData.tomorrow && stellarData.tomorrow.date === tomorrow) {\n\t\t\tLog.debug(\"[weatherprovider.yr] Stellar data found in cache.\");\n\t\t\tlocalStorage.removeItem(\"yrIsFetchingStellarData\");\n\t\t\tresolve(stellarData);\n\t\t} else if (stellarData && stellarData.tomorrow && stellarData.tomorrow.date === today) {\n\t\t\tLog.debug(\"[weatherprovider.yr] Stellar data for today found in cache, but not for tomorrow.\");\n\t\t\tstellarData.today = stellarData.tomorrow;\n\t\t\tthis.getStellarDataFromYr(tomorrow)\n\t\t\t\t.then((data) => {\n\t\t\t\t\tif (data) {\n\t\t\t\t\t\tdata.date = tomorrow;\n\t\t\t\t\t\tstellarData.tomorrow = data;\n\t\t\t\t\t\tthis.cacheStellarData(stellarData);\n\t\t\t\t\t\tresolve(stellarData);\n\t\t\t\t\t} else {\n\t\t\t\t\t\treject(`No stellar data returned from Yr for ${tomorrow}`);\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.catch((err) => {\n\t\t\t\t\tLog.error(\"[weatherprovider.yr] getStellarDataFromYr error: \", err);\n\t\t\t\t\treject(`Unable to get stellar data from Yr for ${tomorrow}`);\n\t\t\t\t})\n\t\t\t\t.finally(() => {\n\t\t\t\t\tlocalStorage.removeItem(\"yrIsFetchingStellarData\");\n\t\t\t\t});\n\t\t} else {\n\t\t\tthis.getStellarDataFromYr(today, 2)\n\t\t\t\t.then((stellarData) => {\n\t\t\t\t\tif (stellarData) {\n\t\t\t\t\t\tconst data = {\n\t\t\t\t\t\t\ttoday: stellarData\n\t\t\t\t\t\t};\n\t\t\t\t\t\tdata.tomorrow = Object.assign({}, data.today);\n\t\t\t\t\t\tdata.today.date = today;\n\t\t\t\t\t\tdata.tomorrow.date = tomorrow;\n\t\t\t\t\t\tthis.cacheStellarData(data);\n\t\t\t\t\t\tresolve(data);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tLog.error(`[weatherprovider.yr] Something went wrong when fetching stellar data. Responses: ${stellarData}`);\n\t\t\t\t\t\treject(stellarData);\n\t\t\t\t\t}\n\t\t\t\t})\n\t\t\t\t.catch((err) => {\n\t\t\t\t\tLog.error(\"[weatherprovider.yr] getStellarDataFromYr error: \", err);\n\t\t\t\t\treject(\"Unable to get stellar data from Yr.\");\n\t\t\t\t})\n\t\t\t\t.finally(() => {\n\t\t\t\t\tlocalStorage.removeItem(\"yrIsFetchingStellarData\");\n\t\t\t\t});\n\t\t}\n\t},\n\n\tgetStellarDataFromCache () {\n\t\tconst stellarData = localStorage.getItem(\"stellarData\");\n\t\tif (stellarData) {\n\t\t\treturn JSON.parse(stellarData);\n\t\t} else {\n\t\t\treturn undefined;\n\t\t}\n\t},\n\n\tgetStellarDataFromYr (date, days = 1) {\n\t\tconst requestHeaders = [{ name: \"Accept\", value: \"application/json\" }];\n\t\treturn this.fetchData(this.getStellarDataUrl(date, days), \"json\", requestHeaders)\n\t\t\t.then((data) => {\n\t\t\t\tLog.debug(\"[weatherprovider.yr] Got stellar data from yr.\");\n\t\t\t\treturn data;\n\t\t\t})\n\t\t\t.catch((err) => {\n\t\t\t\tLog.error(\"[weatherprovider.yr] Could not load weather data.\", err);\n\t\t\t\tthrow new Error(err);\n\t\t\t});\n\t},\n\n\tgetStellarDataUrl (date, days) {\n\t\tlet { lat, lon, altitude } = this.getConfigOptions();\n\n\t\tif (lat.includes(\".\") && lat.split(\".\")[1].length > 4) {\n\t\t\tLog.warn(\"[weatherprovider.yr] Latitude is too specific for stellar data. Do not use more than four decimals. Trimming to maximum length.\");\n\t\t\tconst latParts = lat.split(\".\");\n\t\t\tlat = `${latParts[0]}.${latParts[1].substring(0, 4)}`;\n\t\t}\n\t\tif (lon.includes(\".\") && lon.split(\".\")[1].length > 4) {\n\t\t\tLog.warn(\"[weatherprovider.yr] Longitude is too specific for stellar data. Do not use more than four decimals. Trimming to maximum length.\");\n\t\t\tconst lonParts = lon.split(\".\");\n\t\t\tlon = `${lonParts[0]}.${lonParts[1].substring(0, 4)}`;\n\t\t}\n\n\t\tlet utcOffset = moment().utcOffset() / 60;\n\t\tlet utcOffsetPrefix = \"%2B\";\n\t\tif (utcOffset < 0) {\n\t\t\tutcOffsetPrefix = \"-\";\n\t\t}\n\t\tutcOffset = Math.abs(utcOffset);\n\t\tlet minutes = \"00\";\n\t\tif (utcOffset % 1 !== 0) {\n\t\t\tminutes = \"30\";\n\t\t}\n\t\tlet hours = Math.floor(utcOffset).toString();\n\t\tif (hours.length < 2) {\n\t\t\thours = `0${hours}`;\n\t\t}\n\t\treturn `${this.config.apiBase}/sunrise/${this.config.sunriseApiVersion}/sun?lat=${lat}&lon=${lon}&date=${date}&offset=${utcOffsetPrefix}${hours}%3A${minutes}`;\n\t},\n\n\tcacheStellarData (data) {\n\t\tlocalStorage.setItem(\"stellarData\", JSON.stringify(data));\n\t},\n\n\tgetWeatherDataFrom (forecast, stellarData, units) {\n\t\tconst weather = new WeatherObject();\n\n\t\tweather.date = moment(forecast.time);\n\t\tweather.windSpeed = forecast.data.instant.details.wind_speed;\n\t\tweather.windFromDirection = forecast.data.instant.details.wind_from_direction;\n\t\tweather.temperature = forecast.data.instant.details.air_temperature;\n\t\tweather.minTemperature = forecast.minTemperature;\n\t\tweather.maxTemperature = forecast.maxTemperature;\n\t\tweather.weatherType = forecast.weatherType;\n\t\tweather.humidity = forecast.data.instant.details.relative_humidity;\n\t\tweather.precipitationAmount = forecast.precipitationAmount;\n\t\tweather.precipitationProbability = forecast.precipitationProbability;\n\t\tweather.precipitationUnits = units.precipitation_amount;\n\n\t\tweather.sunrise = stellarData?.today?.properties?.sunrise?.time;\n\t\tweather.sunset = stellarData?.today?.properties?.sunset?.time;\n\n\t\treturn weather;\n\t},\n\n\tconvertWeatherType (weatherType, weatherTime) {\n\t\tconst weatherHour = moment(weatherTime).format(\"HH\");\n\n\t\tconst weatherTypes = {\n\t\t\tclearsky_day: \"day-sunny\",\n\t\t\tclearsky_night: \"night-clear\",\n\t\t\tclearsky_polartwilight: weatherHour < 14 ? \"sunrise\" : \"sunset\",\n\t\t\tcloudy: \"cloudy\",\n\t\t\tfair_day: \"day-sunny-overcast\",\n\t\t\tfair_night: \"night-alt-partly-cloudy\",\n\t\t\tfair_polartwilight: \"day-sunny-overcast\",\n\t\t\tfog: \"fog\",\n\t\t\theavyrain: \"rain\", // Possibly raindrops or raindrop\n\t\t\theavyrainandthunder: \"thunderstorm\",\n\t\t\theavyrainshowers_day: \"day-rain\",\n\t\t\theavyrainshowers_night: \"night-alt-rain\",\n\t\t\theavyrainshowers_polartwilight: \"day-rain\",\n\t\t\theavyrainshowersandthunder_day: \"day-thunderstorm\",\n\t\t\theavyrainshowersandthunder_night: \"night-alt-thunderstorm\",\n\t\t\theavyrainshowersandthunder_polartwilight: \"day-thunderstorm\",\n\t\t\theavysleet: \"sleet\",\n\t\t\theavysleetandthunder: \"day-sleet-storm\",\n\t\t\theavysleetshowers_day: \"day-sleet\",\n\t\t\theavysleetshowers_night: \"night-alt-sleet\",\n\t\t\theavysleetshowers_polartwilight: \"day-sleet\",\n\t\t\theavysleetshowersandthunder_day: \"day-sleet-storm\",\n\t\t\theavysleetshowersandthunder_night: \"night-alt-sleet-storm\",\n\t\t\theavysleetshowersandthunder_polartwilight: \"day-sleet-storm\",\n\t\t\theavysnow: \"snow-wind\",\n\t\t\theavysnowandthunder: \"day-snow-thunderstorm\",\n\t\t\theavysnowshowers_day: \"day-snow-wind\",\n\t\t\theavysnowshowers_night: \"night-alt-snow-wind\",\n\t\t\theavysnowshowers_polartwilight: \"day-snow-wind\",\n\t\t\theavysnowshowersandthunder_day: \"day-snow-thunderstorm\",\n\t\t\theavysnowshowersandthunder_night: \"night-alt-snow-thunderstorm\",\n\t\t\theavysnowshowersandthunder_polartwilight: \"day-snow-thunderstorm\",\n\t\t\tlightrain: \"rain-mix\",\n\t\t\tlightrainandthunder: \"thunderstorm\",\n\t\t\tlightrainshowers_day: \"day-rain-mix\",\n\t\t\tlightrainshowers_night: \"night-alt-rain-mix\",\n\t\t\tlightrainshowers_polartwilight: \"day-rain-mix\",\n\t\t\tlightrainshowersandthunder_day: \"thunderstorm\",\n\t\t\tlightrainshowersandthunder_night: \"thunderstorm\",\n\t\t\tlightrainshowersandthunder_polartwilight: \"thunderstorm\",\n\t\t\tlightsleet: \"day-sleet\",\n\t\t\tlightsleetandthunder: \"day-sleet-storm\",\n\t\t\tlightsleetshowers_day: \"day-sleet\",\n\t\t\tlightsleetshowers_night: \"night-alt-sleet\",\n\t\t\tlightsleetshowers_polartwilight: \"day-sleet\",\n\t\t\tlightsnow: \"snowflake-cold\",\n\t\t\tlightsnowandthunder: \"day-snow-thunderstorm\",\n\t\t\tlightsnowshowers_day: \"day-snow-wind\",\n\t\t\tlightsnowshowers_night: \"night-alt-snow-wind\",\n\t\t\tlightsnowshowers_polartwilight: \"day-snow-wind\",\n\t\t\tlightssleetshowersandthunder_day: \"day-sleet-storm\",\n\t\t\tlightssleetshowersandthunder_night: \"night-alt-sleet-storm\",\n\t\t\tlightssleetshowersandthunder_polartwilight: \"day-sleet-storm\",\n\t\t\tlightssnowshowersandthunder_day: \"day-snow-thunderstorm\",\n\t\t\tlightssnowshowersandthunder_night: \"night-alt-snow-thunderstorm\",\n\t\t\tlightssnowshowersandthunder_polartwilight: \"day-snow-thunderstorm\",\n\t\t\tpartlycloudy_day: \"day-cloudy\",\n\t\t\tpartlycloudy_night: \"night-alt-cloudy\",\n\t\t\tpartlycloudy_polartwilight: \"day-cloudy\",\n\t\t\train: \"rain\",\n\t\t\trainandthunder: \"thunderstorm\",\n\t\t\trainshowers_day: \"day-rain\",\n\t\t\trainshowers_night: \"night-alt-rain\",\n\t\t\trainshowers_polartwilight: \"day-rain\",\n\t\t\trainshowersandthunder_day: \"thunderstorm\",\n\t\t\trainshowersandthunder_night: \"lightning\",\n\t\t\trainshowersandthunder_polartwilight: \"thunderstorm\",\n\t\t\tsleet: \"sleet\",\n\t\t\tsleetandthunder: \"day-sleet-storm\",\n\t\t\tsleetshowers_day: \"day-sleet\",\n\t\t\tsleetshowers_night: \"night-alt-sleet\",\n\t\t\tsleetshowers_polartwilight: \"day-sleet\",\n\t\t\tsleetshowersandthunder_day: \"day-sleet-storm\",\n\t\t\tsleetshowersandthunder_night: \"night-alt-sleet-storm\",\n\t\t\tsleetshowersandthunder_polartwilight: \"day-sleet-storm\",\n\t\t\tsnow: \"snowflake-cold\",\n\t\t\tsnowandthunder: \"lightning\",\n\t\t\tsnowshowers_day: \"day-snow-wind\",\n\t\t\tsnowshowers_night: \"night-alt-snow-wind\",\n\t\t\tsnowshowers_polartwilight: \"day-snow-wind\",\n\t\t\tsnowshowersandthunder_day: \"day-snow-thunderstorm\",\n\t\t\tsnowshowersandthunder_night: \"night-alt-snow-thunderstorm\",\n\t\t\tsnowshowersandthunder_polartwilight: \"day-snow-thunderstorm\"\n\t\t};\n\n\t\treturn weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null;\n\t},\n\n\tgetForecastForXHoursFrom (weather) {\n\t\tif (this.config.currentForecastHours === 1) {\n\t\t\tif (weather.next_1_hours) {\n\t\t\t\treturn weather.next_1_hours;\n\t\t\t} else if (weather.next_6_hours) {\n\t\t\t\treturn weather.next_6_hours;\n\t\t\t} else {\n\t\t\t\treturn weather.next_12_hours;\n\t\t\t}\n\t\t} else if (this.config.currentForecastHours === 6) {\n\t\t\tif (weather.next_6_hours) {\n\t\t\t\treturn weather.next_6_hours;\n\t\t\t} else if (weather.next_12_hours) {\n\t\t\t\treturn weather.next_12_hours;\n\t\t\t} else {\n\t\t\t\treturn weather.next_1_hours;\n\t\t\t}\n\t\t} else {\n\t\t\tif (weather.next_12_hours) {\n\t\t\t\treturn weather.next_12_hours;\n\t\t\t} else if (weather.next_6_hours) {\n\t\t\t\treturn weather.next_6_hours;\n\t\t\t} else {\n\t\t\t\treturn weather.next_1_hours;\n\t\t\t}\n\t\t}\n\t},\n\n\tfetchWeatherHourly () {\n\t\tthis.getWeatherForecast(\"hourly\")\n\t\t\t.then((forecast) => {\n\t\t\t\tthis.setWeatherHourly(forecast);\n\t\t\t\tthis.updateAvailable();\n\t\t\t})\n\t\t\t.catch((error) => {\n\t\t\t\tLog.error(\"[weatherprovider.yr] fetchWeatherHourly error: \", error);\n\t\t\t\tthis.updateAvailable();\n\t\t\t});\n\t},\n\n\tasync getWeatherForecast (type) {\n\t\tconst [weatherData, stellarData] = await Promise.all([this.getWeatherData(), this.getStellarData()]);\n\t\tif (!weatherData.properties.timeseries || !weatherData.properties.timeseries[0]) {\n\t\t\tLog.error(\"[weatherprovider.yr] No weather data available.\");\n\t\t\treturn;\n\t\t}\n\t\tif (!stellarData) {\n\t\t\tLog.warn(\"[weatherprovider.yr] No stellar data available.\");\n\t\t}\n\t\tlet forecasts;\n\t\tswitch (type) {\n\t\t\tcase \"hourly\":\n\t\t\t\tforecasts = this.getHourlyForecastFrom(weatherData);\n\t\t\t\tbreak;\n\t\t\tcase \"daily\":\n\t\t\tdefault:\n\t\t\t\tforecasts = this.getDailyForecastFrom(weatherData);\n\t\t\t\tbreak;\n\t\t}\n\t\tconst series = [];\n\t\tfor (const forecast of forecasts) {\n\t\t\tseries.push(this.getWeatherDataFrom(forecast, stellarData, weatherData.properties.meta.units));\n\t\t}\n\t\treturn series;\n\t},\n\n\tgetHourlyForecastFrom (weatherData) {\n\t\tconst series = [];\n\n\t\tconst now = moment({\n\t\t\tyear: moment().year(),\n\t\t\tmonth: moment().month(),\n\t\t\tday: moment().date(),\n\t\t\thour: moment().hour()\n\t\t});\n\t\tfor (const forecast of weatherData.properties.timeseries) {\n\t\t\tif (now.isAfter(moment(forecast.time))) continue;\n\n\t\t\tforecast.symbol = forecast.data.next_1_hours?.summary?.symbol_code;\n\t\t\tforecast.precipitationAmount = forecast.data.next_1_hours?.details?.precipitation_amount;\n\t\t\tforecast.precipitationProbability = forecast.data.next_1_hours?.details?.probability_of_precipitation;\n\t\t\tforecast.minTemperature = forecast.data.next_1_hours?.details?.air_temperature_min;\n\t\t\tforecast.maxTemperature = forecast.data.next_1_hours?.details?.air_temperature_max;\n\t\t\tforecast.weatherType = this.convertWeatherType(forecast.symbol, forecast.time);\n\t\t\tseries.push(forecast);\n\t\t}\n\t\treturn series;\n\t},\n\n\tgetDailyForecastFrom (weatherData) {\n\t\tconst series = [];\n\n\t\tconst days = weatherData.properties.timeseries.reduce(function (days, forecast) {\n\t\t\tconst date = moment(forecast.time).format(\"YYYY-MM-DD\");\n\t\t\tdays[date] = days[date] || [];\n\t\t\tdays[date].push(forecast);\n\t\t\treturn days;\n\t\t}, Object.create(null));\n\n\t\tObject.keys(days).forEach(function (time) {\n\t\t\tlet minTemperature = undefined;\n\t\t\tlet maxTemperature = undefined;\n\n\t\t\t//Default to first entry\n\t\t\tlet forecast = days[time][0];\n\t\t\tforecast.symbol = forecast.data.next_12_hours?.summary?.symbol_code;\n\t\t\tforecast.precipitation = forecast.data.next_12_hours?.details?.precipitation_amount;\n\n\t\t\t//Coming days\n\t\t\tlet forecastDiffToEight = undefined;\n\t\t\tfor (const timeseries of days[time]) {\n\t\t\t\tif (!timeseries.data.next_6_hours) continue; //next_6_hours has the most data\n\n\t\t\t\tif (!minTemperature || timeseries.data.next_6_hours.details.air_temperature_min < minTemperature) minTemperature = timeseries.data.next_6_hours.details.air_temperature_min;\n\t\t\t\tif (!maxTemperature || maxTemperature < timeseries.data.next_6_hours.details.air_temperature_max) maxTemperature = timeseries.data.next_6_hours.details.air_temperature_max;\n\n\t\t\t\tlet closestTime = Math.abs(moment(timeseries.time).local().set({ hour: 8, minute: 0, second: 0, millisecond: 0 }).diff(moment(timeseries.time).local()));\n\t\t\t\tif ((forecastDiffToEight === undefined || closestTime < forecastDiffToEight) && timeseries.data.next_12_hours) {\n\t\t\t\t\tforecastDiffToEight = closestTime;\n\t\t\t\t\tforecast = timeseries;\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst forecastXHours = forecast.data.next_12_hours ?? forecast.data.next_6_hours ?? forecast.data.next_1_hours;\n\t\t\tif (forecastXHours) {\n\t\t\t\tforecast.symbol = forecastXHours.summary?.symbol_code;\n\t\t\t\tforecast.precipitationAmount = forecastXHours.details?.precipitation_amount ?? forecast.data.next_6_hours?.details?.precipitation_amount; // 6 hours is likely to have precipitation amount even if 12 hours does not\n\t\t\t\tforecast.precipitationProbability = forecastXHours.details?.probability_of_precipitation;\n\t\t\t\tforecast.minTemperature = minTemperature;\n\t\t\t\tforecast.maxTemperature = maxTemperature;\n\n\t\t\t\tseries.push(forecast);\n\t\t\t}\n\t\t});\n\t\tfor (const forecast of series) {\n\t\t\tforecast.weatherType = this.convertWeatherType(forecast.symbol, forecast.time);\n\t\t}\n\t\treturn series;\n\t},\n\n\tfetchWeatherForecast () {\n\t\tthis.getWeatherForecast(\"daily\")\n\t\t\t.then((forecast) => {\n\t\t\t\tthis.setWeatherForecast(forecast);\n\t\t\t\tthis.updateAvailable();\n\t\t\t})\n\t\t\t.catch((error) => {\n\t\t\t\tLog.error(\"[weatherprovider.yr] fetchWeatherForecast error: \", error);\n\t\t\t\tthis.updateAvailable();\n\t\t\t});\n\t}\n});\n"
  },
  {
    "path": "modules/default/weather/weather.css",
    "content": ".weather .weathericon,\n.weather .fa-home {\n  font-size: 75%;\n}\n\n.weather .humidity-icon {\n  padding-right: 4px;\n}\n\n.weather .humidity-padding {\n  padding-bottom: 6px;\n}\n\n.weather .day {\n  padding-left: 0;\n  padding-right: 25px;\n}\n\n.weather .weather-icon {\n  padding-right: 30px;\n  text-align: center;\n}\n\n.weather .min-temp {\n  padding-left: 20px;\n  padding-right: 0;\n}\n\n.weather .precipitation-amount,\n.weather .precipitation-prob,\n.weather .humidity-hourly,\n.weather .uv-index {\n  padding-left: 20px;\n  padding-right: 0;\n}\n\n.weather tr.colored .min-temp {\n  color: #bcddff;\n}\n\n.weather tr.colored .max-temp {\n  color: #ff8e99;\n}\n\n.weather .type-temp {\n  display: flex;\n  align-items: baseline;\n  gap: 10px;\n}\n"
  },
  {
    "path": "modules/default/weather/weather.js",
    "content": "/* global WeatherProvider, WeatherUtils, formatTime */\n\nModule.register(\"weather\", {\n\t// Default module config.\n\tdefaults: {\n\t\tweatherProvider: \"openweathermap\",\n\t\troundTemp: false,\n\t\ttype: \"current\", // current, forecast, daily (equivalent to forecast), hourly (only with OpenWeatherMap /onecall endpoint)\n\t\tlang: config.language,\n\t\tunits: config.units,\n\t\ttempUnits: config.units,\n\t\twindUnits: config.units,\n\t\ttimeFormat: config.timeFormat,\n\t\tupdateInterval: 10 * 60 * 1000, // every 10 minutes\n\t\tanimationSpeed: 1000,\n\t\tshowFeelsLike: true,\n\t\tshowHumidity: \"none\", // possible options for \"current\" weather are \"none\", \"wind\", \"temp\", \"feelslike\" or \"below\", for \"hourly\" weather \"none\" or \"true\"\n\t\thideZeroes: false, // hide zeroes (and empty columns) in hourly, currently only for precipitation\n\t\tshowIndoorHumidity: false,\n\t\tshowIndoorTemperature: false,\n\t\tallowOverrideNotification: false,\n\t\tshowPeriod: true,\n\t\tshowPeriodUpper: false,\n\t\tshowPrecipitationAmount: false,\n\t\tshowPrecipitationProbability: false,\n\t\tshowUVIndex: false,\n\t\tshowSun: true,\n\t\tshowWindDirection: true,\n\t\tshowWindDirectionAsArrow: false,\n\t\tdegreeLabel: false,\n\t\tdecimalSymbol: \".\",\n\t\tmaxNumberOfDays: 5,\n\t\tmaxEntries: 5,\n\t\tignoreToday: false,\n\t\tfade: true,\n\t\tfadePoint: 0.25, // Start on 1/4th of the list.\n\t\tinitialLoadDelay: 0, // 0 seconds delay\n\t\tappendLocationNameToHeader: true,\n\t\tcalendarClass: \"calendar\",\n\t\ttableClass: \"small\",\n\t\tonlyTemp: false,\n\t\tcolored: false,\n\t\tabsoluteDates: false,\n\t\tforecastDateFormat: \"ddd\", // format for forecast date display, e.g., \"ddd\" = Mon, \"dddd\" = Monday, \"D MMM\" = 18 Oct\n\t\thourlyForecastIncrements: 1\n\t},\n\n\t// Module properties.\n\tweatherProvider: null,\n\n\t// Can be used by the provider to display location of event if nothing else is specified\n\tfirstEvent: null,\n\n\t// Define required scripts.\n\tgetStyles () {\n\t\treturn [\"font-awesome.css\", \"weather-icons.css\", \"weather.css\"];\n\t},\n\n\t// Return the scripts that are necessary for the weather module.\n\tgetScripts () {\n\t\treturn [\"moment.js\", \"weatherutils.js\", \"weatherobject.js\", this.file(\"providers/overrideWrapper.js\"), \"weatherprovider.js\", \"suncalc.js\", this.file(`providers/${this.config.weatherProvider.toLowerCase()}.js`)];\n\t},\n\n\t// Override getHeader method.\n\tgetHeader () {\n\t\tif (this.config.appendLocationNameToHeader && this.weatherProvider) {\n\t\t\tif (this.data.header) return `${this.data.header} ${this.weatherProvider.fetchedLocation()}`;\n\t\t\telse return this.weatherProvider.fetchedLocation();\n\t\t}\n\n\t\treturn this.data.header ? this.data.header : \"\";\n\t},\n\n\t// Start the weather module.\n\tstart () {\n\t\tmoment.locale(this.config.lang);\n\n\t\tif (this.config.useKmh) {\n\t\t\tLog.warn(\"[weather] Deprecation warning: Your are using the deprecated config values 'useKmh'. Please switch to windUnits!\");\n\t\t\tthis.windUnits = \"kmh\";\n\t\t} else if (this.config.useBeaufort) {\n\t\t\tLog.warn(\"[weather] Deprecation warning: Your are using the deprecated config values 'useBeaufort'. Please switch to windUnits!\");\n\t\t\tthis.windUnits = \"beaufort\";\n\t\t}\n\t\tif (typeof this.config.showHumidity === \"boolean\") {\n\t\t\tLog.warn(\"[weather] Deprecation warning: Please consider updating showHumidity to the new style (config string).\");\n\t\t\tthis.config.showHumidity = this.config.showHumidity ? \"wind\" : \"none\";\n\t\t}\n\n\t\t// Initialize the weather provider.\n\t\tthis.weatherProvider = WeatherProvider.initialize(this.config.weatherProvider, this);\n\n\t\t// Let the weather provider know we are starting.\n\t\tthis.weatherProvider.start();\n\n\t\t// Add custom filters\n\t\tthis.addFilters();\n\n\t\t// Schedule the first update.\n\t\tthis.scheduleUpdate(this.config.initialLoadDelay);\n\t},\n\n\t// Override notification handler.\n\tnotificationReceived (notification, payload, sender) {\n\t\tif (notification === \"CALENDAR_EVENTS\") {\n\t\t\tconst senderClasses = sender.data.classes.toLowerCase().split(\" \");\n\t\t\tif (senderClasses.indexOf(this.config.calendarClass.toLowerCase()) !== -1) {\n\t\t\t\tthis.firstEvent = null;\n\t\t\t\tfor (let event of payload) {\n\t\t\t\t\tif (event.location || event.geo) {\n\t\t\t\t\t\tthis.firstEvent = event;\n\t\t\t\t\t\tLog.debug(\"[weather] First upcoming event with location: \", event);\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t} else if (notification === \"INDOOR_TEMPERATURE\") {\n\t\t\tthis.indoorTemperature = this.roundValue(payload);\n\t\t\tthis.updateDom(300);\n\t\t} else if (notification === \"INDOOR_HUMIDITY\") {\n\t\t\tthis.indoorHumidity = this.roundValue(payload);\n\t\t\tthis.updateDom(300);\n\t\t} else if (notification === \"CURRENT_WEATHER_OVERRIDE\" && this.config.allowOverrideNotification) {\n\t\t\tthis.weatherProvider.notificationReceived(payload);\n\t\t}\n\t},\n\n\t// Select the template depending on the display type.\n\tgetTemplate () {\n\t\tswitch (this.config.type.toLowerCase()) {\n\t\t\tcase \"current\":\n\t\t\t\treturn \"current.njk\";\n\t\t\tcase \"hourly\":\n\t\t\t\treturn \"hourly.njk\";\n\t\t\tcase \"daily\":\n\t\t\tcase \"forecast\":\n\t\t\t\treturn \"forecast.njk\";\n\t\t\t//Make the invalid values use the \"Loading...\" from forecast\n\t\t\tdefault:\n\t\t\t\treturn \"forecast.njk\";\n\t\t}\n\t},\n\n\t// Add all the data to the template.\n\tgetTemplateData () {\n\t\tconst currentData = this.weatherProvider.currentWeather();\n\t\tconst forecastData = this.weatherProvider.weatherForecast();\n\n\t\t// Skip some hourly forecast entries if configured\n\t\tconst hourlyData = this.weatherProvider.weatherHourly()?.filter((e, i) => (i + 1) % this.config.hourlyForecastIncrements === this.config.hourlyForecastIncrements - 1);\n\n\t\treturn {\n\t\t\tconfig: this.config,\n\t\t\tcurrent: currentData,\n\t\t\tforecast: forecastData,\n\t\t\thourly: hourlyData,\n\t\t\tindoor: {\n\t\t\t\thumidity: this.indoorHumidity,\n\t\t\t\ttemperature: this.indoorTemperature\n\t\t\t}\n\t\t};\n\t},\n\n\t// What to do when the weather provider has new information available?\n\tupdateAvailable () {\n\t\tLog.log(\"[weather] New weather information available.\");\n\t\t// this value was changed from 0 to 300 to stabilize weather tests:\n\t\tthis.updateDom(300);\n\t\tthis.scheduleUpdate();\n\n\t\tif (this.weatherProvider.currentWeather()) {\n\t\t\tthis.sendNotification(\"CURRENTWEATHER_TYPE\", { type: this.weatherProvider.currentWeather().weatherType?.replace(\"-\", \"_\") });\n\t\t}\n\n\t\tconst notificationPayload = {\n\t\t\tcurrentWeather: this.config.units === \"imperial\"\n\t\t\t\t? WeatherUtils.convertWeatherObjectToImperial(this.weatherProvider?.currentWeatherObject?.simpleClone()) ?? null\n\t\t\t\t: this.weatherProvider?.currentWeatherObject?.simpleClone() ?? null,\n\t\t\tforecastArray: this.config.units === \"imperial\"\n\t\t\t\t? this.weatherProvider?.weatherForecastArray?.map((ar) => WeatherUtils.convertWeatherObjectToImperial(ar.simpleClone())) ?? []\n\t\t\t\t: this.weatherProvider?.weatherForecastArray?.map((ar) => ar.simpleClone()) ?? [],\n\t\t\thourlyArray: this.config.units === \"imperial\"\n\t\t\t\t? this.weatherProvider?.weatherHourlyArray?.map((ar) => WeatherUtils.convertWeatherObjectToImperial(ar.simpleClone())) ?? []\n\t\t\t\t: this.weatherProvider?.weatherHourlyArray?.map((ar) => ar.simpleClone()) ?? [],\n\t\t\tlocationName: this.weatherProvider?.fetchedLocationName,\n\t\t\tproviderName: this.weatherProvider.providerName\n\t\t};\n\n\t\tthis.sendNotification(\"WEATHER_UPDATED\", notificationPayload);\n\t},\n\n\tscheduleUpdate (delay = null) {\n\t\tlet nextLoad = this.config.updateInterval;\n\t\tif (delay !== null && delay >= 0) {\n\t\t\tnextLoad = delay;\n\t\t}\n\n\t\tsetTimeout(() => {\n\t\t\tswitch (this.config.type.toLowerCase()) {\n\t\t\t\tcase \"current\":\n\t\t\t\t\tthis.weatherProvider.fetchCurrentWeather();\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"hourly\":\n\t\t\t\t\tthis.weatherProvider.fetchWeatherHourly();\n\t\t\t\t\tbreak;\n\t\t\t\tcase \"daily\":\n\t\t\t\tcase \"forecast\":\n\t\t\t\t\tthis.weatherProvider.fetchWeatherForecast();\n\t\t\t\t\tbreak;\n\t\t\t\tdefault:\n\t\t\t\t\tLog.error(`[weather] Invalid type ${this.config.type} configured (must be one of 'current', 'hourly', 'daily' or 'forecast')`);\n\t\t\t}\n\t\t}, nextLoad);\n\t},\n\n\troundValue (temperature) {\n\t\tconst decimals = this.config.roundTemp ? 0 : 1;\n\t\tconst roundValue = parseFloat(temperature).toFixed(decimals);\n\t\treturn roundValue === \"-0\" ? 0 : roundValue;\n\t},\n\n\taddFilters () {\n\t\tthis.nunjucksEnvironment().addFilter(\n\t\t\t\"formatTime\",\n\t\t\tfunction (date) {\n\t\t\t\treturn formatTime(this.config, date);\n\t\t\t}.bind(this)\n\t\t);\n\n\t\tthis.nunjucksEnvironment().addFilter(\n\t\t\t\"unit\",\n\t\t\tfunction (value, type, valueUnit) {\n\t\t\t\tlet formattedValue;\n\t\t\t\tif (type === \"temperature\") {\n\t\t\t\t\tformattedValue = `${this.roundValue(WeatherUtils.convertTemp(value, this.config.tempUnits))}°`;\n\t\t\t\t\tif (this.config.degreeLabel) {\n\t\t\t\t\t\tif (this.config.tempUnits === \"metric\") {\n\t\t\t\t\t\t\tformattedValue += \"C\";\n\t\t\t\t\t\t} else if (this.config.tempUnits === \"imperial\") {\n\t\t\t\t\t\t\tformattedValue += \"F\";\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\tformattedValue += \"K\";\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else if (type === \"precip\") {\n\t\t\t\t\tif (value === null || isNaN(value)) {\n\t\t\t\t\t\tformattedValue = \"\";\n\t\t\t\t\t} else {\n\t\t\t\t\t\tformattedValue = WeatherUtils.convertPrecipitationUnit(value, valueUnit, this.config.units);\n\t\t\t\t\t}\n\t\t\t\t} else if (type === \"humidity\") {\n\t\t\t\t\tformattedValue = `${value}%`;\n\t\t\t\t} else if (type === \"wind\") {\n\t\t\t\t\tformattedValue = WeatherUtils.convertWind(value, this.config.windUnits);\n\t\t\t\t}\n\t\t\t\treturn formattedValue;\n\t\t\t}.bind(this)\n\t\t);\n\n\t\tthis.nunjucksEnvironment().addFilter(\n\t\t\t\"roundValue\",\n\t\t\tfunction (value) {\n\t\t\t\treturn this.roundValue(value);\n\t\t\t}.bind(this)\n\t\t);\n\n\t\tthis.nunjucksEnvironment().addFilter(\n\t\t\t\"decimalSymbol\",\n\t\t\tfunction (value) {\n\t\t\t\treturn value.toString().replace(/\\./g, this.config.decimalSymbol);\n\t\t\t}.bind(this)\n\t\t);\n\n\t\tthis.nunjucksEnvironment().addFilter(\n\t\t\t\"calcNumSteps\",\n\t\t\tfunction (forecast) {\n\t\t\t\treturn Math.min(forecast.length, this.config.maxNumberOfDays);\n\t\t\t}.bind(this)\n\t\t);\n\n\t\tthis.nunjucksEnvironment().addFilter(\n\t\t\t\"calcNumEntries\",\n\t\t\tfunction (dataArray) {\n\t\t\t\treturn Math.min(dataArray.length, this.config.maxEntries);\n\t\t\t}.bind(this)\n\t\t);\n\n\t\tthis.nunjucksEnvironment().addFilter(\n\t\t\t\"opacity\",\n\t\t\tfunction (currentStep, numSteps) {\n\t\t\t\tif (this.config.fade && this.config.fadePoint < 1) {\n\t\t\t\t\tif (this.config.fadePoint < 0) {\n\t\t\t\t\t\tthis.config.fadePoint = 0;\n\t\t\t\t\t}\n\t\t\t\t\tconst startingPoint = numSteps * this.config.fadePoint;\n\t\t\t\t\tconst numFadesteps = numSteps - startingPoint;\n\t\t\t\t\tif (currentStep >= startingPoint) {\n\t\t\t\t\t\treturn 1 - (currentStep - startingPoint) / numFadesteps;\n\t\t\t\t\t} else {\n\t\t\t\t\t\treturn 1;\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\treturn 1;\n\t\t\t\t}\n\t\t\t}.bind(this)\n\t\t);\n\t}\n});\n"
  },
  {
    "path": "modules/default/weather/weatherobject.js",
    "content": "/* global SunCalc, WeatherUtils */\n\n/**\n * @external Moment\n */\nclass WeatherObject {\n\n\t/**\n\t * Constructor for a WeatherObject\n\t */\n\tconstructor () {\n\t\tthis.date = null;\n\t\tthis.windSpeed = null;\n\t\tthis.windFromDirection = null;\n\t\tthis.sunrise = null;\n\t\tthis.sunset = null;\n\t\tthis.temperature = null;\n\t\tthis.minTemperature = null;\n\t\tthis.maxTemperature = null;\n\t\tthis.weatherType = null;\n\t\tthis.humidity = null;\n\t\tthis.precipitationAmount = null;\n\t\tthis.precipitationUnits = null;\n\t\tthis.precipitationProbability = null;\n\t\tthis.feelsLikeTemp = null;\n\t}\n\n\tcardinalWindDirection () {\n\t\tif (this.windFromDirection > 11.25 && this.windFromDirection <= 33.75) {\n\t\t\treturn \"NNE\";\n\t\t} else if (this.windFromDirection > 33.75 && this.windFromDirection <= 56.25) {\n\t\t\treturn \"NE\";\n\t\t} else if (this.windFromDirection > 56.25 && this.windFromDirection <= 78.75) {\n\t\t\treturn \"ENE\";\n\t\t} else if (this.windFromDirection > 78.75 && this.windFromDirection <= 101.25) {\n\t\t\treturn \"E\";\n\t\t} else if (this.windFromDirection > 101.25 && this.windFromDirection <= 123.75) {\n\t\t\treturn \"ESE\";\n\t\t} else if (this.windFromDirection > 123.75 && this.windFromDirection <= 146.25) {\n\t\t\treturn \"SE\";\n\t\t} else if (this.windFromDirection > 146.25 && this.windFromDirection <= 168.75) {\n\t\t\treturn \"SSE\";\n\t\t} else if (this.windFromDirection > 168.75 && this.windFromDirection <= 191.25) {\n\t\t\treturn \"S\";\n\t\t} else if (this.windFromDirection > 191.25 && this.windFromDirection <= 213.75) {\n\t\t\treturn \"SSW\";\n\t\t} else if (this.windFromDirection > 213.75 && this.windFromDirection <= 236.25) {\n\t\t\treturn \"SW\";\n\t\t} else if (this.windFromDirection > 236.25 && this.windFromDirection <= 258.75) {\n\t\t\treturn \"WSW\";\n\t\t} else if (this.windFromDirection > 258.75 && this.windFromDirection <= 281.25) {\n\t\t\treturn \"W\";\n\t\t} else if (this.windFromDirection > 281.25 && this.windFromDirection <= 303.75) {\n\t\t\treturn \"WNW\";\n\t\t} else if (this.windFromDirection > 303.75 && this.windFromDirection <= 326.25) {\n\t\t\treturn \"NW\";\n\t\t} else if (this.windFromDirection > 326.25 && this.windFromDirection <= 348.75) {\n\t\t\treturn \"NNW\";\n\t\t} else {\n\t\t\treturn \"N\";\n\t\t}\n\t}\n\n\t/**\n\t * Determines if the sun sets or rises next. Uses the current time and not\n\t * the date from the weather-forecast.\n\t * @param {Moment} date an optional date where you want to get the next\n\t * action for. Useful only in tests, defaults to the current time.\n\t * @returns {string} \"sunset\" or \"sunrise\"\n\t */\n\tnextSunAction (date = moment()) {\n\t\treturn date.isBetween(this.sunrise, this.sunset) ? \"sunset\" : \"sunrise\";\n\t}\n\n\tfeelsLike () {\n\t\tif (this.feelsLikeTemp) {\n\t\t\treturn this.feelsLikeTemp;\n\t\t}\n\t\treturn WeatherUtils.calculateFeelsLike(this.temperature, this.windSpeed, this.humidity);\n\t}\n\n\t/**\n\t * Checks if the weatherObject is at dayTime.\n\t * @returns {boolean} true if it is at dayTime\n\t */\n\tisDayTime () {\n\t\tconst now = !this.date ? moment() : this.date;\n\t\treturn now.isBetween(this.sunrise, this.sunset, undefined, \"[]\");\n\t}\n\n\t/**\n\t * Update the sunrise / sunset time depending on the location. This can be\n\t * used if your provider doesn't provide that data by itself. Then SunCalc\n\t * is used here to calculate them according to the location.\n\t * @param {number} lat latitude\n\t * @param {number} lon longitude\n\t */\n\tupdateSunTime (lat, lon) {\n\t\tconst now = !this.date ? new Date() : this.date.toDate();\n\t\tconst times = SunCalc.getTimes(now, lat, lon);\n\t\tthis.sunrise = moment(times.sunrise);\n\t\tthis.sunset = moment(times.sunset);\n\t}\n\n\t/**\n\t * Clone to simple object to prevent mutating and deprecation of legacy library.\n\t *\n\t * Before being handed to other modules, mutable values must be cloned safely.\n\t * Especially 'moment' object is not immutable, so original 'date', 'sunrise', 'sunset' could be corrupted or changed by other modules.\n\t * @returns {object} plained object clone of original weatherObject\n\t */\n\tsimpleClone () {\n\t\tconst toFlat = [\"date\", \"sunrise\", \"sunset\"];\n\t\tlet clone = { ...this };\n\t\tfor (const prop of toFlat) {\n\t\t\tclone[prop] = clone?.[prop]?.valueOf() ?? clone?.[prop];\n\t\t}\n\t\treturn clone;\n\t}\n}\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = WeatherObject;\n}\n"
  },
  {
    "path": "modules/default/weather/weatherprovider.js",
    "content": "/* global Class, performWebRequest, OverrideWrapper */\n\n// This class is the blueprint for a weather provider.\nconst WeatherProvider = Class.extend({\n\t// Weather Provider Properties\n\tproviderName: null,\n\tdefaults: {},\n\n\t// The following properties have accessor methods.\n\t// Try to not access them directly.\n\tcurrentWeatherObject: null,\n\tweatherForecastArray: null,\n\tweatherHourlyArray: null,\n\tfetchedLocationName: null,\n\n\t// The following properties will be set automatically.\n\t// You do not need to overwrite these properties.\n\tconfig: null,\n\tdelegate: null,\n\tproviderIdentifier: null,\n\n\t// Weather Provider Methods\n\t// All the following methods can be overwritten, although most are good as they are.\n\n\t// Called when a weather provider is initialized.\n\tinit (config) {\n\t\tthis.config = config;\n\t\tLog.info(`[weatherprovider] ${this.providerName} initialized.`);\n\t},\n\n\t// Called to set the config, this config is the same as the weather module's config.\n\tsetConfig (config) {\n\t\tthis.config = config;\n\t\tLog.info(`[weatherprovider] ${this.providerName} config set.`, this.config);\n\t},\n\n\t// Called when the weather provider is about to start.\n\tstart () {\n\t\tLog.info(`[weatherprovider] ${this.providerName} started.`);\n\t},\n\n\t// This method should start the API request to fetch the current weather.\n\t// This method should definitely be overwritten in the provider.\n\tfetchCurrentWeather () {\n\t\tLog.warn(`[weatherprovider] ${this.providerName} does not override the fetchCurrentWeather method.`);\n\t},\n\n\t// This method should start the API request to fetch the weather forecast.\n\t// This method should definitely be overwritten in the provider.\n\tfetchWeatherForecast () {\n\t\tLog.warn(`[weatherprovider] ${this.providerName} does not override the fetchWeatherForecast method.`);\n\t},\n\n\t// This method should start the API request to fetch the weather hourly.\n\t// This method should definitely be overwritten in the provider.\n\tfetchWeatherHourly () {\n\t\tLog.warn(`[weatherprovider] ${this.providerName} does not override the fetchWeatherHourly method.`);\n\t},\n\n\t// This returns a WeatherDay object for the current weather.\n\tcurrentWeather () {\n\t\treturn this.currentWeatherObject;\n\t},\n\n\t// This returns an array of WeatherDay objects for the weather forecast.\n\tweatherForecast () {\n\t\treturn this.weatherForecastArray;\n\t},\n\n\t// This returns an object containing WeatherDay object(s) depending on the type of call.\n\tweatherHourly () {\n\t\treturn this.weatherHourlyArray;\n\t},\n\n\t// This returns the name of the fetched location or an empty string.\n\tfetchedLocation () {\n\t\treturn this.fetchedLocationName || \"\";\n\t},\n\n\t// Set the currentWeather and notify the delegate that new information is available.\n\tsetCurrentWeather (currentWeatherObject) {\n\t\t// We should check here if we are passing a WeatherDay\n\t\tthis.currentWeatherObject = currentWeatherObject;\n\t},\n\n\t// Set the weatherForecastArray and notify the delegate that new information is available.\n\tsetWeatherForecast (weatherForecastArray) {\n\t\t// We should check here if we are passing a WeatherDay\n\t\tthis.weatherForecastArray = weatherForecastArray;\n\t},\n\n\t// Set the weatherHourlyArray and notify the delegate that new information is available.\n\tsetWeatherHourly (weatherHourlyArray) {\n\t\tthis.weatherHourlyArray = weatherHourlyArray;\n\t},\n\n\t// Set the fetched location name.\n\tsetFetchedLocation (name) {\n\t\tthis.fetchedLocationName = name;\n\t},\n\n\t// Notify the delegate that new weather is available.\n\tupdateAvailable () {\n\t\tthis.delegate.updateAvailable(this);\n\t},\n\n\t/**\n\t * A convenience function to make requests.\n\t * @param {string} url the url to fetch from\n\t * @param {string} type what content-type to expect in the response, can be \"json\" or \"xml\"\n\t * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send\n\t * @param {Array.<string>} expectedResponseHeaders the expected HTTP headers to receive\n\t * @returns {Promise} resolved when the fetch is done\n\t */\n\tasync fetchData (url, type = \"json\", requestHeaders = undefined, expectedResponseHeaders = undefined) {\n\t\tconst mockData = this.config.mockData;\n\t\tif (mockData) {\n\t\t\tconst data = mockData.substring(1, mockData.length - 1);\n\t\t\treturn JSON.parse(data);\n\t\t}\n\t\tconst useCorsProxy = typeof this.config.useCorsProxy !== \"undefined\" && this.config.useCorsProxy;\n\t\treturn performWebRequest(url, type, useCorsProxy, requestHeaders, expectedResponseHeaders, config.basePath);\n\t}\n});\n\n/**\n * Collection of registered weather providers.\n */\nWeatherProvider.providers = [];\n\n/**\n * Static method to register a new weather provider.\n * @param {string} providerIdentifier The name of the weather provider\n * @param {object} providerDetails The details of the weather provider\n */\nWeatherProvider.register = function (providerIdentifier, providerDetails) {\n\tWeatherProvider.providers[providerIdentifier.toLowerCase()] = WeatherProvider.extend(providerDetails);\n};\n\n/**\n * Static method to initialize a new weather provider.\n * @param {string} providerIdentifier The name of the weather provider\n * @param {object} delegate The weather module\n * @returns {object} The new weather provider\n */\nWeatherProvider.initialize = function (providerIdentifier, delegate) {\n\tconst pi = providerIdentifier.toLowerCase();\n\n\tconst provider = new WeatherProvider.providers[pi]();\n\tconst config = Object.assign({}, provider.defaults, delegate.config);\n\n\tprovider.delegate = delegate;\n\tprovider.setConfig(config);\n\n\tprovider.providerIdentifier = pi;\n\tif (!provider.providerName) {\n\t\tprovider.providerName = pi;\n\t}\n\n\tif (config.allowOverrideNotification) {\n\t\treturn new OverrideWrapper(provider);\n\t}\n\n\treturn provider;\n};\n"
  },
  {
    "path": "modules/default/weather/weatherutils.js",
    "content": "const WeatherUtils = {\n\n\t/**\n\t * Convert wind (from m/s) to beaufort scale\n\t * @param {number} speedInMS the windspeed you want to convert\n\t * @returns {number} the speed in beaufort\n\t */\n\tbeaufortWindSpeed (speedInMS) {\n\t\tconst windInKmh = this.convertWind(speedInMS, \"kmh\");\n\t\tconst speeds = [1, 5, 11, 19, 28, 38, 49, 61, 74, 88, 102, 117, 1000];\n\t\tfor (const [index, speed] of speeds.entries()) {\n\t\t\tif (speed > windInKmh) {\n\t\t\t\treturn index;\n\t\t\t}\n\t\t}\n\t\treturn 12;\n\t},\n\n\t/**\n\t * Convert a value in a given unit to a string with a converted\n\t * value and a postfix matching the output unit system.\n\t * @param {number} value - The value to convert.\n\t * @param {string} valueUnit - The unit the values has. Default is mm.\n\t * @param {string} outputUnit - The unit system (imperial/metric) the return value should have.\n\t * @returns {string} - A string with tha value and a unit postfix.\n\t */\n\tconvertPrecipitationUnit (value, valueUnit, outputUnit) {\n\t\tif (valueUnit === \"%\") return `${value.toFixed(0)} ${valueUnit}`;\n\n\t\tlet convertedValue = value;\n\t\tlet conversionUnit = valueUnit;\n\t\tif (outputUnit === \"imperial\") {\n\t\t\tconvertedValue = this.convertPrecipitationToInch(value, valueUnit);\n\t\t\tconversionUnit = \"in\";\n\t\t} else {\n\t\t\tconversionUnit = valueUnit ? valueUnit : \"mm\";\n\t\t}\n\n\t\treturn `${convertedValue.toFixed(2)} ${conversionUnit}`;\n\t},\n\n\t/**\n\t * Convert precipitation value into inch\n\t * @param {number} value the precipitation value for convert\n\t * @param {string} valueUnit can be 'mm' or 'cm'\n\t * @returns {number} the converted precipitation value\n\t */\n\tconvertPrecipitationToInch (value, valueUnit) {\n\t\tif (valueUnit && valueUnit.toLowerCase() === \"cm\") return value * 0.3937007874;\n\t\telse return value * 0.03937007874;\n\t},\n\n\t/**\n\t * Convert temp (from degrees C) into imperial or metric unit depending on\n\t * your config\n\t * @param {number} tempInC the temperature in Celsius you want to convert\n\t * @param {string} unit can be 'imperial' or 'metric'\n\t * @returns {number} the converted temperature\n\t */\n\tconvertTemp (tempInC, unit) {\n\t\treturn unit === \"imperial\" ? tempInC * 1.8 + 32 : tempInC;\n\t},\n\n\t/**\n\t * Convert temp (from degrees C) into metric unit\n\t * @param {number} tempInF the temperature in Fahrenheit you want to convert\n\t * @returns {number} the converted temperature\n\t */\n\tconvertTempToMetric (tempInF) {\n\t\treturn ((tempInF - 32) * 5) / 9;\n\t},\n\n\t/**\n\t * Convert wind speed into another unit.\n\t * @param {number} windInMS the windspeed in meter/sec you want to convert\n\t * @param {string} unit can be 'beaufort', 'kmh', 'knots, 'imperial' (mph)\n\t * or 'metric' (mps)\n\t * @returns {number} the converted windspeed\n\t */\n\tconvertWind (windInMS, unit) {\n\t\tswitch (unit) {\n\t\t\tcase \"beaufort\":\n\t\t\t\treturn this.beaufortWindSpeed(windInMS);\n\t\t\tcase \"kmh\":\n\t\t\t\treturn (windInMS * 3600) / 1000;\n\t\t\tcase \"knots\":\n\t\t\t\treturn windInMS * 1.943844;\n\t\t\tcase \"imperial\":\n\t\t\t\treturn windInMS * 2.2369362920544;\n\t\t\tcase \"metric\":\n\t\t\tdefault:\n\t\t\t\treturn windInMS;\n\t\t}\n\t},\n\n\t/*\n\t * Convert the wind direction cardinal to value\n\t */\n\tconvertWindDirection (windDirection) {\n\t\tconst windCardinals = {\n\t\t\tN: 0,\n\t\t\tNNE: 22,\n\t\t\tNE: 45,\n\t\t\tENE: 67,\n\t\t\tE: 90,\n\t\t\tESE: 112,\n\t\t\tSE: 135,\n\t\t\tSSE: 157,\n\t\t\tS: 180,\n\t\t\tSSW: 202,\n\t\t\tSW: 225,\n\t\t\tWSW: 247,\n\t\t\tW: 270,\n\t\t\tWNW: 292,\n\t\t\tNW: 315,\n\t\t\tNNW: 337\n\t\t};\n\n\t\treturn windCardinals.hasOwnProperty(windDirection) ? windCardinals[windDirection] : null;\n\t},\n\n\tconvertWindToMetric (mph) {\n\t\treturn mph / 2.2369362920544;\n\t},\n\n\tconvertWindToMs (kmh) {\n\t\treturn kmh * 0.27777777777778;\n\t},\n\n\t/**\n\t * Taken from https://community.home-assistant.io/t/calculating-apparent-feels-like-temperature/370834/18\n\t * @param {number} temperature temperature in degrees Celsius\n\t * @param {number} windSpeed wind speed in meter/second\n\t * @param {number} humidity relative humidity in percent\n\t * @returns {number} the feels like temperature in degrees Celsius\n\t */\n\tcalculateFeelsLike (temperature, windSpeed, humidity) {\n\t\tconst windInMph = this.convertWind(windSpeed, \"imperial\");\n\t\tconst tempInF = this.convertTemp(temperature, \"imperial\");\n\n\t\tlet HI;\n\t\tlet WC = tempInF;\n\n\t\t// Calculate wind chill for certain conditions\n\t\tif (tempInF <= 70 && windInMph >= 3) {\n\t\t\tWC = 35.74 + (0.6215 * tempInF) - 35.75 * Math.pow(windInMph, 0.16) + ((0.4275 * tempInF) * Math.pow(windInMph, 0.16));\n\t\t}\n\n\t\t// Steadman Heat Index Vorberechnung\n\t\tconst STEADMAN_HI = 0.5 * (tempInF + 61.0 + ((tempInF - 68.0) * 1.2) + (humidity * 0.094));\n\n\t\tif (STEADMAN_HI >= 80) {\n\t\t\t// Rothfusz-Komplex\n\t\t\tconst ROTHFUSZ_HI = -42.379 + 2.04901523 * tempInF + 10.14333127 * humidity - 0.22475541 * tempInF * humidity - 0.00683783 * tempInF * tempInF - 0.05481717 * humidity * humidity + 0.00122874 * tempInF * tempInF * humidity + 0.00085282 * tempInF * humidity * humidity - 0.00000199 * tempInF * tempInF * humidity * humidity;\n\n\t\t\tHI = ROTHFUSZ_HI;\n\n\t\t\tif (humidity < 13 && tempInF > 80 && tempInF < 112) {\n\t\t\t\tconst ADJUSTMENT = ((13 - humidity) / 4) * Math.pow(Math.abs(17 - (tempInF - 95)), 0.5) / 17; // sqrt Teil\n\t\t\t\tHI = HI - ADJUSTMENT;\n\t\t\t} else if (humidity > 85 && tempInF > 80 && tempInF < 87) {\n\t\t\t\tconst ADJUSTMENT = ((humidity - 85) / 10) * ((87 - tempInF) / 5);\n\t\t\t\tHI = HI + ADJUSTMENT;\n\t\t\t}\n\n\t\t} else { HI = STEADMAN_HI; }\n\n\t\t// Feuchte Lastberechnung FL\n\t\tlet FL;\n\t\tif (tempInF < 50) { FL = WC; }\n\t\telse if (tempInF >= 50 && tempInF < 70) { FL = ((70 - tempInF) / 20) * WC + ((tempInF - 50) / 20) * HI; }\n\t\telse if (tempInF >= 70) { FL = HI; }\n\n\t\treturn this.convertTempToMetric(FL);\n\t},\n\n\t/**\n\t * Converts the Weather Object's values into imperial unit\n\t * @param {object} weatherObject the weather object\n\t * @returns {object} the weather object with converted values to imperial\n\t */\n\tconvertWeatherObjectToImperial (weatherObject) {\n\t\tif (!weatherObject || Object.keys(weatherObject).length === 0) return null;\n\n\t\tlet imperialWeatherObject = { ...weatherObject };\n\n\t\tif (imperialWeatherObject) {\n\t\t\tif (imperialWeatherObject.feelsLikeTemp) imperialWeatherObject.feelsLikeTemp = this.convertTemp(imperialWeatherObject.feelsLikeTemp, \"imperial\");\n\t\t\tif (imperialWeatherObject.maxTemperature) imperialWeatherObject.maxTemperature = this.convertTemp(imperialWeatherObject.maxTemperature, \"imperial\");\n\t\t\tif (imperialWeatherObject.minTemperature) imperialWeatherObject.minTemperature = this.convertTemp(imperialWeatherObject.minTemperature, \"imperial\");\n\t\t\tif (imperialWeatherObject.precipitationAmount) imperialWeatherObject.precipitationAmount = this.convertPrecipitationToInch(imperialWeatherObject.precipitationAmount, imperialWeatherObject.precipitationUnits);\n\t\t\tif (imperialWeatherObject.temperature) imperialWeatherObject.temperature = this.convertTemp(imperialWeatherObject.temperature, \"imperial\");\n\t\t\tif (imperialWeatherObject.windSpeed) imperialWeatherObject.windSpeed = this.convertWind(imperialWeatherObject.windSpeed, \"imperial\");\n\t\t}\n\n\t\treturn imperialWeatherObject;\n\t}\n};\n\nif (typeof module !== \"undefined\") {\n\tmodule.exports = WeatherUtils;\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n\t\"name\": \"magicmirror\",\n\t\"version\": \"2.34.0\",\n\t\"description\": \"The open source modular smart mirror platform.\",\n\t\"keywords\": [\n\t\t\"magic mirror\",\n\t\t\"magicmirror\",\n\t\t\"smart mirror\",\n\t\t\"mirror UI\",\n\t\t\"modular\"\n\t],\n\t\"homepage\": \"https://magicmirror.builders\",\n\t\"bugs\": {\n\t\t\"url\": \"https://github.com/MagicMirrorOrg/MagicMirror/issues\"\n\t},\n\t\"repository\": {\n\t\t\"type\": \"git\",\n\t\t\"url\": \"https://github.com/MagicMirrorOrg/MagicMirror\"\n\t},\n\t\"license\": \"MIT\",\n\t\"author\": \"Michael Teeuw\",\n\t\"contributors\": [\n\t\t{\n\t\t\t\"name\": \"MagicMirror contributors\",\n\t\t\t\"url\": \"https://github.com/MagicMirrorOrg/MagicMirror/graphs/contributors\"\n\t\t}\n\t],\n\t\"type\": \"commonjs\",\n\t\"imports\": {\n\t\t\"#module_functions\": {\n\t\t\t\"default\": \"./js/module_functions.js\"\n\t\t},\n\t\t\"#server_functions\": {\n\t\t\t\"default\": \"./js/server_functions.js\"\n\t\t}\n\t},\n\t\"main\": \"js/electron.js\",\n\t\"scripts\": {\n\t\t\"config:check\": \"node js/check_config.js\",\n\t\t\"postinstall\": \"git clean -df fonts vendor\",\n\t\t\"install-mm\": \"npm install --no-audit --no-fund --no-update-notifier --only=prod --omit=dev\",\n\t\t\"install-mm:dev\": \"npm install --no-audit --no-fund --no-update-notifier && npx playwright install chromium\",\n\t\t\"lint:css\": \"stylelint 'css/main.css' 'css/roboto.css' 'css/font-awesome.css' 'modules/default/**/*.css' --fix\",\n\t\t\"lint:js\": \"eslint --fix\",\n\t\t\"lint:markdown\": \"markdownlint-cli2 . --fix\",\n\t\t\"lint:prettier\": \"prettier . --write\",\n\t\t\"prepare\": \"[ -f node_modules/.bin/husky ] && husky || echo no husky installed.\",\n\t\t\"server\": \"node ./serveronly\",\n\t\t\"server:watch\": \"node ./serveronly/watcher.js\",\n\t\t\"start\": \"node --run start:x11\",\n\t\t\"start:dev\": \"node --run start:x11 -- dev\",\n\t\t\"start:wayland\": \"WAYLAND_DISPLAY=\\\"${WAYLAND_DISPLAY:=wayland-1}\\\" ./node_modules/.bin/electron js/electron.js --ozone-platform=wayland\",\n\t\t\"start:wayland:dev\": \"node --run start:wayland -- dev\",\n\t\t\"start:windows\": \".\\\\node_modules\\\\.bin\\\\electron js\\\\electron.js\",\n\t\t\"start:windows:dev\": \"node --run start:windows -- dev\",\n\t\t\"start:x11\": \"DISPLAY=\\\"${DISPLAY:=:0}\\\" ./node_modules/.bin/electron js/electron.js\",\n\t\t\"start:x11:dev\": \"node --run start:x11 -- dev\",\n\t\t\"test\": \"vitest run\",\n\t\t\"test:calendar\": \"node ./modules/default/calendar/debug.js\",\n\t\t\"test:coverage\": \"vitest run --coverage\",\n\t\t\"test:css\": \"stylelint 'css/main.css' 'css/roboto.css' 'css/font-awesome.css' 'modules/default/**/*.css'\",\n\t\t\"test:e2e\": \"vitest run tests/e2e\",\n\t\t\"test:electron\": \"vitest run tests/electron\",\n\t\t\"test:js\": \"eslint\",\n\t\t\"test:markdown\": \"markdownlint-cli2 .\",\n\t\t\"test:prettier\": \"prettier . --check\",\n\t\t\"test:spelling\": \"cspell . --gitignore\",\n\t\t\"test:ui\": \"vitest --ui\",\n\t\t\"test:unit\": \"vitest run tests/unit\",\n\t\t\"test:watch\": \"vitest\"\n\t},\n\t\"lint-staged\": {\n\t\t\"*\": \"prettier --ignore-unknown --write\",\n\t\t\"*.js\": \"eslint --fix\",\n\t\t\"*.css\": \"stylelint --fix\"\n\t},\n\t\"dependencies\": {\n\t\t\"@fontsource/roboto\": \"^5.2.9\",\n\t\t\"@fontsource/roboto-condensed\": \"^5.2.8\",\n\t\t\"@fortawesome/fontawesome-free\": \"^7.1.0\",\n\t\t\"ajv\": \"^8.17.1\",\n\t\t\"animate.css\": \"^4.1.1\",\n\t\t\"console-stamp\": \"^3.1.2\",\n\t\t\"croner\": \"^9.1.0\",\n\t\t\"envsub\": \"^4.1.0\",\n\t\t\"eslint\": \"^9.39.2\",\n\t\t\"express\": \"^5.2.1\",\n\t\t\"feedme\": \"^2.0.2\",\n\t\t\"helmet\": \"^8.1.0\",\n\t\t\"html-to-text\": \"^9.0.5\",\n\t\t\"iconv-lite\": \"^0.7.1\",\n\t\t\"ipaddr.js\": \"^2.3.0\",\n\t\t\"moment\": \"^2.30.1\",\n\t\t\"moment-timezone\": \"^0.6.0\",\n\t\t\"node-ical\": \"^0.22.1\",\n\t\t\"nunjucks\": \"^3.2.4\",\n\t\t\"pm2\": \"^6.0.14\",\n\t\t\"socket.io\": \"^4.8.3\",\n\t\t\"suncalc\": \"^1.9.0\",\n\t\t\"systeminformation\": \"^5.28.2\",\n\t\t\"undici\": \"^7.16.0\",\n\t\t\"weathericons\": \"^2.1.0\"\n\t},\n\t\"devDependencies\": {\n\t\t\"@stylistic/eslint-plugin\": \"^5.6.1\",\n\t\t\"@vitest/coverage-v8\": \"^4.0.16\",\n\t\t\"@vitest/ui\": \"^4.0.16\",\n\t\t\"cspell\": \"^9.4.0\",\n\t\t\"eslint-plugin-import-x\": \"^4.16.1\",\n\t\t\"eslint-plugin-jsdoc\": \"^61.5.0\",\n\t\t\"eslint-plugin-package-json\": \"^0.85.0\",\n\t\t\"eslint-plugin-playwright\": \"^2.4.0\",\n\t\t\"eslint-plugin-vitest\": \"^0.5.4\",\n\t\t\"express-basic-auth\": \"^1.2.1\",\n\t\t\"husky\": \"^9.1.7\",\n\t\t\"jsdom\": \"^27.4.0\",\n\t\t\"lint-staged\": \"^16.2.7\",\n\t\t\"markdownlint-cli2\": \"^0.20.0\",\n\t\t\"playwright\": \"^1.57.0\",\n\t\t\"prettier\": \"^3.7.4\",\n\t\t\"prettier-plugin-jinja-template\": \"^2.1.0\",\n\t\t\"stylelint\": \"^16.26.1\",\n\t\t\"stylelint-config-standard\": \"^39.0.1\",\n\t\t\"stylelint-prettier\": \"^5.0.3\",\n\t\t\"vitest\": \"^4.0.16\"\n\t},\n\t\"optionalDependencies\": {\n\t\t\"electron\": \"^39.2.7\"\n\t},\n\t\"engines\": {\n\t\t\"node\": \">=22.21.1 <23 || >=24\"\n\t}\n}\n"
  },
  {
    "path": "prettier.config.mjs",
    "content": "const config = {\n\tplugins: [\"prettier-plugin-jinja-template\"],\n\toverrides: [\n\t\t{\n\t\t\tfiles: \"*.md\",\n\t\t\toptions: {\n\t\t\t\tparser: \"markdown\"\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\tfiles: [\"*.njk\"],\n\t\t\toptions: {\n\t\t\t\tparser: \"jinja-template\"\n\t\t\t}\n\t\t}\n\t],\n\ttrailingComma: \"none\"\n};\n\nexport default config;\n"
  },
  {
    "path": "serveronly/index.js",
    "content": "const app = require(\"../js/app\");\nconst Log = require(\"../js/logger\");\n\napp.start().then((config) => {\n\tconst bindAddress = config.address ? config.address : \"localhost\";\n\tconst httpType = config.useHttps ? \"https\" : \"http\";\n\tLog.info(`\\n>>>   Ready to go! Please point your browser to: ${httpType}://${bindAddress}:${global.mmPort || config.port}   <<<`);\n});\n"
  },
  {
    "path": "serveronly/watcher.js",
    "content": "// Load lightweight internal alias resolver to enable require(\"logger\")\nrequire(\"../js/alias-resolver\");\n\nconst { spawn } = require(\"child_process\");\nconst fs = require(\"fs\");\nconst path = require(\"path\");\nconst net = require(\"net\");\nconst http = require(\"http\");\nconst Log = require(\"logger\");\nconst { getConfigFilePath } = require(\"#server_functions\");\n\nconst RESTART_DELAY_MS = 500;\nconst PORT_CHECK_MAX_ATTEMPTS = 20;\nconst PORT_CHECK_INTERVAL_MS = 500;\n\nlet child = null;\nlet restartTimer = null;\nlet isShuttingDown = false;\nlet isRestarting = false;\nlet serverConfig = null;\nconst rootDir = path.join(__dirname, \"..\");\n\n/**\n * Get the server configuration (port and address)\n * @returns {{port: number, address: string}} The server config\n */\nfunction getServerConfig () {\n\tif (serverConfig) return serverConfig;\n\n\ttry {\n\t\tconst configPath = getConfigFilePath();\n\t\tdelete require.cache[require.resolve(configPath)];\n\t\tconst config = require(configPath);\n\t\tserverConfig = {\n\t\t\tport: global.mmPort || config.port || 8080,\n\t\t\taddress: config.address || \"localhost\"\n\t\t};\n\t} catch (err) {\n\t\tserverConfig = { port: 8080, address: \"localhost\" };\n\t}\n\n\treturn serverConfig;\n}\n\n/**\n * Check if a port is available on the configured address\n * @param {number} port The port to check\n * @returns {Promise<boolean>} True if port is available\n */\nfunction isPortAvailable (port) {\n\treturn new Promise((resolve) => {\n\t\tconst server = net.createServer();\n\n\t\tserver.once(\"error\", () => {\n\t\t\tresolve(false);\n\t\t});\n\n\t\tserver.once(\"listening\", () => {\n\t\t\tserver.close();\n\t\t\tresolve(true);\n\t\t});\n\n\t\t// Use the same address as the actual server will bind to\n\t\tconst { address } = getServerConfig();\n\t\tserver.listen(port, address);\n\t});\n}\n\n/**\n * Wait until port is available\n * @param {number} port The port to wait for\n * @param {number} maxAttempts Maximum number of attempts\n * @returns {Promise<void>}\n */\nasync function waitForPort (port, maxAttempts = PORT_CHECK_MAX_ATTEMPTS) {\n\tfor (let i = 0; i < maxAttempts; i++) {\n\t\tif (await isPortAvailable(port)) {\n\t\t\tLog.info(`Port ${port} is now available`);\n\t\t\treturn;\n\t\t}\n\t\tawait new Promise((resolve) => setTimeout(resolve, PORT_CHECK_INTERVAL_MS));\n\t}\n\tLog.warn(`Port ${port} still not available after ${maxAttempts} attempts`);\n}\n\n/**\n * Start the server process\n */\nfunction startServer () {\n\t// Start node directly instead of via npm to avoid process tree issues\n\tchild = spawn(\"node\", [\"./serveronly\"], {\n\t\tstdio: \"inherit\",\n\t\tcwd: path.join(__dirname, \"..\")\n\t});\n\n\tchild.on(\"error\", (error) => {\n\t\tLog.error(\"Failed to start server process:\", error.message);\n\t\tchild = null;\n\t});\n\n\tchild.on(\"exit\", (code, signal) => {\n\t\tchild = null;\n\n\t\tif (isShuttingDown) {\n\t\t\treturn;\n\t\t}\n\n\t\tif (isRestarting) {\n\t\t\t// Expected restart - don't log as error\n\t\t\tisRestarting = false;\n\t\t} else {\n\t\t\t// Unexpected exit\n\t\t\tLog.error(`Server exited unexpectedly with code ${code} and signal ${signal}`);\n\t\t}\n\t});\n}\n\n/**\n * Send reload notification to all connected clients\n */\nfunction notifyClientsToReload () {\n\tconst { port, address } = getServerConfig();\n\tconst options = {\n\t\thostname: address,\n\t\tport: port,\n\t\tpath: \"/reload\",\n\t\tmethod: \"GET\"\n\t};\n\n\tconst req = http.request(options, (res) => {\n\t\tif (res.statusCode === 200) {\n\t\t\tLog.info(\"Reload notification sent to clients\");\n\t\t}\n\t});\n\n\treq.on(\"error\", (err) => {\n\t\t// Server might not be running yet, ignore\n\t\tLog.debug(`Could not send reload notification: ${err.message}`);\n\t});\n\n\treq.end();\n}\n\n/**\n * Restart the server process\n * @param {string} reason The reason for the restart\n */\nasync function restartServer (reason) {\n\tif (restartTimer) clearTimeout(restartTimer);\n\n\trestartTimer = setTimeout(async () => {\n\t\tLog.info(reason);\n\n\t\tif (child) {\n\t\t\tisRestarting = true;\n\n\t\t\t// Get the actual port being used\n\t\t\tconst { port } = getServerConfig();\n\n\t\t\t// Notify clients to reload before restart\n\t\t\tnotifyClientsToReload();\n\n\t\t\t// Set up one-time listener for the exit event\n\t\t\tchild.once(\"exit\", async () => {\n\t\t\t\t// Wait until port is actually available\n\t\t\t\tawait waitForPort(port);\n\t\t\t\t// Reset config cache in case it changed\n\t\t\t\tserverConfig = null;\n\t\t\t\tstartServer();\n\t\t\t});\n\n\t\t\tchild.kill(\"SIGTERM\");\n\t\t} else {\n\t\t\tstartServer();\n\t\t}\n\t}, RESTART_DELAY_MS);\n}\n\n/**\n * Watch a specific file for changes and restart the server on change\n * Watches the parent directory to handle editors that use atomic writes\n * @param {string} file The file path to watch\n */\nfunction watchFile (file) {\n\ttry {\n\t\tconst fileName = path.basename(file);\n\t\tconst dirName = path.dirname(file);\n\n\t\tconst watcher = fs.watch(dirName, (_eventType, changedFile) => {\n\t\t\t// Only trigger for the specific file we're interested in\n\t\t\tif (changedFile !== fileName) return;\n\n\t\t\tLog.info(`[watchFile] Change detected in: ${file}`);\n\t\t\tif (restartTimer) clearTimeout(restartTimer);\n\n\t\t\trestartTimer = setTimeout(() => {\n\t\t\t\tLog.info(`[watchFile] Triggering restart due to change in: ${file}`);\n\t\t\t\trestartServer(`File changed: ${path.basename(file)} — restarting...`);\n\t\t\t}, RESTART_DELAY_MS);\n\t\t});\n\n\t\twatcher.on(\"error\", (error) => {\n\t\t\tLog.error(`Watcher error for ${file}:`, error.message);\n\t\t});\n\n\t\tLog.log(`Watching file: ${file}`);\n\t} catch (error) {\n\t\tLog.error(`Failed to watch file ${file}:`, error.message);\n\t}\n}\n\nstartServer();\n\n// Setup file watching based on config\ntry {\n\tconst configPath = getConfigFilePath();\n\tdelete require.cache[require.resolve(configPath)];\n\tconst config = require(configPath);\n\n\tlet watchTargets = [];\n\tif (Array.isArray(config.watchTargets) && config.watchTargets.length > 0) {\n\t\twatchTargets = config.watchTargets.filter((target) => typeof target === \"string\" && target.trim() !== \"\");\n\t}\n\n\tif (watchTargets.length === 0) {\n\t\tLog.warn(\"Watch mode is enabled but no watchTargets are configured. No files will be monitored. Set the watchTargets array in your config.js to enable file watching.\");\n\t}\n\n\tLog.log(`Watch mode enabled. Watching ${watchTargets.length} file(s)`);\n\n\t// Watch each target file\n\tfor (const target of watchTargets) {\n\t\tconst targetPath = path.isAbsolute(target)\n\t\t\t? target\n\t\t\t: path.join(rootDir, target);\n\n\t\t// Check if file exists\n\t\tif (!fs.existsSync(targetPath)) {\n\t\t\tLog.warn(`Watch target does not exist: ${targetPath}`);\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Check if it's a file (directories are not supported)\n\t\tconst stats = fs.statSync(targetPath);\n\t\tif (stats.isFile()) {\n\t\t\twatchFile(targetPath);\n\t\t} else {\n\t\t\tLog.warn(`Watch target is not a file (directories not supported): ${targetPath}`);\n\t\t}\n\t}\n} catch (err) {\n\t// Config file might not exist or be invalid, use fallback targets\n\tLog.warn(\"Could not load watchTargets from config.\");\n}\n\nprocess.on(\"SIGINT\", () => {\n\tisShuttingDown = true;\n\tif (restartTimer) clearTimeout(restartTimer);\n\tif (child) child.kill(\"SIGTERM\");\n\tprocess.exit(0);\n});\n"
  },
  {
    "path": "stylelint.config.mjs",
    "content": "const config = {\n\textends: [\"stylelint-config-standard\", \"stylelint-prettier/recommended\"],\n\troot: true,\n\trules: {}\n};\n\nexport default config;\n"
  },
  {
    "path": "tests/configs/customregions.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tmodules:\n\t\t// Using exotic content. This is why don't accept go to JSON configuration file\n\t\t(() => {\n\t\t\tlet positions = [\"row3_left\", \"top3_left1\"];\n\t\t\tlet modules = Array();\n\t\t\tfor (let idx in positions) {\n\t\t\t\tmodules.push({\n\t\t\t\t\tmodule: \"helloworld\",\n\t\t\t\t\tposition: positions[idx],\n\t\t\t\t\tconfig: {\n\t\t\t\t\t\ttext: `Text in ${positions[idx]}`\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t}\n\t\t\treturn modules;\n\t\t})()\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/default.js",
    "content": "if (typeof exports === \"object\") {\n\t// running in nodejs (not in browser)\n\texports.configFactory = (options) => {\n\t\treturn Object.assign(\n\t\t\t{\n\t\t\t\telectronOptions: {\n\t\t\t\t\twebPreferences: {\n\t\t\t\t\t\tnodeIntegration: true,\n\t\t\t\t\t\tenableRemoteModule: true,\n\t\t\t\t\t\tcontextIsolation: false\n\t\t\t\t\t}\n\t\t\t\t},\n\n\t\t\t\tmodules: []\n\t\t\t},\n\t\t\toptions\n\t\t);\n\t};\n}\n"
  },
  {
    "path": "tests/configs/empty_ipWhiteList.js",
    "content": "let config = require(`${process.cwd()}/tests/configs/default.js`).configFactory({\n\tipWhitelist: [],\n\tport: 8282\n});\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/alert/welcome_false.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"alert\",\n\t\t\tconfig: {\n\t\t\t\tdisplay_time: 1000000,\n\t\t\t\twelcome_message: false\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/alert/welcome_string.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"alert\",\n\t\t\tconfig: {\n\t\t\t\tdisplay_time: 1000000,\n\t\t\t\twelcome_message: \"Custom welcome message!\"\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/alert/welcome_true.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"alert\",\n\t\t\tconfig: {\n\t\t\t\tdisplay_time: 1000000,\n\t\t\t\twelcome_message: true\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/3_move_first_allday_repeating_event.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\n\ttimeFormat: 24,\n\tunits: \"metric\",\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tfade: false,\n\t\t\t\thideDuplicates: false,\n\t\t\t\tmaximumEntries: 100,\n\t\t\t\turgency: 0,\n\t\t\t\tdateFormat: \"Do.MMM, HH:mm\",\n\t\t\t\tfullDayEventDateFormat: \"Do.MMM\",\n\t\t\t\ttimeFormat: \"absolute\",\n\t\t\t\tgetRelative: 0,\n\t\t\t\tmaximumNumberOfDays: 28,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 100,\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/3_move_first_allday_repeating_event.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/auth-default.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumNumberOfDays: 10000,\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/calendar_test.ics\",\n\t\t\t\t\t\tauth: {\n\t\t\t\t\t\t\tuser: \"MagicMirror\",\n\t\t\t\t\t\t\tpass: \"CallMeADog\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/bad_rrule.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\tlogLevel: [\"INFO\", \"LOG\", \"WARN\", \"ERROR\", \"DEBUG\"],\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/bad_rrule.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/basic-auth.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumNumberOfDays: 10000,\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/calendar_test.ics\",\n\t\t\t\t\t\tauth: {\n\t\t\t\t\t\t\tuser: \"MagicMirror\",\n\t\t\t\t\t\t\tpass: \"CallMeADog\",\n\t\t\t\t\t\t\tmethod: \"basic\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/berlin_end_of_day_repeating.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\n\ttimeFormat: 24,\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tfade: false,\n\t\t\t\turgency: 0,\n\t\t\t\tdateFormat: \"Do.MMM, HH:mm\",\n\t\t\t\tfullDayEventDateFormat: \"Do.MMM\",\n\t\t\t\ttimeFormat: \"absolute\",\n\t\t\t\tgetRelative: 0,\n\t\t\t\tmaximumNumberOfDays: 28,\n\t\t\t\tshowEnd: true,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 100,\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/end_of_day_berlin_moved.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/berlin_multi.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\n\ttimeFormat: 24,\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tfade: false,\n\t\t\t\turgency: 0,\n\t\t\t\tdateFormat: \"Do.MMM, HH:mm\",\n\t\t\t\tfullDayEventDateFormat: \"Do.MMM\",\n\t\t\t\ttimeFormat: \"absolute\",\n\t\t\t\tgetRelative: 0,\n\t\t\t\tmaximumNumberOfDays: 28,\n\t\t\t\tshowEnd: true,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 100,\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/RepeatingEvent.Oct21.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/berlin_whole_day_event_moved_over_dst_change.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\n\ttimeFormat: 24,\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tfade: false,\n\t\t\t\turgency: 0,\n\t\t\t\tdateFormat: \"Do.MMM, HH:mm\",\n\t\t\t\tfullDayEventDateFormat: \"Do.MMM\",\n\t\t\t\ttimeFormat: \"absolute\",\n\t\t\t\tgetRelative: 0,\n\t\t\t\tmaximumNumberOfDays: 28,\n\t\t\t\tshowEnd: true,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 100,\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/whole_day_moved_over_dst_change_berlin.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/changed-port.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumNumberOfDays: 10000,\n\t\t\t\t\t\turl: \"http://localhost:8010/tests/mocks/calendar_test.ics\",\n\t\t\t\t\t\tauth: {\n\t\t\t\t\t\t\tuser: \"MagicMirror\",\n\t\t\t\t\t\t\tpass: \"CallMeADog\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/chicago-looking-at-ny-recurring.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\n\ttimeFormat: 24,\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tfade: false,\n\t\t\t\turgency: 0,\n\t\t\t\tdateFormat: \"Do.MMM, HH:mm\",\n\t\t\t\tfullDayEventDateFormat: \"Do.MMM\",\n\t\t\t\ttimeFormat: \"absolute\",\n\t\t\t\tgetRelative: 0,\n\t\t\t\tmaximumNumberOfDays: 28,\n\t\t\t\tshowEnd: true,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 100,\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/chicago-nyedge.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/chicago_late_in_timezone.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\n\ttimeFormat: 24,\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tfade: false,\n\t\t\t\turgency: 0,\n\t\t\t\tdateFormat: \"Do.MMM, HH:mm\",\n\t\t\t\tfullDayEventDateFormat: \"Do.MMM\",\n\t\t\t\ttimeFormat: \"absolute\",\n\t\t\t\tgetRelative: 0,\n\t\t\t\tmaximumNumberOfDays: 20,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 100,\n\t\t\t\t\t\t//url: \"http://localhost:8080/tests/mocks/chicago_late_in_timezone.ics\"\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/chicago_late_in_timezone.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/countCalendarEvents.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\tforeignModulesDir: \"tests/mocks\",\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\n\t\t\tconfig: {\n\t\t\t\tmaximumEntries: 1,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tfetchInterval: 10000, //7 * 24 * 60 * 60 * 1000,\n\t\t\t\t\t\tsymbol: [\"calendar-check\", \"google\"],\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/12_events.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\tmodule: \"testNotification\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tdebug: true,\n\t\t\t\tmatch: {\n\t\t\t\t\tmatchtype: \"count\",\n\t\t\t\t\tnotificationID: \"CALENDAR_EVENTS\"\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/custom.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tcustomEvents: [{ keyword: \"CustomEvent\", symbol: \"dice\", eventClass: \"undo\" }],\n\t\t\t\tforceUseCurrentTime: true,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 5,\n\t\t\t\t\t\tpastDaysCount: 5,\n\t\t\t\t\t\tbroadcastPastEvents: true,\n\t\t\t\t\t\tmaximumNumberOfDays: 10000,\n\t\t\t\t\t\tsymbol: \"birthday-cake\",\n\t\t\t\t\t\tfullDaySymbol: \"calendar-day\",\n\t\t\t\t\t\trecurringSymbol: \"undo\",\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/calendar_test_icons.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/default.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumNumberOfDays: 10000,\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/calendar_test.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/diff_tz_start_end.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\n\ttimeFormat: 24,\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tfade: false,\n\t\t\t\turgency: 0,\n\t\t\t\tdateFormat: \"Do.MMM, HH:mm\",\n\t\t\t\tdateEndFormat: \"Do.MMM, HH:mm\",\n\t\t\t\tfullDayEventDateFormat: \"Do.MMM\",\n\t\t\t\ttimeFormat: \"absolute\",\n\t\t\t\tgetRelative: 0,\n\t\t\t\tmaximumNumberOfDays: 28,\n\t\t\t\tshowEnd: true,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 100,\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/diff_tz_start_end.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/end_of_day_berlin_moved.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\n\ttimeFormat: 24,\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tfade: false,\n\t\t\t\turgency: 0,\n\t\t\t\tdateFormat: \"Do.MMM, HH:mm\",\n\t\t\t\tfullDayEventDateFormat: \"Do.MMM\",\n\t\t\t\ttimeFormat: \"absolute\",\n\t\t\t\tgetRelative: 0,\n\t\t\t\tmaximumNumberOfDays: 28,\n\t\t\t\tshowEnd: true,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 100,\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/end_of_day_berlin_moved.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_display_end.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\n\ttimeFormat: 24,\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tfade: false,\n\t\t\t\turgency: 0,\n\t\t\t\tdateFormat: \"Do.MMM, HH:mm\",\n\t\t\t\tdateEndFormat: \"Do.MMM, HH:mm\",\n\t\t\t\tfullDayEventDateFormat: \"Do.MMM\",\n\t\t\t\ttimeFormat: \"absolute\",\n\t\t\t\tgetRelative: 0,\n\t\t\t\tshowEnd: true,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 100,\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/event_with_time_over_multiple_days_non_repeating.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_no_display_end.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\n\ttimeFormat: 24,\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tfade: false,\n\t\t\t\turgency: 0,\n\t\t\t\tdateFormat: \"Do.MMM, HH:mm\",\n\t\t\t\tdateEndFormat: \"Do.MMM, HH:mm\",\n\t\t\t\tfullDayEventDateFormat: \"Do.MMM\",\n\t\t\t\ttimeFormat: \"absolute\",\n\t\t\t\tgetRelative: 0,\n\t\t\t\tshowEnd: true,\n\t\t\t\tshowEndsOnlyWithDuration: true,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 100,\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/event_with_time_over_multiple_days_non_repeating.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/exdate_and_recurrence_together.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\n\ttimeFormat: 24,\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tfade: false,\n\t\t\t\turgency: 0,\n\t\t\t\tdateFormat: \"Do.MMM, HH:mm\",\n\t\t\t\tfullDayEventDateFormat: \"Do.MMM\",\n\t\t\t\ttimeFormat: \"absolute\",\n\t\t\t\tgetRelative: 0,\n\t\t\t\tmaximumNumberOfDays: 28,\n\t\t\t\tshowEnd: true,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 100,\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/exdate_and_recurrence_together.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/exdate_la_at_midnight_dst.js",
    "content": "/*\n * MagicMirror² Test calendar exdate\n *\n * By jkriegshauser\n * MIT Licensed.\n *\n * See issue #3250\n * See tests/electron/modules/calendar_spec.js\n */\nlet config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tmaximumEntries: 100,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 100,\n\t\t\t\t\t\tmaximumNumberOfDays: 28, // 4 weeks, 2 of which are skipped\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/exdate_la_at_midnight_dst.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/exdate_la_at_midnight_std.js",
    "content": "/*\n * MagicMirror² Test calendar exdate\n *\n * By jkriegshauser\n * MIT Licensed.\n *\n * See issue #3250\n * See tests/electron/modules/calendar_spec.js\n */\nlet config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tmaximumEntries: 100,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 100,\n\t\t\t\t\t\tmaximumNumberOfDays: 28, // 4 weeks, 2 of which are skipped\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/exdate_la_at_midnight_std.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/exdate_la_before_midnight.js",
    "content": "/*\n * MagicMirror² Test calendar exdate\n *\n * By jkriegshauser\n * MIT Licensed.\n *\n * See issue #3250\n * See tests/electron/modules/calendar_spec.js\n */\nlet config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tmaximumEntries: 100,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 100,\n\t\t\t\t\t\tmaximumNumberOfDays: 28, // 4 weeks, 2 of which are skipped\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/exdate_la_before_midnight.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/exdate_syd_at_midnight_dst.js",
    "content": "/*\n * MagicMirror² Test calendar exdate\n *\n * By jkriegshauser\n * MIT Licensed.\n *\n * See issue #3250\n * See tests/electron/modules/calendar_spec.js\n */\nlet config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tmaximumEntries: 100,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 100,\n\t\t\t\t\t\tmaximumNumberOfDays: 28, // 4 weeks, 2 of which are skipped\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/exdate_syd_at_midnight_dst.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/exdate_syd_at_midnight_std.js",
    "content": "/*\n * MagicMirror² Test calendar exdate\n *\n * By jkriegshauser\n * MIT Licensed.\n *\n * See issue #3250\n * See tests/electron/modules/calendar_spec.js\n */\nlet config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tmaximumEntries: 100,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 100,\n\t\t\t\t\t\tmaximumNumberOfDays: 28, // 4 weeks, 2 of which are skipped\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/exdate_syd_at_midnight_std.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/exdate_syd_before_midnight.js",
    "content": "/*\n * MagicMirror² Test calendar exdate\n *\n * By jkriegshauser\n * MIT Licensed.\n *\n * See issue #3250\n * See tests/electron/modules/calendar_spec.js\n */\nlet config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tmaximumEntries: 100,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 100,\n\t\t\t\t\t\tmaximumNumberOfDays: 28, // 4 weeks, 2 of which are skipped\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/exdate_syd_before_midnight.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/fail-basic-auth.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumNumberOfDays: 10000,\n\t\t\t\t\t\turl: \"http://localhost:8020/tests/mocks/calendar_test.ics\",\n\t\t\t\t\t\tauth: {\n\t\t\t\t\t\t\tuser: \"MagicMirror\",\n\t\t\t\t\t\t\tpass: \"StairwayToHeaven\",\n\t\t\t\t\t\t\tmethod: \"basic\"\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/fullday_event_over_multiple_days_nonrepeating.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\n\ttimeFormat: 24,\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tfade: false,\n\t\t\t\turgency: 0,\n\t\t\t\tdateFormat: \"Do.MMM, HH:mm\",\n\t\t\t\tfullDayEventDateFormat: \"Do.MMM\",\n\t\t\t\ttimeFormat: \"absolute\",\n\t\t\t\tgetRelative: 0,\n\t\t\t\tshowEnd: true,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 100,\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/fullday_event_over_multiple_days_nonrepeating.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/fullday_until.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\thideDuplicates: false,\n\t\t\t\tmaximumEntries: 100,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 100,\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/fullday_until.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/germany_at_end_of_day_repeating.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\thideDuplicates: false,\n\t\t\t\tmaximumEntries: 100,\n\t\t\t\tsliceMultiDayEvents: true,\n\t\t\t\tdateFormat: \"MMM Do, HH:mm\",\n\t\t\t\ttimeFormat: \"absolute\",\n\t\t\t\tgetRelative: 0,\n\t\t\t\turgency: 0,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 100,\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/germany_at_end_of_day_repeating.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/long-fullday-event.js",
    "content": "/*\n * MagicMirror² Test config for fullday calendar entries over multiple days\n *\n * By Paranoid93 https://github.com/Paranoid93/\n * MIT Licensed.\n */\nlet config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumNumberOfDays: 2,\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/calendar_test_multi_day_starting_today.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/old-basic-auth.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumNumberOfDays: 10000,\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/calendar_test.ics\",\n\t\t\t\t\t\tuser: \"MagicMirror\",\n\t\t\t\t\t\tpass: \"CallMeADog\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/recurring.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 6,\n\t\t\t\t\t\tmaximumNumberOfDays: 3650,\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/calendar_test_recurring.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/rrule_until.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\thideDuplicates: false,\n\t\t\t\tmaximumEntries: 100,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 100,\n\t\t\t\t\t\tmaximumNumberOfDays: 1, // Just today\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/rrule_until.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/show-duplicates-in-calendar.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tmaximumEntries: 30,\n\t\t\t\thideDuplicates: false,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 15,\n\t\t\t\t\t\tmaximumNumberOfDays: 10000,\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/calendar_test.ics\" // contains 11 events\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 15,\n\t\t\t\t\t\tmaximumNumberOfDays: 10000,\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/calendar_test_clone.ics\" // clone of upper calendar\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/single-fullday-event.js",
    "content": "/*\n * MagicMirror² Test config for fullday calendar entries over multiple days\n *\n * By Paranoid93 https://github.com/Paranoid93/\n * MIT Licensed.\n */\nlet config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumNumberOfDays: 2,\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/calendar_test_full_day_events.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/sliceMultiDayEvents.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\thideDuplicates: false,\n\t\t\t\tmaximumEntries: 100,\n\t\t\t\tsliceMultiDayEvents: true,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tmaximumEntries: 100,\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/sliceMultiDayEvents.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/calendar/symboltest.js",
    "content": "\nlet config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"calendar\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tmaximumEntries: 1,\n\t\t\t\tcalendars: [\n\t\t\t\t\t{\n\t\t\t\t\t\tsymbol: [\"calendar-check\", \"google\"],\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/12_events.ics\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/clock/clock_12hr.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"clock\",\n\t\t\tposition: \"middle_center\"\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/clock/clock_24hr.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"clock\",\n\t\t\tposition: \"middle_center\"\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/clock/clock_analog.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"clock\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\tdisplayType: \"analog\",\n\t\t\t\tanalogFace: \"face-006\",\n\t\t\t\tshowDate: false\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/clock/clock_displaySeconds_false.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"clock\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\tdisplaySeconds: false\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/clock/clock_showDateAnalog.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"clock\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\tshowTime: true,\n\t\t\t\tshowDate: true,\n\t\t\t\tdisplayType: \"analog\"\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/clock/clock_showPeriodUpper.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"clock\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\tshowPeriodUpper: true\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/clock/clock_showSunMoon.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"clock\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\tshowSunTimes: true,\n\t\t\t\tshowMoonTimes: true\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/clock/clock_showSunNoEvent.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"clock\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\tshowSunTimes: \"disableNextEvent\"\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/clock/clock_showTime.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"clock\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\tshowTime: false\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/clock/clock_showWeek.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"clock\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\tshowWeek: true\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/clock/clock_showWeek_short.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"clock\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\tshowWeek: \"short\"\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/clock/de/clock_showWeek.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tlanguage: \"de\",\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"clock\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\tshowWeek: true\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/clock/de/clock_showWeek_short.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tlanguage: \"de\",\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"clock\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\tshowWeek: \"short\"\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/clock/es/clock_12hr.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tlanguage: \"es\",\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"clock\",\n\t\t\tposition: \"middle_center\"\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/clock/es/clock_24hr.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tlanguage: \"es\",\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"clock\",\n\t\t\tposition: \"middle_center\"\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/clock/es/clock_showPeriodUpper.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tlanguage: \"es\",\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"clock\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\tshowPeriodUpper: true\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/clock/es/clock_showWeek.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tlanguage: \"es\",\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"clock\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\tshowWeek: true\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/clock/es/clock_showWeek_short.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tlanguage: \"es\",\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"clock\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\tshowWeek: \"short\"\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/compliments/compliments_animateCSS.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"compliments\",\n\t\t\tposition: \"lower_third\",\n\t\t\tanimateIn: \"flipInX\",\n\t\t\tanimateOut: \"flipOutX\",\n\t\t\tconfig: {\n\t\t\t\tcompliments: {\n\t\t\t\t\tanytime: [\"AnimateCSS Testing...\"]\n\t\t\t\t},\n\t\t\t\tupdateInterval: 2000,\n\t\t\t\tfadeSpeed: 1000\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/compliments/compliments_animateCSS_fallbackToDefault.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"compliments\",\n\t\t\tposition: \"lower_third\",\n\t\t\tanimateIn: \"foo\",\n\t\t\tanimateOut: \"bar\",\n\t\t\tconfig: {\n\t\t\t\tcompliments: {\n\t\t\t\t\tanytime: [\"AnimateCSS Testing...\"]\n\t\t\t\t},\n\t\t\t\tupdateInterval: 2000,\n\t\t\t\tfadeSpeed: 1000\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/compliments/compliments_animateCSS_invertedAnimationName.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"compliments\",\n\t\t\tposition: \"lower_third\",\n\t\t\tanimateIn: \"flipOutX\",\n\t\t\tanimateOut: \"flipInX\",\n\t\t\tconfig: {\n\t\t\t\tcompliments: {\n\t\t\t\t\tanytime: [\"AnimateCSS Testing...\"]\n\t\t\t\t},\n\t\t\t\tupdateInterval: 2000,\n\t\t\t\tfadeSpeed: 1000\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/compliments/compliments_anytime.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"compliments\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\tcompliments: {\n\t\t\t\t\tmorning: [],\n\t\t\t\t\tafternoon: [],\n\t\t\t\t\tevening: [],\n\t\t\t\t\tanytime: [\"Anytime here\"]\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/compliments/compliments_cron_entry.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"compliments\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\tspecialDayUnique: true,\n\t\t\t\tcompliments: {\n\t\t\t\t\tanytime: [\"just a test\"],\n\t\t\t\t\t\"00-10 16-19 * * fri\": [\"just pub time\"]\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") { module.exports = config; }\n"
  },
  {
    "path": "tests/configs/modules/compliments/compliments_date.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"compliments\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\tcompliments: {\n\t\t\t\t\tmorning: [],\n\t\t\t\t\tafternoon: [],\n\t\t\t\t\tevening: [],\n\t\t\t\t\t\"....-01-01\": [\"Happy new year!\"]\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/compliments/compliments_e2e_cron_entry.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"compliments\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\tspecialDayUnique: true,\n\t\t\t\tcompliments: {\n\t\t\t\t\tanytime: [\"just a test\"],\n\t\t\t\t\t\"* * * * *\": [\"anytime cron\"]\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") { module.exports = config; }\n"
  },
  {
    "path": "tests/configs/modules/compliments/compliments_evening.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"compliments\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\tcompliments: {\n\t\t\t\t\tevening: [\"Evening here\"]\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/compliments/compliments_file.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"compliments\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tupdateInterval: 3000,\n\t\t\t\tremoteFile: \"http://localhost:8080/tests/mocks/compliments_test.json\"\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") { module.exports = config; }\n"
  },
  {
    "path": "tests/configs/modules/compliments/compliments_file_change.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"compliments\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tupdateInterval: 3000,\n\t\t\t\tremoteFileRefreshInterval: 1500,\n\t\t\t\tremoteFile: \"http://localhost:8080/tests/mocks/compliments_test.json\",\n\t\t\t\tremoteFile2: \"http://localhost:8080/tests/mocks/compliments_file.json\"\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") { module.exports = config; }\n"
  },
  {
    "path": "tests/configs/modules/compliments/compliments_only_anytime.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"compliments\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\tcompliments: {\n\t\t\t\t\tanytime: [\"Anytime here\"]\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/compliments/compliments_parts_day.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"compliments\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\tcompliments: {\n\t\t\t\t\tmorning: [\"Hi\", \"Good Morning\", \"Morning test\"],\n\t\t\t\t\tafternoon: [\"Hello\", \"Good Afternoon\", \"Afternoon test\"],\n\t\t\t\t\tevening: [\"Hello There\", \"Good Evening\", \"Evening test\"]\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/compliments/compliments_remote.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"compliments\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\tremoteFile: \"http://localhost:8080/tests/mocks/compliments_test.json\"\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/compliments/compliments_specialDayUnique_false.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"compliments\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\tspecialDayUnique: false,\n\t\t\t\tcompliments: {\n\t\t\t\t\tanytime: [\n\t\t\t\t\t\t\"Typical message 1\",\n\t\t\t\t\t\t\"Typical message 2\",\n\t\t\t\t\t\t\"Typical message 3\"\n\t\t\t\t\t],\n\t\t\t\t\t\"....-..-..\": [\"Special day message\"]\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") { module.exports = config; }\n"
  },
  {
    "path": "tests/configs/modules/compliments/compliments_specialDayUnique_true.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"compliments\",\n\t\t\tposition: \"middle_center\",\n\t\t\tconfig: {\n\t\t\t\tspecialDayUnique: true,\n\t\t\t\tcompliments: {\n\t\t\t\t\tanytime: [\n\t\t\t\t\t\t\"Typical message 1\",\n\t\t\t\t\t\t\"Typical message 2\",\n\t\t\t\t\t\t\"Typical message 3\"\n\t\t\t\t\t],\n\t\t\t\t\t\"....-..-..\": [\"Special day message\"]\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") { module.exports = config; }\n"
  },
  {
    "path": "tests/configs/modules/display.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"helloworld\",\n\t\t\tposition: \"top_bar\",\n\t\t\theader: \"test_header\",\n\t\t\tconfig: {\n\t\t\t\ttext: \"Test Display Header\"\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\tmodule: \"helloworld\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\ttext: \"Test Hide Header\"\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/helloworld/helloworld.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"helloworld\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\ttext: \"Test HelloWorld Module\"\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/helloworld/helloworld_default.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"helloworld\",\n\t\t\tposition: \"bottom_bar\"\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/newsfeed/default.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"newsfeed\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tfeeds: [\n\t\t\t\t\t{\n\t\t\t\t\t\ttitle: \"Rodrigo Ramirez Blog\",\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/newsfeed_test.xml\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/newsfeed/ignore_items.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"newsfeed\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tfeeds: [\n\t\t\t\t\t{\n\t\t\t\t\t\ttitle: \"Rodrigo Ramirez Blog\",\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/newsfeed_test.xml\"\n\t\t\t\t\t}\n\t\t\t\t],\n\t\t\t\tignoreOldItems: true\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/newsfeed/incorrect_url.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"newsfeed\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tfeeds: [\n\t\t\t\t\t{\n\t\t\t\t\t\ttitle: \"Incorrect Url\",\n\t\t\t\t\t\turl: \"this is not a valid url\"\n\t\t\t\t\t}\n\t\t\t\t]\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/newsfeed/prohibited_words.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"newsfeed\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tfeeds: [\n\t\t\t\t\t{\n\t\t\t\t\t\ttitle: \"Rodrigo Ramirez Blog\",\n\t\t\t\t\t\turl: \"http://localhost:8080/tests/mocks/newsfeed_test.xml\"\n\t\t\t\t\t}\n\t\t\t\t],\n\t\t\t\tprohibitedWords: [\"QPanel\"],\n\t\t\t\tshowDescription: true\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/positions.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tmodules:\n\t\t// Using exotic content. This is why don't accept go to JSON configuration file\n\t\t(() => {\n\t\t\tlet positions = [\"top_bar\", \"top_left\", \"top_center\", \"top_right\", \"upper_third\", \"middle_center\", \"lower_third\", \"bottom_left\", \"bottom_center\", \"bottom_right\", \"bottom_bar\", \"fullscreen_above\", \"fullscreen_below\"];\n\t\t\tlet modules = Array();\n\t\t\tfor (let idx in positions) {\n\t\t\t\tmodules.push({\n\t\t\t\t\tmodule: \"helloworld\",\n\t\t\t\t\tposition: positions[idx],\n\t\t\t\t\tconfig: {\n\t\t\t\t\t\ttext: `Text in ${positions[idx]}`\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t}\n\t\t\treturn modules;\n\t\t})()\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/weather/currentweather_compliments.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"compliments\",\n\t\t\tposition: \"top_bar\",\n\t\t\tconfig: {\n\t\t\t\tcompliments: {\n\t\t\t\t\tsnow: [\"snow\"]\n\t\t\t\t},\n\t\t\t\tupdateInterval: 3000\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\tmodule: \"weather\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tlocation: \"Munich\",\n\t\t\t\tweatherProvider: \"openweathermap\",\n\t\t\t\tweatherEndpoint: \"/weather\",\n\t\t\t\tmockData: '\"#####WEATHERDATA#####\"'\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/weather/currentweather_default.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"weather\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tlocation: \"Munich\",\n\t\t\t\tshowHumidity: \"feelslike\",\n\t\t\t\tweatherProvider: \"openweathermap\",\n\t\t\t\tweatherEndpoint: \"/weather\",\n\t\t\t\tmockData: '\"#####WEATHERDATA#####\"'\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/weather/currentweather_options.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"weather\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tlocation: \"Munich\",\n\t\t\t\tweatherProvider: \"openweathermap\",\n\t\t\t\tweatherEndpoint: \"/weather\",\n\t\t\t\tmockData: '\"#####WEATHERDATA#####\"',\n\t\t\t\twindUnits: \"beaufort\",\n\t\t\t\tshowWindDirectionAsArrow: true,\n\t\t\t\tshowSun: false,\n\t\t\t\tshowHumidity: \"wind\",\n\t\t\t\troundTemp: true,\n\t\t\t\tdegreeLabel: true\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/weather/currentweather_units.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tunits: \"imperial\",\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"weather\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\tlocation: \"Munich\",\n\t\t\t\tweatherProvider: \"openweathermap\",\n\t\t\t\tweatherEndpoint: \"/weather\",\n\t\t\t\tmockData: '\"#####WEATHERDATA#####\"',\n\t\t\t\tdecimalSymbol: \",\",\n\t\t\t\tshowHumidity: \"wind\"\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/weather/forecastweather_absolute.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"weather\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\ttype: \"forecast\",\n\t\t\t\tlocation: \"Munich\",\n\t\t\t\tweatherProvider: \"openweathermap\",\n\t\t\t\tweatherEndpoint: \"/forecast/daily\",\n\t\t\t\tmockData: '\"#####WEATHERDATA#####\"',\n\t\t\t\tabsoluteDates: true\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/weather/forecastweather_default.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"weather\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\ttype: \"forecast\",\n\t\t\t\tlocation: \"Munich\",\n\t\t\t\tweatherProvider: \"openweathermap\",\n\t\t\t\tweatherEndpoint: \"/forecast/daily\",\n\t\t\t\tmockData: '\"#####WEATHERDATA#####\"'\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/weather/forecastweather_options.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"weather\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\ttype: \"forecast\",\n\t\t\t\tlocation: \"Munich\",\n\t\t\t\tweatherProvider: \"openweathermap\",\n\t\t\t\tweatherEndpoint: \"/forecast/daily\",\n\t\t\t\tmockData: '\"#####WEATHERDATA#####\"',\n\t\t\t\tshowPrecipitationAmount: true,\n\t\t\t\tcolored: true,\n\t\t\t\ttableClass: \"myTableClass\"\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/weather/forecastweather_units.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\tunits: \"imperial\",\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"weather\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\ttype: \"forecast\",\n\t\t\t\tlocation: \"Munich\",\n\t\t\t\tweatherProvider: \"openweathermap\",\n\t\t\t\tweatherEndpoint: \"/forecast/daily\",\n\t\t\t\tmockData: '\"#####WEATHERDATA#####\"',\n\t\t\t\tdecimalSymbol: \"_\",\n\t\t\t\tshowPrecipitationAmount: true\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/weather/hourlyweather_default.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"weather\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\ttype: \"hourly\",\n\t\t\t\tlocation: \"Berlin\",\n\t\t\t\tweatherProvider: \"openweathermap\",\n\t\t\t\tweatherEndpoint: \"/onecall\",\n\t\t\t\tmockData: '\"#####WEATHERDATA#####\"'\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/weather/hourlyweather_options.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"weather\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\ttype: \"hourly\",\n\t\t\t\tlocation: \"Berlin\",\n\t\t\t\tweatherProvider: \"openweathermap\",\n\t\t\t\tweatherEndpoint: \"/onecall\",\n\t\t\t\tmockData: '\"#####WEATHERDATA#####\"',\n\t\t\t\thourlyForecastIncrements: 2\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/modules/weather/hourlyweather_showPrecipitation.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: [],\n\ttimeFormat: 12,\n\n\tmodules: [\n\t\t{\n\t\t\tmodule: \"weather\",\n\t\t\tposition: \"bottom_bar\",\n\t\t\tconfig: {\n\t\t\t\ttype: \"hourly\",\n\t\t\t\tlocation: \"Berlin\",\n\t\t\t\tweatherProvider: \"openweathermap\",\n\t\t\t\tweatherEndpoint: \"/onecall\",\n\t\t\t\tmockData: '\"#####WEATHERDATA#####\"',\n\t\t\t\tshowPrecipitationAmount: true,\n\t\t\t\tshowPrecipitationProbability: true\n\t\t\t}\n\t\t}\n\t]\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/noIpWhiteList.js",
    "content": "let config = require(`${process.cwd()}/tests/configs/default.js`).configFactory({\n\tipWhitelist: [\"x.x.x.x\"],\n\tport: 8181\n});\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/port_8090.js",
    "content": "let config = require(`${process.cwd()}/tests/configs/default.js`).configFactory({\n\tport: 8090\n});\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/port_variable.env",
    "content": "MM_PORT=8090\n"
  },
  {
    "path": "tests/configs/port_variable.js.template",
    "content": "let config = require(`${process.cwd()}/tests/configs/default.js`).configFactory({\n\tport: ${MM_PORT}\n});\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/configs/without_modules.js",
    "content": "let config = {\n\taddress: \"0.0.0.0\",\n\tipWhitelist: []\n};\n\n/*************** DO NOT EDIT THE LINE BELOW ***************/\nif (typeof module !== \"undefined\") {\n\tmodule.exports = config;\n}\n"
  },
  {
    "path": "tests/e2e/animateCSS_spec.js",
    "content": "const { expect } = require(\"playwright/test\");\nconst helpers = require(\"./helpers/global-setup\");\n\n// Validate Animate.css integration for compliments module using class toggling.\n// We intentionally ignore computed animation styles (jsdom doesn't simulate real animations).\ndescribe(\"AnimateCSS integration Test\", () => {\n\tlet page;\n\n\t// Config variants under test\n\tconst TEST_CONFIG_ANIM = \"tests/configs/modules/compliments/compliments_animateCSS.js\";\n\tconst TEST_CONFIG_FALLBACK = \"tests/configs/modules/compliments/compliments_animateCSS_fallbackToDefault.js\"; // invalid animation names\n\tconst TEST_CONFIG_INVERTED = \"tests/configs/modules/compliments/compliments_animateCSS_invertedAnimationName.js\"; // in/out swapped\n\tconst TEST_CONFIG_NONE = \"tests/configs/modules/compliments/compliments_anytime.js\"; // no animations defined\n\n\t/**\n\t * Get the compliments container element (waits until available).\n\t * @returns {Promise<void>}\n\t */\n\tasync function getComplimentsElement () {\n\t\tawait helpers.getDocument();\n\t\tpage = helpers.getPage();\n\t\tawait expect(page.locator(\".compliments\")).toBeVisible();\n\t}\n\n\t/**\n\t * Wait for an Animate.css class to appear and persist briefly.\n\t * @param {string} cls Animation class name without leading dot (e.g. animate__flipInX)\n\t * @param {{timeout?: number}} [options] Poll timeout in ms (default 6000)\n\t * @returns {Promise<void>}\n\t */\n\tasync function waitForAnimationClass (cls, { timeout = 6000 } = {}) {\n\t\tconst locator = page.locator(`.compliments.animate__animated.${cls}`);\n\t\tawait locator.waitFor({ state: \"attached\", timeout });\n\t\t// small stability wait\n\t\tawait new Promise((r) => setTimeout(r, 50));\n\t\tawait expect(locator).toBeAttached();\n\t}\n\n\t/**\n\t * Assert that no Animate.css animation class is applied within a time window.\n\t * @param {number} [ms] Observation period in ms (default 2000)\n\t * @returns {Promise<void>}\n\t */\n\tasync function assertNoAnimationWithin (ms = 2000) {\n\t\tconst start = Date.now();\n\t\tconst locator = page.locator(\".compliments.animate__animated\");\n\t\twhile (Date.now() - start < ms) {\n\t\t\tconst count = await locator.count();\n\t\t\tif (count > 0) {\n\t\t\t\tthrow new Error(\"Unexpected animate__animated class present in non-animation scenario\");\n\t\t\t}\n\t\t\tawait new Promise((r) => setTimeout(r, 100));\n\t\t}\n\t}\n\n\t/**\n\t * Run one animation test scenario.\n\t * @param {string} [animationIn] Expected animate-in name\n\t * @param {string} [animationOut] Expected animate-out name\n\t * @returns {Promise<void>} Throws on assertion failure\n\t */\n\tasync function runAnimationTest (animationIn, animationOut) {\n\t\tawait getComplimentsElement();\n\t\tif (!animationIn && !animationOut) {\n\t\t\tawait assertNoAnimationWithin(2000);\n\t\t\treturn;\n\t\t}\n\t\tif (animationIn) await waitForAnimationClass(`animate__${animationIn}`);\n\t\tif (animationOut) {\n\t\t\t// Wait just beyond one update cycle (updateInterval=2000ms) before expecting animateOut.\n\t\t\tawait new Promise((r) => setTimeout(r, 2100));\n\t\t\tawait waitForAnimationClass(`animate__${animationOut}`);\n\t\t}\n\t}\n\n\tafterEach(async () => {\n\t\tawait helpers.stopApplication();\n\t});\n\n\tdescribe(\"animateIn and animateOut Test\", () => {\n\t\tit(\"with flipInX and flipOutX animation\", async () => {\n\t\t\tawait helpers.startApplication(TEST_CONFIG_ANIM);\n\t\t\tawait runAnimationTest(\"flipInX\", \"flipOutX\");\n\t\t});\n\t});\n\n\tdescribe(\"use animateOut name for animateIn (vice versa) Test\", () => {\n\t\tit(\"without animation (inverted names)\", async () => {\n\t\t\tawait helpers.startApplication(TEST_CONFIG_INVERTED);\n\t\t\tawait runAnimationTest();\n\t\t});\n\t});\n\n\tdescribe(\"false Animation name test\", () => {\n\t\tit(\"without animation (invalid names)\", async () => {\n\t\t\tawait helpers.startApplication(TEST_CONFIG_FALLBACK);\n\t\t\tawait runAnimationTest();\n\t\t});\n\t});\n\n\tdescribe(\"no Animation defined test\", () => {\n\t\tit(\"without animation (no config)\", async () => {\n\t\t\tawait helpers.startApplication(TEST_CONFIG_NONE);\n\t\t\tawait runAnimationTest();\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/custom_module_regions_spec.js",
    "content": "const { expect } = require(\"playwright/test\");\nconst helpers = require(\"./helpers/global-setup\");\n\ndescribe(\"Custom Position of modules\", () => {\n\tlet page;\n\n\tbeforeAll(async () => {\n\t\tawait helpers.fixupIndex();\n\t\tawait helpers.startApplication(\"tests/configs/customregions.js\");\n\t\tawait helpers.getDocument();\n\t\tpage = helpers.getPage();\n\t});\n\tafterAll(async () => {\n\t\tawait helpers.stopApplication();\n\t\tawait helpers.restoreIndex();\n\t});\n\n\tconst positions = [\"row3_left\", \"top3_left1\"];\n\tlet i = 0;\n\tconst className1 = positions[i].replace(\"_\", \".\");\n\tlet message1 = positions[i];\n\tit(`should show text in ${message1}`, async () => {\n\t\tawait expect(page.locator(`.${className1} .module-content`)).toContainText(`Text in ${message1}`);\n\t});\n\ti = 1;\n\tconst className2 = positions[i].replace(\"_\", \".\");\n\tlet message2 = positions[i];\n\tit(`should NOT show text in ${message2}`, async () => {\n\t\tawait expect(page.locator(`.${className2} .module-content`)).toHaveCount(0);\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/env_spec.js",
    "content": "const { expect } = require(\"playwright/test\");\nconst helpers = require(\"./helpers/global-setup\");\n\ndescribe(\"App environment\", () => {\n\tlet page;\n\n\tbeforeAll(async () => {\n\t\tawait helpers.startApplication(\"tests/configs/default.js\");\n\t\tawait helpers.getDocument();\n\t\tpage = helpers.getPage();\n\t});\n\tafterAll(async () => {\n\t\tawait helpers.stopApplication();\n\t});\n\n\tit(\"get request from http://localhost:8080 should return 200\", async () => {\n\t\tconst res = await fetch(\"http://localhost:8080\");\n\t\texpect(res.status).toBe(200);\n\t});\n\n\tit(\"get request from http://localhost:8080/nothing should return 404\", async () => {\n\t\tconst res = await fetch(\"http://localhost:8080/nothing\");\n\t\texpect(res.status).toBe(404);\n\t});\n\n\tit(\"should show the title MagicMirror²\", async () => {\n\t\tawait expect(page).toHaveTitle(\"MagicMirror²\");\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/fonts_spec.js",
    "content": "const helpers = require(\"./helpers/global-setup\");\n\ndescribe(\"All font files from roboto.css should be downloadable\", () => {\n\tconst fontFiles = [];\n\t// Statements below filters out all 'url' lines in the CSS file\n\tconst fileContent = require(\"node:fs\").readFileSync(`${global.root_path}/css/roboto.css`, \"utf8\");\n\tconst regex = /\\burl\\(['\"]([^'\"]+)['\"]\\)/g;\n\tlet match = regex.exec(fileContent);\n\twhile (match !== null) {\n\t\t// Push 1st match group onto fontFiles stack\n\t\tfontFiles.push(match[1]);\n\t\t// Find the next one\n\t\tmatch = regex.exec(fileContent);\n\t}\n\n\tbeforeAll(async () => {\n\t\tawait helpers.startApplication(\"tests/configs/without_modules.js\");\n\t});\n\tafterAll(async () => {\n\t\tawait helpers.stopApplication();\n\t});\n\n\tit.each(fontFiles)(\"should return 200 HTTP code for file '%s'\", async (fontFile) => {\n\t\tconst fontUrl = `http://localhost:8080/fonts/${fontFile}`;\n\t\tconst res = await fetch(fontUrl);\n\t\texpect(res.status).toBe(200);\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/helpers/basic-auth.js",
    "content": "const path = require(\"node:path\");\nconst auth = require(\"express-basic-auth\");\nconst express = require(\"express\");\n\nconst app = express();\n\nconst basicAuth = auth({\n\trealm: \"MagicMirror² Area restricted.\",\n\tusers: { MagicMirror: \"CallMeADog\" }\n});\n\napp.use(basicAuth);\n\n// Set available directories\nconst directories = [\"/tests/configs\", \"/tests/mocks\"];\n\nfor (let directory of directories) {\n\tapp.use(directory, express.static(path.resolve(`${global.root_path}/${directory}`)));\n}\n\nlet server;\n\nexports.listen = (...args) => {\n\tserver = app.listen.apply(app, args);\n};\n\nexports.close = async () => {\n\tawait server.close();\n};\n"
  },
  {
    "path": "tests/e2e/helpers/global-setup.js",
    "content": "const path = require(\"node:path\");\nconst os = require(\"node:os\");\nconst fs = require(\"node:fs\");\nconst { chromium } = require(\"playwright\");\n\n// global absolute root path\nglobal.root_path = path.resolve(`${__dirname}/../../../`);\n\nconst indexFile = `${global.root_path}/index.html`;\nconst cssFile = `${global.root_path}/css/custom.css`;\nconst sampleCss = [\n\t\".region.row3 {\",\n\t\" top: 0;\",\n\t\"}\",\n\t\".region.row3.left {\",\n\t\" top: 100%;\",\n\t\"}\"\n];\nlet indexData = \"\";\nlet cssData = \"\";\n\nlet browser;\nlet context;\nlet page;\n\n/**\n * Ensure Playwright browser and context are available.\n * @returns {Promise<void>}\n */\nasync function ensureContext () {\n\tif (!browser) {\n\t\t// Additional args for CI stability to prevent crashes\n\t\tconst launchOptions = {\n\t\t\theadless: true,\n\t\t\targs: [\n\t\t\t\t\"--disable-dev-shm-usage\", // Overcome limited resource problems in Docker/CI\n\t\t\t\t\"--disable-gpu\", // Disable GPU hardware acceleration\n\t\t\t\t\"--no-sandbox\", // Required for running as root in some CI environments\n\t\t\t\t\"--disable-setuid-sandbox\",\n\t\t\t\t\"--single-process\" // Run in single process mode for better stability in CI\n\t\t\t]\n\t\t};\n\t\tbrowser = await chromium.launch(launchOptions);\n\t}\n\tif (!context) {\n\t\tcontext = await browser.newContext();\n\t}\n}\n\n/**\n * Open a fresh page pointing to the provided url.\n * @param {string} url target url\n * @returns {Promise<import('playwright').Page>} initialized page instance\n */\nasync function openPage (url) {\n\tawait ensureContext();\n\tif (page) {\n\t\tawait page.close();\n\t}\n\tpage = await context.newPage();\n\tawait page.goto(url, { waitUntil: \"load\" });\n\treturn page;\n}\n\n/**\n * Close page, context and browser if they exist.\n * @returns {Promise<void>}\n */\nasync function closeBrowser () {\n\tif (page) {\n\t\tawait page.close();\n\t\tpage = null;\n\t}\n\tif (context) {\n\t\tawait context.close();\n\t\tcontext = null;\n\t}\n\tif (browser) {\n\t\tawait browser.close();\n\t\tbrowser = null;\n\t}\n}\n\nexports.getPage = () => {\n\tif (!page) {\n\t\tthrow new Error(\"Playwright page is not initialized. Call getDocument() first.\");\n\t}\n\treturn page;\n};\n\n\nexports.startApplication = async (configFilename, exec) => {\n\tvi.resetModules();\n\n\t// Clear Node's require cache for config and app files to prevent stale configs and middlewares\n\tObject.keys(require.cache).forEach((key) => {\n\t\tif (\n\t\t\tkey.includes(\"/tests/configs/\")\n\t\t\t|| key.includes(\"/config/config\")\n\t\t\t|| key.includes(\"/js/app.js\")\n\t\t\t|| key.includes(\"/js/server.js\")\n\t\t) {\n\t\t\tdelete require.cache[key];\n\t\t}\n\t});\n\n\tif (global.app) {\n\t\tawait exports.stopApplication();\n\t}\n\n\t// Use fixed port 8080 (tests run sequentially, no conflicts)\n\tconst port = 8080;\n\tglobal.testPort = port;\n\n\t// Set config sample for use in test\n\tlet configPath;\n\tif (configFilename === \"\") {\n\t\tconfigPath = \"config/config.js\";\n\t} else {\n\t\tconfigPath = configFilename;\n\t}\n\n\tprocess.env.MM_CONFIG_FILE = configPath;\n\n\t// Override port in config - MUST be set before app loads\n\tprocess.env.MM_PORT = port.toString();\n\n\tprocess.env.mmTestMode = \"true\";\n\tprocess.setMaxListeners(0);\n\tif (exec) exec;\n\tglobal.app = require(`${global.root_path}/js/app`);\n\n\treturn global.app.start();\n};\n\nexports.stopApplication = async (waitTime = 100) => {\n\tawait closeBrowser();\n\n\tif (!global.app) {\n\t\tdelete global.testPort;\n\t\treturn Promise.resolve();\n\t}\n\n\tawait global.app.stop();\n\tdelete global.app;\n\tdelete global.testPort;\n\n\t// Wait for any pending async operations to complete before closing DOM\n\tawait new Promise((resolve) => setTimeout(resolve, waitTime));\n};\n\nexports.getDocument = async () => {\n\tconst port = global.testPort || config.port || 8080;\n\tconst address = config.address === \"0.0.0.0\" ? \"localhost\" : config.address || \"localhost\";\n\tconst url = `http://${address}:${port}`;\n\n\tawait openPage(url);\n};\n\nexports.fixupIndex = async () => {\n\t// read and save the git level index file\n\tindexData = (await fs.promises.readFile(indexFile)).toString();\n\t// make lines of the content\n\tconst workIndexLines = indexData.split(os.EOL);\n\t// loop thru the lines to find place to insert new region\n\tfor (let l in workIndexLines) {\n\t\tif (workIndexLines[l].includes(\"region top right\")) {\n\t\t\t// insert a new line with new region definition\n\t\t\tworkIndexLines.splice(l, 0, \"      <div class=\\\"region row3 left\\\"><div class=\\\"container\\\"></div></div>\");\n\t\t\tbreak;\n\t\t}\n\t}\n\t// write out the new index.html file, not append\n\tawait fs.promises.writeFile(indexFile, workIndexLines.join(os.EOL), { flush: true });\n\t// read in the current custom.css\n\tcssData = (await fs.promises.readFile(cssFile)).toString();\n\t// write out the custom.css for this testcase, matching the new region name\n\tawait fs.promises.writeFile(cssFile, sampleCss.join(os.EOL), { flush: true });\n};\n\nexports.restoreIndex = async () => {\n\t// if we read in data\n\tif (indexData.length > 0) {\n\t\t//write out saved index.html\n\t\tawait fs.promises.writeFile(indexFile, indexData, { flush: true });\n\t\t// write out saved custom.css\n\t\tawait fs.promises.writeFile(cssFile, cssData, { flush: true });\n\t}\n};\n"
  },
  {
    "path": "tests/e2e/helpers/weather-functions.js",
    "content": "const { injectMockData, cleanupMockData } = require(\"../../utils/weather_mocker\");\nconst helpers = require(\"./global-setup\");\n\nexports.startApplication = async (configFileName, additionalMockData) => {\n\tawait helpers.startApplication(injectMockData(configFileName, additionalMockData));\n\tawait helpers.getDocument();\n};\n\nexports.stopApplication = async () => {\n\tawait helpers.stopApplication();\n\tcleanupMockData();\n};\n"
  },
  {
    "path": "tests/e2e/ipWhitelist_spec.js",
    "content": "const helpers = require(\"./helpers/global-setup\");\n\ndescribe(\"ipWhitelist directive configuration\", () => {\n\tdescribe(\"When IP is not in whitelist\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/noIpWhiteList.js\");\n\t\t});\n\t\tafterAll(async () => {\n\t\t\tawait helpers.stopApplication();\n\t\t});\n\n\t\tit(\"should reject request with 403 (Forbidden)\", async () => {\n\t\t\tconst port = global.testPort || 8080;\n\t\t\tconst res = await fetch(`http://localhost:${port}`);\n\t\t\texpect(res.status).toBe(403);\n\t\t});\n\t});\n\n\tdescribe(\"When whitelist is empty (allow all IPs)\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/empty_ipWhiteList.js\");\n\t\t});\n\t\tafterAll(async () => {\n\t\t\tawait helpers.stopApplication();\n\t\t});\n\n\t\tit(\"should allow request with 200 (OK)\", async () => {\n\t\t\tconst port = global.testPort || 8080;\n\t\t\tconst res = await fetch(`http://localhost:${port}`);\n\t\t\texpect(res.status).toBe(200);\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/modules/alert_spec.js",
    "content": "const { expect } = require(\"playwright/test\");\nconst helpers = require(\"../helpers/global-setup\");\n\ndescribe(\"Alert module\", () => {\n\tlet page;\n\n\tafterAll(async () => {\n\t\tawait helpers.stopApplication();\n\t});\n\n\tdescribe(\"with welcome_message set to false\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/alert/welcome_false.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should not show any welcome message\", async () => {\n\t\t\t// Wait a bit to ensure no message appears\n\t\t\tawait new Promise((resolve) => setTimeout(resolve, 1000));\n\n\t\t\t// Check that no alert/notification elements are present\n\t\t\tawait expect(page.locator(\".ns-box .ns-box-inner .light.bright.small\")).toHaveCount(0);\n\t\t});\n\t});\n\n\tdescribe(\"with welcome_message set to true\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/alert/welcome_true.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\n\t\t\t// Wait for the application to initialize\n\t\t\tawait new Promise((resolve) => setTimeout(resolve, 1000));\n\t\t});\n\n\t\tit(\"should show the translated welcome message\", async () => {\n\t\t\tawait expect(page.locator(\".ns-box .ns-box-inner .light.bright.small\")).toContainText(\"Welcome, start was successful!\");\n\t\t});\n\t});\n\n\tdescribe(\"with welcome_message set to custom string\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/alert/welcome_string.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should show the custom welcome message\", async () => {\n\t\t\tawait expect(page.locator(\".ns-box .ns-box-inner .light.bright.small\")).toContainText(\"Custom welcome message!\");\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/modules/calendar_spec.js",
    "content": "const { expect } = require(\"playwright/test\");\nconst helpers = require(\"../helpers/global-setup\");\nconst serverBasicAuth = require(\"../helpers/basic-auth\");\n\ndescribe(\"Calendar module\", () => {\n\tlet page;\n\n\t/**\n\t * Assert the number of matching elements.\n\t * @param {string} selector css selector\n\t * @param {number} expectedLength expected number of elements\n\t * @param {string} [not] optional negation marker (use \"not\" to negate)\n\t * @returns {Promise<void>}\n\t */\n\tconst testElementLength = async (selector, expectedLength, not) => {\n\t\tconst locator = page.locator(selector);\n\t\tif (not === \"not\") {\n\t\t\tawait expect(locator).not.toHaveCount(expectedLength);\n\t\t} else {\n\t\t\tawait expect(locator).toHaveCount(expectedLength);\n\t\t}\n\t};\n\n\tconst testTextContain = async (selector, expectedText) => {\n\t\tawait expect(page.locator(selector).first()).toContainText(expectedText);\n\t};\n\n\tafterAll(async () => {\n\t\tawait helpers.stopApplication();\n\t});\n\n\tdescribe(\"Default configuration\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/default.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should show the default maximumEntries of 10\", async () => {\n\t\t\tawait testElementLength(\".calendar .event\", 10);\n\t\t});\n\n\t\tit(\"should show the default calendar symbol in each event\", async () => {\n\t\t\tawait testElementLength(\".calendar .event .fa-calendar-days\", 0, \"not\");\n\t\t});\n\t});\n\n\tdescribe(\"Custom configuration\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/custom.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should show the custom maximumEntries of 5\", async () => {\n\t\t\tawait testElementLength(\".calendar .event\", 5);\n\t\t});\n\n\t\tit(\"should show the custom calendar symbol in four events\", async () => {\n\t\t\tawait testElementLength(\".calendar .event .fa-birthday-cake\", 4);\n\t\t});\n\n\t\tit(\"should show a customEvent calendar symbol in one event\", async () => {\n\t\t\tawait testElementLength(\".calendar .event .fa-dice\", 1);\n\t\t});\n\n\t\tit(\"should show a customEvent calendar eventClass in one event\", async () => {\n\t\t\tawait testElementLength(\".calendar .event.undo\", 1);\n\t\t});\n\n\t\tit(\"should show two custom icons for repeating events\", async () => {\n\t\t\tawait testElementLength(\".calendar .event .fa-undo\", 2);\n\t\t});\n\n\t\tit(\"should show two custom icons for day events\", async () => {\n\t\t\tawait testElementLength(\".calendar .event .fa-calendar-day\", 2);\n\t\t});\n\t});\n\n\tdescribe(\"Recurring event\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/recurring.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should show the recurring birthday event 6 times\", async () => {\n\t\t\tawait testElementLength(\".calendar .event\", 6);\n\t\t});\n\t});\n\n\t//Will contain everyday an fullDayEvent that starts today and ends tomorrow, and one starting tomorrow and ending the day after tomorrow\n\tdescribe(\"FullDayEvent over several days should show how many days are left from the from the starting date on\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/long-fullday-event.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should contain text 'Ends in' with the left days\", async () => {\n\t\t\tawait testTextContain(\".calendar .today .time\", \"Ends in\");\n\t\t\tawait testTextContain(\".calendar .yesterday .time\", \"Today\");\n\t\t\tawait testTextContain(\".calendar .tomorrow .time\", \"Tomorrow\");\n\t\t});\n\t\tit(\"should contain in total three events\", async () => {\n\t\t\tawait testElementLength(\".calendar .event\", 3);\n\t\t});\n\t});\n\n\tdescribe(\"FullDayEvent Single day, should show Today\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/single-fullday-event.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should contain text 'Today'\", async () => {\n\t\t\tawait testTextContain(\".calendar .time\", \"Today\");\n\t\t});\n\t\tit(\"should contain in total two events\", async () => {\n\t\t\tawait testElementLength(\".calendar .event\", 2);\n\t\t});\n\t});\n\n\tdescribe(\"Changed port\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/changed-port.js\");\n\t\t\tserverBasicAuth.listen(8010);\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tafterAll(async () => {\n\t\t\tawait serverBasicAuth.close();\n\t\t});\n\n\t\tit(\"should return TestEvents\", async () => {\n\t\t\tawait testElementLength(\".calendar .event\", 0, \"not\");\n\t\t});\n\t});\n\n\tdescribe(\"Basic auth\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/basic-auth.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should return TestEvents\", async () => {\n\t\t\tawait testElementLength(\".calendar .event\", 0, \"not\");\n\t\t});\n\t});\n\n\tdescribe(\"Basic auth by default\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/auth-default.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should return TestEvents\", async () => {\n\t\t\tawait testElementLength(\".calendar .event\", 0, \"not\");\n\t\t});\n\t});\n\n\tdescribe(\"Basic auth backward compatibility configuration: DEPRECATED\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/old-basic-auth.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should return TestEvents\", async () => {\n\t\t\tawait testElementLength(\".calendar .event\", 0, \"not\");\n\t\t});\n\t});\n\n\tdescribe(\"Fail Basic auth\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/fail-basic-auth.js\");\n\t\t\tserverBasicAuth.listen(8020);\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tafterAll(async () => {\n\t\t\tawait serverBasicAuth.close();\n\t\t});\n\n\t\tit(\"should show Unauthorized error\", async () => {\n\t\t\tawait testTextContain(\".calendar\", \"Error in the calendar module. Authorization failed\");\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/modules/clock_de_spec.js",
    "content": "const { expect } = require(\"playwright/test\");\nconst helpers = require(\"../helpers/global-setup\");\n\ndescribe(\"Clock set to german language module\", () => {\n\tlet page;\n\n\tafterAll(async () => {\n\t\tawait helpers.stopApplication();\n\t});\n\n\tdescribe(\"with showWeek config enabled\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/clock/de/clock_showWeek.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"shows week with correct format\", async () => {\n\t\t\tconst weekRegex = /^[0-9]{1,2}. Kalenderwoche$/;\n\t\t\tawait expect(page.locator(\".clock .week\")).toHaveText(weekRegex);\n\t\t});\n\t});\n\n\tdescribe(\"with showWeek short config enabled\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/clock/de/clock_showWeek_short.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"shows week with correct format\", async () => {\n\t\t\tconst weekRegex = /^[0-9]{1,2}KW$/;\n\t\t\tawait expect(page.locator(\".clock .week\")).toHaveText(weekRegex);\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/modules/clock_es_spec.js",
    "content": "const { expect } = require(\"playwright/test\");\nconst helpers = require(\"../helpers/global-setup\");\n\ndescribe(\"Clock set to spanish language module\", () => {\n\tlet page;\n\n\tafterAll(async () => {\n\t\tawait helpers.stopApplication();\n\t});\n\n\tdescribe(\"with default 24hr clock config\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/clock/es/clock_24hr.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"shows date with correct format\", async () => {\n\t\t\tconst dateRegex = /^(?:lunes|martes|miércoles|jueves|viernes|sábado|domingo), \\d{1,2} de (?:enero|febrero|marzo|abril|mayo|junio|julio|agosto|septiembre|octubre|noviembre|diciembre) de \\d{4}$/;\n\t\t\tawait expect(page.locator(\".clock .date\")).toHaveText(dateRegex);\n\t\t});\n\n\t\tit(\"shows time in 24hr format\", async () => {\n\t\t\tconst timeRegex = /^(?:2[0-3]|[01]\\d):[0-5]\\d[0-5]\\d$/;\n\t\t\tawait expect(page.locator(\".clock .time\")).toHaveText(timeRegex);\n\t\t});\n\t});\n\n\tdescribe(\"with default 12hr clock config\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/clock/es/clock_12hr.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"shows date with correct format\", async () => {\n\t\t\tconst dateRegex = /^(?:lunes|martes|miércoles|jueves|viernes|sábado|domingo), \\d{1,2} de (?:enero|febrero|marzo|abril|mayo|junio|julio|agosto|septiembre|octubre|noviembre|diciembre) de \\d{4}$/;\n\t\t\tawait expect(page.locator(\".clock .date\")).toHaveText(dateRegex);\n\t\t});\n\n\t\tit(\"shows time in 12hr format\", async () => {\n\t\t\tconst timeRegex = /^(?:1[0-2]|[1-9]):[0-5]\\d[0-5]\\d[ap]m$/;\n\t\t\tawait expect(page.locator(\".clock .time\")).toHaveText(timeRegex);\n\t\t});\n\t});\n\n\tdescribe(\"with showPeriodUpper config enabled\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/clock/es/clock_showPeriodUpper.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"shows 12hr time with upper case AM/PM\", async () => {\n\t\t\tconst timeRegex = /^(?:1[0-2]|[1-9]):[0-5]\\d[0-5]\\d[AP]M$/;\n\t\t\tawait expect(page.locator(\".clock .time\")).toHaveText(timeRegex);\n\t\t});\n\t});\n\n\tdescribe(\"with showWeek config enabled\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/clock/es/clock_showWeek.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"shows week with correct format\", async () => {\n\t\t\tconst weekRegex = /^Semana [0-9]{1,2}$/;\n\t\t\tawait expect(page.locator(\".clock .week\")).toHaveText(weekRegex);\n\t\t});\n\t});\n\n\tdescribe(\"with showWeek short config enabled\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/clock/es/clock_showWeek_short.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"shows week with correct format\", async () => {\n\t\t\tconst weekRegex = /^S[0-9]{1,2}$/;\n\t\t\tawait expect(page.locator(\".clock .week\")).toHaveText(weekRegex);\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/modules/clock_spec.js",
    "content": "const { expect } = require(\"playwright/test\");\nconst moment = require(\"moment\");\nconst helpers = require(\"../helpers/global-setup\");\n\ndescribe(\"Clock module\", () => {\n\tlet page;\n\n\tafterAll(async () => {\n\t\tawait helpers.stopApplication();\n\t});\n\n\tdescribe(\"with default 24hr clock config\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/clock/clock_24hr.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should show the date in the correct format\", async () => {\n\t\t\tconst dateRegex = /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (?:January|February|March|April|May|June|July|August|September|October|November|December) \\d{1,2}, \\d{4}$/;\n\t\t\tawait expect(page.locator(\".clock .date\")).toHaveText(dateRegex);\n\t\t});\n\n\t\tit(\"should show the time in 24hr format\", async () => {\n\t\t\tconst timeRegex = /^(?:2[0-3]|[01]\\d):[0-5]\\d[0-5]\\d$/;\n\t\t\tawait expect(page.locator(\".clock .time\")).toHaveText(timeRegex);\n\t\t});\n\t});\n\n\tdescribe(\"with default 12hr clock config\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/clock/clock_12hr.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should show the date in the correct format\", async () => {\n\t\t\tconst dateRegex = /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (?:January|February|March|April|May|June|July|August|September|October|November|December) \\d{1,2}, \\d{4}$/;\n\t\t\tawait expect(page.locator(\".clock .date\")).toHaveText(dateRegex);\n\t\t});\n\n\t\tit(\"should show the time in 12hr format\", async () => {\n\t\t\tconst timeRegex = /^(?:1[0-2]|[1-9]):[0-5]\\d[0-5]\\d[ap]m$/;\n\t\t\tawait expect(page.locator(\".clock .time\")).toHaveText(timeRegex);\n\t\t});\n\n\t\tit(\"check for discreet elements of clock\", async () => {\n\t\t\tawait expect(page.locator(\".clock-hour-digital\")).toBeVisible();\n\t\t\tawait expect(page.locator(\".clock-minute-digital\")).toBeVisible();\n\t\t});\n\t});\n\n\tdescribe(\"with showPeriodUpper config enabled\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/clock/clock_showPeriodUpper.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should show 12hr time with upper case AM/PM\", async () => {\n\t\t\tconst timeRegex = /^(?:1[0-2]|[1-9]):[0-5]\\d[0-5]\\d[AP]M$/;\n\t\t\tawait expect(page.locator(\".clock .time\")).toHaveText(timeRegex);\n\t\t});\n\t});\n\n\tdescribe(\"with displaySeconds config disabled\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/clock/clock_displaySeconds_false.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should show 12hr time without seconds am/pm\", async () => {\n\t\t\tconst timeRegex = /^(?:1[0-2]|[1-9]):[0-5]\\d[ap]m$/;\n\t\t\tawait expect(page.locator(\".clock .time\")).toHaveText(timeRegex);\n\t\t});\n\t});\n\n\tdescribe(\"with showTime config disabled\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/clock/clock_showTime.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should not show the time when digital clock is shown\", async () => {\n\t\t\tawait expect(page.locator(\".clock .digital .time\")).toHaveCount(0);\n\t\t});\n\t});\n\n\tdescribe(\"with showSun/MoonTime enabled\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/clock/clock_showSunMoon.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should show the sun times\", async () => {\n\t\t\tawait expect(page.locator(\".clock .digital .sun\")).toBeVisible();\n\t\t\tawait expect(page.locator(\".clock .digital .sun .fas.fa-sun\")).toBeVisible();\n\t\t});\n\n\t\tit(\"should show the moon times\", async () => {\n\t\t\tawait expect(page.locator(\".clock .digital .moon\")).toBeVisible();\n\t\t});\n\t});\n\n\tdescribe(\"with showSunNextEvent disabled\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/clock/clock_showSunNoEvent.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should show the sun times\", async () => {\n\t\t\tawait expect(page.locator(\".clock .digital .sun\")).toBeVisible();\n\t\t\tawait expect(page.locator(\".clock .digital .sun .fas.fa-sun\")).toHaveCount(0);\n\t\t});\n\t});\n\n\tdescribe(\"with showWeek config enabled\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/clock/clock_showWeek.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should show the week in the correct format\", async () => {\n\t\t\tconst weekRegex = /^Week [0-9]{1,2}$/;\n\t\t\tawait expect(page.locator(\".clock .week\")).toHaveText(weekRegex);\n\t\t});\n\n\t\tit(\"should show the week with the correct number of week of year\", async () => {\n\t\t\tconst currentWeekNumber = moment().week();\n\t\t\tconst weekToShow = `Week ${currentWeekNumber}`;\n\t\t\tawait expect(page.locator(\".clock .week\")).toHaveText(weekToShow);\n\t\t});\n\t});\n\n\tdescribe(\"with showWeek short config enabled\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/clock/clock_showWeek_short.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should show the week in the correct format\", async () => {\n\t\t\tconst weekRegex = /^W[0-9]{1,2}$/;\n\t\t\tawait expect(page.locator(\".clock .week\")).toHaveText(weekRegex);\n\t\t});\n\n\t\tit(\"should show the week with the correct number of week of year\", async () => {\n\t\t\tconst currentWeekNumber = moment().week();\n\t\t\tconst weekToShow = `W${currentWeekNumber}`;\n\t\t\tawait expect(page.locator(\".clock .week\")).toHaveText(weekToShow);\n\t\t});\n\t});\n\n\tdescribe(\"with analog clock face enabled\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/clock/clock_analog.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should show the analog clock face\", async () => {\n\t\t\tawait expect(page.locator(\".clock-circle\")).toBeVisible();\n\t\t});\n\t});\n\n\tdescribe(\"with analog clock face and date enabled\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/clock/clock_showDateAnalog.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should show the analog clock face and the date\", async () => {\n\t\t\tawait expect(page.locator(\".clock-circle\")).toBeVisible();\n\t\t\tawait expect(page.locator(\".clock .date\")).toBeVisible();\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/modules/compliments_spec.js",
    "content": "const { expect } = require(\"playwright/test\");\nconst helpers = require(\"../helpers/global-setup\");\n\ndescribe(\"Compliments module\", () => {\n\tlet page;\n\n\t/**\n\t * move similar tests in function doTest\n\t * @param {Array} complimentsArray The array of compliments.\n\t * @returns {Promise<void>}\n\t */\n\tconst doTest = async (complimentsArray) => {\n\t\tawait expect(page.locator(\".compliments\")).toBeVisible();\n\t\tconst contentLocator = page.locator(\".module-content\");\n\t\tawait contentLocator.waitFor({ state: \"visible\" });\n\t\tconst content = await contentLocator.textContent();\n\t\texpect(complimentsArray).toContain(content);\n\t};\n\n\tafterAll(async () => {\n\t\tawait helpers.stopApplication();\n\t});\n\n\tdescribe(\"Feature anytime in compliments module\", () => {\n\t\tdescribe(\"Set anytime and empty compliments for morning, evening and afternoon\", () => {\n\t\t\tbeforeAll(async () => {\n\t\t\t\tawait helpers.startApplication(\"tests/configs/modules/compliments/compliments_anytime.js\");\n\t\t\t\tawait helpers.getDocument();\n\t\t\t\tpage = helpers.getPage();\n\t\t\t});\n\n\t\t\tit(\"shows anytime because if configure empty parts of day compliments and set anytime compliments\", async () => {\n\t\t\t\tawait doTest([\"Anytime here\"]);\n\t\t\t});\n\t\t});\n\n\t\tdescribe(\"Only anytime present in configuration compliments\", () => {\n\t\t\tbeforeAll(async () => {\n\t\t\t\tawait helpers.startApplication(\"tests/configs/modules/compliments/compliments_only_anytime.js\");\n\t\t\t\tawait helpers.getDocument();\n\t\t\t\tpage = helpers.getPage();\n\t\t\t});\n\n\t\t\tit(\"shows anytime compliments\", async () => {\n\t\t\t\tawait doTest([\"Anytime here\"]);\n\t\t\t});\n\t\t});\n\t});\n\n\tdescribe(\"remoteFile option\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/compliments/compliments_remote.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should show compliments from a remote file\", async () => {\n\t\t\tawait doTest([\"Remote compliment file works!\"]);\n\t\t});\n\t});\n\n\tdescribe(\"Feature specialDayUnique in compliments module\", () => {\n\t\tdescribe(\"specialDayUnique is false\", () => {\n\t\t\tbeforeAll(async () => {\n\t\t\t\tawait helpers.startApplication(\"tests/configs/modules/compliments/compliments_specialDayUnique_false.js\");\n\t\t\t\tawait helpers.getDocument();\n\t\t\t\tpage = helpers.getPage();\n\t\t\t});\n\n\t\t\tit(\"compliments array can contain all values\", async () => {\n\t\t\t\tawait doTest([\"Special day message\", \"Typical message 1\", \"Typical message 2\", \"Typical message 3\"]);\n\t\t\t});\n\t\t});\n\n\t\tdescribe(\"specialDayUnique is true\", () => {\n\t\t\tbeforeAll(async () => {\n\t\t\t\tawait helpers.startApplication(\"tests/configs/modules/compliments/compliments_specialDayUnique_true.js\");\n\t\t\t\tawait helpers.getDocument();\n\t\t\t\tpage = helpers.getPage();\n\t\t\t});\n\n\t\t\tit(\"compliments array contains only special value\", async () => {\n\t\t\t\tawait doTest([\"Special day message\"]);\n\t\t\t});\n\t\t});\n\n\t\tdescribe(\"cron type key\", () => {\n\t\t\tbeforeAll(async () => {\n\t\t\t\tawait helpers.startApplication(\"tests/configs/modules/compliments/compliments_e2e_cron_entry.js\");\n\t\t\t\tawait helpers.getDocument();\n\t\t\t\tpage = helpers.getPage();\n\t\t\t});\n\n\t\t\tit(\"compliments array contains only special value\", async () => {\n\t\t\t\tawait doTest([\"anytime cron\"]);\n\t\t\t});\n\t\t});\n\t});\n\n\tdescribe(\"Feature remote compliments file\", () => {\n\t\tdescribe(\"get list from remote file\", () => {\n\t\t\tbeforeAll(async () => {\n\t\t\t\tawait helpers.startApplication(\"tests/configs/modules/compliments/compliments_file.js\");\n\t\t\t\tawait helpers.getDocument();\n\t\t\t\tpage = helpers.getPage();\n\t\t\t});\n\t\t\tit(\"shows 'Remote compliment file works!' as only anytime list set\", async () => {\n\t\t\t\t//await helpers.startApplication(\"tests/configs/modules/compliments/compliments_file.js\", \"01 Jan 2022 10:00:00 GMT\");\n\t\t\t\tawait doTest([\"Remote compliment file works!\"]);\n\t\t\t});\n\t\t\t//\t\t\tafterAll(async () =>{\n\t\t\t//\t\t\t\tawait helpers.stopApplication()\n\t\t\t//\t\t\t});\n\t\t});\n\n\t\tdescribe(\"get list from remote file w update\", () => {\n\t\t\tbeforeAll(async () => {\n\t\t\t\tawait helpers.startApplication(\"tests/configs/modules/compliments/compliments_file_change.js\");\n\t\t\t\tawait helpers.getDocument();\n\t\t\t\tpage = helpers.getPage();\n\t\t\t});\n\t\t\tit(\"shows 'test in morning' as test time set to 10am\", async () => {\n\t\t\t\t//await helpers.startApplication(\"tests/configs/modules/compliments/compliments_file_change.js\", \"01 Jan 2022 10:00:00 GMT\");\n\t\t\t\tawait doTest([\"Remote compliment file works!\"]);\n\t\t\t\tawait new Promise((r) => setTimeout(r, 10000));\n\t\t\t\tawait doTest([\"test in morning\"]);\n\t\t\t});\n\t\t\t//\t\t\tafterAll(async () =>{\n\t\t\t//\t\t\t\tawait helpers.stopApplication()\n\t\t\t//\t\t\t});\n\t\t});\n\t});\n\n});\n"
  },
  {
    "path": "tests/e2e/modules/helloworld_spec.js",
    "content": "const { expect } = require(\"playwright/test\");\nconst helpers = require(\"../helpers/global-setup\");\n\ndescribe(\"Test helloworld module\", () => {\n\tlet page;\n\n\tafterAll(async () => {\n\t\tawait helpers.stopApplication();\n\t});\n\n\tdescribe(\"helloworld set config text\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/helloworld/helloworld.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"Test message helloworld module\", async () => {\n\t\t\tawait expect(page.locator(\".helloworld\")).toContainText(\"Test HelloWorld Module\");\n\t\t});\n\t});\n\n\tdescribe(\"helloworld default config text\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/helloworld/helloworld_default.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"Test message helloworld module\", async () => {\n\t\t\tawait expect(page.locator(\".helloworld\")).toContainText(\"Hello World!\");\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/modules/newsfeed_spec.js",
    "content": "const fs = require(\"node:fs\");\nconst { expect } = require(\"playwright/test\");\nconst helpers = require(\"../helpers/global-setup\");\n\nconst runTests = async () => {\n\tlet page;\n\n\tdescribe(\"Default configuration\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/newsfeed/default.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should show the newsfeed title\", async () => {\n\t\t\tawait expect(page.locator(\".newsfeed .newsfeed-source\")).toContainText(\"Rodrigo Ramirez Blog\");\n\t\t});\n\n\t\tit(\"should show the newsfeed article\", async () => {\n\t\t\tawait expect(page.locator(\".newsfeed .newsfeed-title\")).toContainText(\"QPanel\");\n\t\t});\n\n\t\tit(\"should NOT show the newsfeed description\", async () => {\n\t\t\tawait page.locator(\".newsfeed\").waitFor({ state: \"visible\" });\n\t\t\tawait expect(page.locator(\".newsfeed .newsfeed-desc\")).toHaveCount(0);\n\t\t});\n\t});\n\n\tdescribe(\"Custom configuration\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/newsfeed/prohibited_words.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should not show articles with prohibited words\", async () => {\n\t\t\tawait expect(page.locator(\".newsfeed .newsfeed-title\")).toContainText(\"Problema VirtualBox\");\n\t\t});\n\n\t\tit(\"should show the newsfeed description\", async () => {\n\t\t\tconst locator = page.locator(\".newsfeed .newsfeed-desc\");\n\t\t\tawait expect(locator).toBeVisible();\n\t\t\tconst text = await locator.textContent();\n\t\t\texpect(text).toMatch(/\\S/);\n\t\t});\n\t});\n\n\tdescribe(\"Invalid configuration\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/newsfeed/incorrect_url.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should show malformed url warning\", async () => {\n\t\t\tawait expect(page.locator(\".newsfeed .small\")).toContainText(\"Error in the Newsfeed module. Malformed url.\");\n\t\t});\n\t});\n\n\tdescribe(\"Ignore items\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/newsfeed/ignore_items.js\");\n\t\t\tawait helpers.getDocument();\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should show empty items info message\", async () => {\n\t\t\tawait expect(page.locator(\".newsfeed .small\")).toContainText(\"No news at the moment.\");\n\t\t});\n\t});\n};\n\ndescribe(\"Newsfeed module\", () => {\n\tafterAll(async () => {\n\t\tawait helpers.stopApplication();\n\t});\n\n\trunTests();\n});\n\ndescribe(\"Newsfeed module located in config directory\", () => {\n\tbeforeAll(() => {\n\t\tfs.cpSync(`${global.root_path}/modules/default/newsfeed`, `${global.root_path}/config/newsfeed`, { recursive: true });\n\t\tprocess.env.MM_MODULES_DIR = \"config\";\n\t});\n\n\tafterAll(async () => {\n\t\tawait helpers.stopApplication();\n\t});\n\n\trunTests();\n});\n"
  },
  {
    "path": "tests/e2e/modules/weather_current_spec.js",
    "content": "const { expect } = require(\"playwright/test\");\nconst helpers = require(\"../helpers/global-setup\");\nconst weatherFunc = require(\"../helpers/weather-functions\");\n\ndescribe(\"Weather module\", () => {\n\tlet page;\n\n\tafterAll(async () => {\n\t\tawait weatherFunc.stopApplication();\n\t});\n\n\tdescribe(\"Current weather\", () => {\n\t\tdescribe(\"Default configuration\", () => {\n\t\t\tbeforeAll(async () => {\n\t\t\t\tawait weatherFunc.startApplication(\"tests/configs/modules/weather/currentweather_default.js\", {});\n\t\t\t\tpage = helpers.getPage();\n\t\t\t});\n\n\t\t\tit(\"should render wind speed and wind direction\", async () => {\n\t\t\t\tawait expect(page.locator(\".weather .normal.medium span:nth-child(2)\")).toHaveText(\"12 WSW\");\n\t\t\t});\n\n\t\t\tit(\"should render temperature with icon\", async () => {\n\t\t\t\tawait expect(page.locator(\".weather .large span.light.bright\")).toHaveText(\"1.5°\");\n\t\t\t\tawait expect(page.locator(\".weather .large span.weathericon\")).toBeVisible();\n\t\t\t});\n\n\t\t\tit(\"should render feels like temperature\", async () => {\n\t\t\t\t// Template contains &nbsp; which renders as \\xa0\n\t\t\t\tawait expect(page.locator(\".weather .normal.medium.feelslike span.dimmed\")).toHaveText(\"93.7\\xa0 Feels like -5.6°\");\n\t\t\t});\n\n\t\t\tit(\"should render humidity next to feels-like\", async () => {\n\t\t\t\tawait expect(page.locator(\".weather .normal.medium.feelslike span.dimmed .humidity\")).toHaveText(\"93.7\");\n\t\t\t});\n\t\t});\n\t});\n\n\tdescribe(\"Compliments Integration\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait weatherFunc.startApplication(\"tests/configs/modules/weather/currentweather_compliments.js\", {});\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should render a compliment based on the current weather\", async () => {\n\t\t\tconst compliment = page.locator(\".compliments .module-content span\");\n\t\t\tawait compliment.waitFor({ state: \"visible\" });\n\t\t\tawait expect(compliment).toHaveText(\"snow\");\n\t\t});\n\t});\n\n\tdescribe(\"Configuration Options\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait weatherFunc.startApplication(\"tests/configs/modules/weather/currentweather_options.js\", {});\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should render windUnits in beaufort\", async () => {\n\t\t\tawait expect(page.locator(\".weather .normal.medium span:nth-child(2)\")).toHaveText(\"6\");\n\t\t});\n\n\t\tit(\"should render windDirection with an arrow\", async () => {\n\t\t\tconst arrow = page.locator(\".weather .normal.medium sup i.fa-long-arrow-alt-down\");\n\t\t\tawait expect(arrow).toHaveAttribute(\"style\", \"transform:rotate(250deg)\");\n\t\t});\n\n\t\tit(\"should render humidity next to wind\", async () => {\n\t\t\tawait expect(page.locator(\".weather .normal.medium .humidity\")).toHaveText(\"93.7\");\n\t\t});\n\n\t\tit(\"should render degreeLabel for temp\", async () => {\n\t\t\tawait expect(page.locator(\".weather .large span.bright.light\")).toHaveText(\"1°C\");\n\t\t});\n\n\t\tit(\"should render degreeLabel for feels like\", async () => {\n\t\t\tawait expect(page.locator(\".weather .normal.medium.feelslike span.dimmed\")).toHaveText(\"Feels like -6°C\");\n\t\t});\n\t});\n\n\tdescribe(\"Current weather with imperial units\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait weatherFunc.startApplication(\"tests/configs/modules/weather/currentweather_units.js\", {});\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should render wind in imperial units\", async () => {\n\t\t\tawait expect(page.locator(\".weather .normal.medium span:nth-child(2)\")).toHaveText(\"26 WSW\");\n\t\t});\n\n\t\tit(\"should render temperatures in fahrenheit\", async () => {\n\t\t\tawait expect(page.locator(\".weather .large span.bright.light\")).toHaveText(\"34,7°\");\n\t\t});\n\n\t\tit(\"should render 'feels like' in fahrenheit\", async () => {\n\t\t\tawait expect(page.locator(\".weather .normal.medium.feelslike span.dimmed\")).toHaveText(\"Feels like 21,9°\");\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/modules/weather_forecast_spec.js",
    "content": "const { expect } = require(\"playwright/test\");\nconst helpers = require(\"../helpers/global-setup\");\nconst weatherFunc = require(\"../helpers/weather-functions\");\n\ndescribe(\"Weather module: Weather Forecast\", () => {\n\tlet page;\n\n\tafterAll(async () => {\n\t\tawait weatherFunc.stopApplication();\n\t});\n\n\tdescribe(\"Default configuration\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait weatherFunc.startApplication(\"tests/configs/modules/weather/forecastweather_default.js\", {});\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tconst days = [\"Today\", \"Tomorrow\", \"Sun\", \"Mon\", \"Tue\"];\n\t\tfor (const [index, day] of days.entries()) {\n\t\t\tit(`should render day ${day}`, async () => {\n\t\t\t\tconst dayCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(1)`);\n\t\t\t\tawait expect(dayCell).toHaveText(day);\n\t\t\t});\n\t\t}\n\n\t\tconst icons = [\"day-cloudy\", \"rain\", \"day-sunny\", \"day-sunny\", \"day-sunny\"];\n\t\tfor (const [index, icon] of icons.entries()) {\n\t\t\tit(`should render icon ${icon}`, async () => {\n\t\t\t\tconst iconElement = page.locator(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(2) span.wi-${icon}`);\n\t\t\t\tawait expect(iconElement).toBeVisible();\n\t\t\t});\n\t\t}\n\n\t\tconst maxTemps = [\"24.4°\", \"21.0°\", \"22.9°\", \"23.4°\", \"20.6°\"];\n\t\tfor (const [index, temp] of maxTemps.entries()) {\n\t\t\tit(`should render max temperature ${temp}`, async () => {\n\t\t\t\tconst maxTempCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(3)`);\n\t\t\t\tawait expect(maxTempCell).toHaveText(temp);\n\t\t\t});\n\t\t}\n\n\t\tconst minTemps = [\"15.3°\", \"13.6°\", \"13.8°\", \"13.9°\", \"10.9°\"];\n\t\tfor (const [index, temp] of minTemps.entries()) {\n\t\t\tit(`should render min temperature ${temp}`, async () => {\n\t\t\t\tconst minTempCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(4)`);\n\t\t\t\tawait expect(minTempCell).toHaveText(temp);\n\t\t\t});\n\t\t}\n\n\t\tconst opacities = [1, 1, 0.8, 0.5333333333333333, 0.2666666666666667];\n\t\tfor (const [index, opacity] of opacities.entries()) {\n\t\t\tit(`should render fading of rows with opacity=${opacity}`, async () => {\n\t\t\t\tconst row = page.locator(`.weather table.small tr:nth-child(${index + 1})`);\n\t\t\t\tawait expect(row).toHaveAttribute(\"style\", `opacity: ${opacity};`);\n\t\t\t});\n\t\t}\n\t});\n\n\tdescribe(\"Absolute configuration\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait weatherFunc.startApplication(\"tests/configs/modules/weather/forecastweather_absolute.js\", {});\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tconst days = [\"Fri\", \"Sat\", \"Sun\", \"Mon\", \"Tue\"];\n\t\tfor (const [index, day] of days.entries()) {\n\t\t\tit(`should render day ${day}`, async () => {\n\t\t\t\tconst dayCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(1)`);\n\t\t\t\tawait expect(dayCell).toHaveText(day);\n\t\t\t});\n\t\t}\n\t});\n\n\tdescribe(\"Configuration Options\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait weatherFunc.startApplication(\"tests/configs/modules/weather/forecastweather_options.js\", {});\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tit(\"should render custom table class\", async () => {\n\t\t\tawait expect(page.locator(\".weather table.myTableClass\")).toBeVisible();\n\t\t});\n\n\t\tit(\"should render colored rows\", async () => {\n\t\t\tconst rows = page.locator(\".weather table.myTableClass tr\");\n\t\t\tawait expect(rows).toHaveCount(5);\n\t\t});\n\n\t\tconst precipitations = [undefined, \"2.51 mm\"];\n\t\tfor (const [index, precipitation] of precipitations.entries()) {\n\t\t\tif (precipitation) {\n\t\t\t\tit(`should render precipitation amount ${precipitation}`, async () => {\n\t\t\t\t\tconst precipCell = page.locator(`.weather table tr:nth-child(${index + 1}) td.precipitation-amount`);\n\t\t\t\t\tawait expect(precipCell).toHaveText(precipitation);\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t});\n\n\tdescribe(\"Forecast weather with imperial units\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait weatherFunc.startApplication(\"tests/configs/modules/weather/forecastweather_units.js\", {});\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tdescribe(\"Temperature units\", () => {\n\t\t\tconst temperatures = [\"75_9°\", \"69_8°\", \"73_2°\", \"74_1°\", \"69_1°\"];\n\t\t\tfor (const [index, temp] of temperatures.entries()) {\n\t\t\t\tit(`should render custom decimalSymbol = '_' for temp ${temp}`, async () => {\n\t\t\t\t\tconst tempCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(3)`);\n\t\t\t\t\tawait expect(tempCell).toHaveText(temp);\n\t\t\t\t});\n\t\t\t}\n\t\t});\n\n\t\tdescribe(\"Precipitation units\", () => {\n\t\t\tconst precipitations = [undefined, \"0.10 in\"];\n\t\t\tfor (const [index, precipitation] of precipitations.entries()) {\n\t\t\t\tif (precipitation) {\n\t\t\t\t\tit(`should render precipitation amount ${precipitation}`, async () => {\n\t\t\t\t\t\tconst precipCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td.precipitation-amount`);\n\t\t\t\t\t\tawait expect(precipCell).toHaveText(precipitation);\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/modules/weather_hourly_spec.js",
    "content": "const { expect } = require(\"playwright/test\");\nconst helpers = require(\"../helpers/global-setup\");\nconst weatherFunc = require(\"../helpers/weather-functions\");\n\ndescribe(\"Weather module: Weather Hourly Forecast\", () => {\n\tlet page;\n\n\tafterAll(async () => {\n\t\tawait weatherFunc.stopApplication();\n\t});\n\n\tdescribe(\"Default configuration\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait weatherFunc.startApplication(\"tests/configs/modules/weather/hourlyweather_default.js\", {});\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tconst minTemps = [\"7:00 pm\", \"8:00 pm\", \"9:00 pm\", \"10:00 pm\", \"11:00 pm\"];\n\t\tfor (const [index, hour] of minTemps.entries()) {\n\t\t\tit(`should render forecast for hour ${hour}`, async () => {\n\t\t\t\tconst dayCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td.day`);\n\t\t\t\tawait expect(dayCell).toHaveText(hour);\n\t\t\t});\n\t\t}\n\t});\n\n\tdescribe(\"Hourly weather options\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait weatherFunc.startApplication(\"tests/configs/modules/weather/hourlyweather_options.js\", {});\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tdescribe(\"Hourly increments of 2\", () => {\n\t\t\tconst minTemps = [\"7:00 pm\", \"9:00 pm\", \"11:00 pm\", \"1:00 am\", \"3:00 am\"];\n\t\t\tfor (const [index, hour] of minTemps.entries()) {\n\t\t\t\tit(`should render forecast for hour ${hour}`, async () => {\n\t\t\t\t\tconst dayCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td.day`);\n\t\t\t\t\tawait expect(dayCell).toHaveText(hour);\n\t\t\t\t});\n\t\t\t}\n\t\t});\n\t});\n\n\tdescribe(\"Show precipitations\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait weatherFunc.startApplication(\"tests/configs/modules/weather/hourlyweather_showPrecipitation.js\", {});\n\t\t\tpage = helpers.getPage();\n\t\t});\n\n\t\tdescribe(\"Shows precipitation amount\", () => {\n\t\t\tconst amounts = [undefined, undefined, undefined, \"0.13 mm\", \"0.13 mm\"];\n\t\t\tfor (const [index, amount] of amounts.entries()) {\n\t\t\t\tif (amount) {\n\t\t\t\t\tit(`should render precipitation amount ${amount}`, async () => {\n\t\t\t\t\t\tconst amountCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td.precipitation-amount`);\n\t\t\t\t\t\tawait expect(amountCell).toHaveText(amount);\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\n\t\tdescribe(\"Shows precipitation probability\", () => {\n\t\t\tconst probabilities = [undefined, undefined, \"12 %\", \"36 %\", \"44 %\"];\n\t\t\tfor (const [index, probability] of probabilities.entries()) {\n\t\t\t\tif (probability) {\n\t\t\t\t\tit(`should render probability ${probability}`, async () => {\n\t\t\t\t\t\tconst probabilityCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td.precipitation-prob`);\n\t\t\t\t\t\tawait expect(probabilityCell).toHaveText(probability);\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/modules_display_spec.js",
    "content": "const { expect } = require(\"playwright/test\");\nconst helpers = require(\"./helpers/global-setup\");\n\ndescribe(\"Display of modules\", () => {\n\tlet page;\n\n\tbeforeAll(async () => {\n\t\tawait helpers.startApplication(\"tests/configs/modules/display.js\");\n\t\tawait helpers.getDocument();\n\t\tpage = helpers.getPage();\n\t});\n\tafterAll(async () => {\n\t\tawait helpers.stopApplication();\n\t});\n\n\tit(\"should show the test header\", async () => {\n\t\t// textContent returns lowercase here, the uppercase is realized by CSS, which therefore does not end up in textContent\n\t\tawait expect(page.locator(\"#module_0_helloworld .module-header\")).toHaveText(\"test_header\");\n\t});\n\n\tit(\"should show no header if no header text is specified\", async () => {\n\t\tawait expect(page.locator(\"#module_1_helloworld .module-header\")).toHaveText(\"undefined\");\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/modules_empty_spec.js",
    "content": "const { expect } = require(\"playwright/test\");\nconst helpers = require(\"./helpers/global-setup\");\n\ndescribe(\"Check configuration without modules\", () => {\n\tlet page;\n\n\tbeforeAll(async () => {\n\t\tawait helpers.startApplication(\"tests/configs/without_modules.js\");\n\t\tawait helpers.getDocument();\n\t\tpage = helpers.getPage();\n\t});\n\tafterAll(async () => {\n\t\tawait helpers.stopApplication();\n\t});\n\n\tit(\"shows the message MagicMirror² title\", async () => {\n\t\tawait expect(page.locator(\"#module_1_helloworld .module-content\")).toContainText(\"MagicMirror²\");\n\t});\n\n\tit(\"shows the project URL\", async () => {\n\t\tawait expect(page.locator(\"#module_5_helloworld .module-content\")).toContainText(\"https://magicmirror.builders/\");\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/modules_position_spec.js",
    "content": "const helpers = require(\"./helpers/global-setup\");\n\nconst getPage = () => helpers.getPage();\n\ndescribe(\"Position of modules\", () => {\n\tbeforeAll(async () => {\n\t\tawait helpers.startApplication(\"tests/configs/modules/positions.js\");\n\t\tawait helpers.getDocument();\n\t});\n\tafterAll(async () => {\n\t\tawait helpers.stopApplication();\n\t});\n\n\tconst positions = [\"top_bar\", \"top_left\", \"top_center\", \"top_right\", \"upper_third\", \"middle_center\", \"lower_third\", \"bottom_left\", \"bottom_center\", \"bottom_right\", \"bottom_bar\", \"fullscreen_above\", \"fullscreen_below\"];\n\n\tfor (const position of positions) {\n\t\tconst className = position.replace(\"_\", \".\");\n\t\tit(`should show text in ${position}`, async () => {\n\t\t\tconst locator = getPage().locator(`.${className} .module-content`).first();\n\t\t\tawait locator.waitFor({ state: \"visible\" });\n\t\t\tconst text = await locator.textContent();\n\t\t\texpect(text).not.toBeNull();\n\t\t\texpect(text).toContain(`Text in ${position}`);\n\t\t});\n\t}\n});\n"
  },
  {
    "path": "tests/e2e/port_spec.js",
    "content": "const helpers = require(\"./helpers/global-setup\");\n\ndescribe(\"port directive configuration\", () => {\n\tdescribe(\"Set port 8090\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/port_8090.js\");\n\t\t});\n\t\tafterAll(async () => {\n\t\t\tawait helpers.stopApplication();\n\t\t});\n\n\t\tit(\"should return 200\", async () => {\n\t\t\tconst port = global.testPort || 8080;\n\t\t\tconst res = await fetch(`http://localhost:${port}`);\n\t\t\texpect(res.status).toBe(200);\n\t\t});\n\t});\n\n\tdescribe(\"Set port 8100 on environment variable MM_PORT\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/port_8090.js\", (process.env.MM_PORT = 8100));\n\t\t});\n\t\tafterAll(async () => {\n\t\t\tawait helpers.stopApplication();\n\t\t});\n\n\t\tit(\"should return 200\", async () => {\n\t\t\tconst port = global.testPort || 8080;\n\t\t\tconst res = await fetch(`http://localhost:${port}`);\n\t\t\texpect(res.status).toBe(200);\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/serveronly_spec.js",
    "content": "const delay = (time) => {\n\treturn new Promise((resolve) => setTimeout(resolve, time));\n};\n\nconst runConfigCheck = async () => {\n\tconst serverProcess = await require(\"node:child_process\").spawnSync(\"node\", [\"--run\", \"config:check\"], { env: process.env });\n\treturn await serverProcess.status;\n};\n\ndescribe(\"App environment\", () => {\n\tlet serverProcess;\n\n\tbeforeAll(async () => {\n\t\t// Use fixed port 8080 (tests run sequentially)\n\t\tconst testPort = 8080;\n\n\t\tprocess.env.MM_CONFIG_FILE = \"tests/configs/default.js\";\n\t\tprocess.env.MM_PORT = testPort.toString();\n\t\tserverProcess = await require(\"node:child_process\").spawn(\"node\", [\"--run\", \"server\"], { env: process.env, detached: true });\n\t\t// we have to wait until the server is started\n\t\tawait delay(2000);\n\t});\n\tafterAll(async () => {\n\t\tawait process.kill(-serverProcess.pid);\n\t});\n\n\tit(\"get request from http://localhost:8080 should return 200\", async () => {\n\t\tconst res = await fetch(\"http://localhost:8080\");\n\t\texpect(res.status).toBe(200);\n\t});\n\n\tit(\"get request from http://localhost:8080/nothing should return 404\", async () => {\n\t\tconst res = await fetch(\"http://localhost:8080/nothing\");\n\t\texpect(res.status).toBe(404);\n\t});\n});\n\ndescribe(\"Check config\", () => {\n\tit(\"config check should return without errors\", async () => {\n\t\tprocess.env.MM_CONFIG_FILE = \"tests/configs/default.js\";\n\t\tconst exitCode = await runConfigCheck();\n\t\texpect(exitCode).toBe(0);\n\t});\n\n\tit(\"config check should fail with non existent config file\", async () => {\n\t\tprocess.env.MM_CONFIG_FILE = \"tests/configs/not_exists.js\";\n\t\tconst exitCode = await runConfigCheck();\n\t\texpect(exitCode).toBe(1);\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/template_spec.js",
    "content": "const fs = require(\"node:fs\");\nconst helpers = require(\"./helpers/global-setup\");\n\ndescribe(\"templated config with port variable\", () => {\n\tbeforeAll(async () => {\n\t\tawait helpers.startApplication(\"tests/configs/port_variable.js\");\n\t});\n\tafterAll(async () => {\n\t\tawait helpers.stopApplication();\n\t\ttry {\n\t\t\tfs.unlinkSync(\"tests/configs/port_variable.js\");\n\t\t} catch (err) {\n\t\t\t// do nothing\n\t\t}\n\t});\n\n\tit(\"should return 200\", async () => {\n\t\tconst port = global.testPort || 8080;\n\t\tconst res = await fetch(`http://localhost:${port}`);\n\t\texpect(res.status).toBe(200);\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/translations_spec.js",
    "content": "const fs = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst helmet = require(\"helmet\");\nconst { JSDOM } = require(\"jsdom\");\nconst express = require(\"express\");\nconst translations = require(\"../../translations/translations\");\n\n/**\n * Helper function to create a fresh Translator instance with DOM environment.\n * @returns {object} Object containing window and Translator\n */\nfunction createTranslationTestEnvironment () {\n\t// Setup DOM environment with Translator\n\tconst translatorJs = fs.readFileSync(path.join(__dirname, \"..\", \"..\", \"js\", \"translator.js\"), \"utf-8\");\n\tconst dom = new JSDOM(\"\", { url: \"http://localhost:3000\", runScripts: \"outside-only\" });\n\n\tdom.window.Log = { log: vi.fn(), error: vi.fn() };\n\tdom.window.translations = translations;\n\tdom.window.fetch = fetch;\n\tdom.window.eval(translatorJs);\n\n\tconst window = dom.window;\n\n\treturn { window, Translator: window.Translator };\n}\n\ndescribe(\"translations\", () => {\n\tlet server;\n\n\tbeforeAll(() => {\n\t\tconst app = express();\n\t\tapp.use(helmet());\n\t\tapp.use((req, res, next) => {\n\t\t\tres.header(\"Access-Control-Allow-Origin\", \"*\");\n\t\t\tnext();\n\t\t});\n\t\tapp.use(\"/translations\", express.static(path.join(__dirname, \"..\", \"..\", \"translations\")));\n\n\t\tserver = app.listen(3000);\n\t});\n\n\tafterAll(async () => {\n\t\tawait server.close();\n\t});\n\n\tit(\"should have a translation file in the specified path\", () => {\n\t\tfor (const language in translations) {\n\t\t\tconst file = fs.statSync(translations[language]);\n\n\t\t\texpect(file.isFile()).toBe(true);\n\t\t}\n\t});\n\n\tdescribe(\"loadTranslations\", () => {\n\t\tlet dom;\n\n\t\tbeforeEach(() => {\n\t\t\t// Create a new translation test environment for each test\n\t\t\tconst env = createTranslationTestEnvironment();\n\t\t\tconst window = env.window;\n\n\t\t\t// Load class.js and module.js content directly for loadTranslations tests\n\t\t\tconst classJs = fs.readFileSync(path.join(__dirname, \"..\", \"..\", \"js\", \"class.js\"), \"utf-8\");\n\t\t\tconst moduleJs = fs.readFileSync(path.join(__dirname, \"..\", \"..\", \"js\", \"module.js\"), \"utf-8\");\n\n\t\t\t// Execute the scripts in the JSDOM context\n\t\t\twindow.eval(classJs);\n\t\t\twindow.eval(moduleJs);\n\n\t\t\t// Additional setup for loadTranslations tests\n\t\t\twindow.config = { language: \"de\" };\n\n\t\t\tdom = { window };\n\t\t});\n\n\t\tit(\"should load translation file\", async () => {\n\t\t\tconst { Translator, Module, config } = dom.window;\n\t\t\tconfig.language = \"en\";\n\t\t\tTranslator.load = vi.fn().mockImplementation((_m, _f, _fb) => null);\n\n\t\t\tModule.register(\"name\", { getTranslations: () => translations });\n\t\t\tconst MMM = Module.create(\"name\");\n\n\t\t\tawait MMM.loadTranslations();\n\n\t\t\texpect(Translator.load.mock.calls).toHaveLength(1);\n\t\t\texpect(Translator.load).toHaveBeenCalledWith(MMM, \"translations/en.json\", false);\n\t\t});\n\n\t\tit(\"should load translation + fallback file\", async () => {\n\t\t\tconst { Translator, Module } = dom.window;\n\t\t\tTranslator.load = vi.fn().mockImplementation((_m, _f, _fb) => null);\n\n\t\t\tModule.register(\"name\", { getTranslations: () => translations });\n\t\t\tconst MMM = Module.create(\"name\");\n\n\t\t\tawait MMM.loadTranslations();\n\n\t\t\texpect(Translator.load.mock.calls).toHaveLength(2);\n\t\t\texpect(Translator.load).toHaveBeenCalledWith(MMM, \"translations/de.json\", false);\n\t\t\texpect(Translator.load).toHaveBeenCalledWith(MMM, \"translations/en.json\", true);\n\t\t});\n\n\t\tit(\"should load translation fallback file\", async () => {\n\t\t\tconst { Translator, Module, config } = dom.window;\n\t\t\tconfig.language = \"--\";\n\t\t\tTranslator.load = vi.fn().mockImplementation((_m, _f, _fb) => null);\n\n\t\t\tModule.register(\"name\", { getTranslations: () => translations });\n\t\t\tconst MMM = Module.create(\"name\");\n\n\t\t\tawait MMM.loadTranslations();\n\n\t\t\texpect(Translator.load.mock.calls).toHaveLength(1);\n\t\t\texpect(Translator.load).toHaveBeenCalledWith(MMM, \"translations/en.json\", true);\n\t\t});\n\n\t\tit(\"should load no file\", async () => {\n\t\t\tconst { Translator, Module } = dom.window;\n\t\t\tTranslator.load = vi.fn();\n\n\t\t\tModule.register(\"name\", {});\n\t\t\tconst MMM = Module.create(\"name\");\n\n\t\t\tawait MMM.loadTranslations();\n\n\t\t\texpect(Translator.load.mock.calls).toHaveLength(0);\n\t\t});\n\t});\n\n\tconst mmm = {\n\t\tname: \"TranslationTest\",\n\t\tfile (file) {\n\t\t\treturn `http://localhost:3000/${file}`;\n\t\t}\n\t};\n\n\tdescribe(\"parsing language files through the Translator class\", () => {\n\t\tfor (const language in translations) {\n\t\t\tit(`should parse ${language}`, async () => {\n\t\t\t\tconst { Translator } = createTranslationTestEnvironment();\n\t\t\t\tawait Translator.load(mmm, translations[language], false);\n\n\t\t\t\texpect(typeof Translator.translations[mmm.name]).toBe(\"object\");\n\t\t\t\texpect(Object.keys(Translator.translations[mmm.name]).length).toBeGreaterThanOrEqual(1);\n\t\t\t});\n\t\t}\n\t});\n\n\tdescribe(\"same keys\", () => {\n\t\tlet base;\n\n\t\t// Some expressions are not easy to translate automatically. For the sake of a working test, we filter them out.\n\t\tconst COMMON_EXCEPTIONS = [\"WEEK_SHORT\"];\n\n\t\t// Some languages don't have certain words, so we need to filter those language specific exceptions.\n\t\tconst LANGUAGE_EXCEPTIONS = {\n\t\t\tca: [\"DAYBEFOREYESTERDAY\"],\n\t\t\tcv: [\"DAYBEFOREYESTERDAY\"],\n\t\t\tcy: [\"DAYBEFOREYESTERDAY\"],\n\t\t\ten: [\"DAYAFTERTOMORROW\", \"DAYBEFOREYESTERDAY\"],\n\t\t\tfy: [\"DAYBEFOREYESTERDAY\"],\n\t\t\tgl: [\"DAYBEFOREYESTERDAY\"],\n\t\t\thu: [\"DAYBEFOREYESTERDAY\"],\n\t\t\tid: [\"DAYBEFOREYESTERDAY\"],\n\t\t\tit: [\"DAYBEFOREYESTERDAY\"],\n\t\t\t\"pt-br\": [\"DAYAFTERTOMORROW\"],\n\t\t\ttr: [\"DAYBEFOREYESTERDAY\"]\n\t\t};\n\n\t\t// Function to initialize JSDOM and load translations\n\t\tconst initializeTranslationDOM = async (language) => {\n\t\t\tconst { Translator } = createTranslationTestEnvironment();\n\t\t\tawait Translator.load(mmm, translations[language], false);\n\t\t\treturn Translator.translations[mmm.name];\n\t\t};\n\n\t\tbeforeAll(async () => {\n\t\t\t// Using German as the base rather than English, since\n\t\t\t// some words do not have a direct translation in English.\n\t\t\tconst germanTranslations = await initializeTranslationDOM(\"de\");\n\t\t\tbase = Object.keys(germanTranslations).sort();\n\t\t});\n\n\t\tfor (const language in translations) {\n\t\t\tif (language === \"de\") continue;\n\n\t\t\tdescribe(`Translation keys of ${language}`, () => {\n\t\t\t\tlet keys;\n\n\t\t\t\tbeforeAll(async () => {\n\t\t\t\t\tconst languageTranslations = await initializeTranslationDOM(language);\n\t\t\t\t\tkeys = Object.keys(languageTranslations).sort();\n\t\t\t\t});\n\n\t\t\t\tit(`${language} should not contain keys that are not in base language`, () => {\n\t\t\t\t\tkeys.forEach((key) => {\n\t\t\t\t\t\texpect(base).toContain(key, `Translation key '${key}' in language '${language}' is not present in base language`);\n\t\t\t\t\t});\n\t\t\t\t});\n\n\t\t\t\tit(`${language} should contain all base keys (excluding defined exceptions)`, () => {\n\t\t\t\t\tlet filteredBase = base.filter((key) => !COMMON_EXCEPTIONS.includes(key));\n\t\t\t\t\tlet filteredKeys = keys.filter((key) => !COMMON_EXCEPTIONS.includes(key));\n\n\t\t\t\t\tif (LANGUAGE_EXCEPTIONS[language]) {\n\t\t\t\t\t\tconst exceptions = LANGUAGE_EXCEPTIONS[language];\n\t\t\t\t\t\tfilteredBase = filteredBase.filter((key) => !exceptions.includes(key));\n\t\t\t\t\t\tfilteredKeys = filteredKeys.filter((key) => !exceptions.includes(key));\n\t\t\t\t\t}\n\n\t\t\t\t\tfilteredBase.forEach((baseKey) => {\n\t\t\t\t\t\texpect(filteredKeys).toContain(baseKey, `Translation key '${baseKey}' is missing in language '${language}'`);\n\t\t\t\t\t});\n\t\t\t\t});\n\t\t\t});\n\t\t}\n\t});\n});\n"
  },
  {
    "path": "tests/e2e/vendor_spec.js",
    "content": "const helpers = require(\"./helpers/global-setup\");\n\ndescribe(\"Vendors\", () => {\n\tbeforeAll(async () => {\n\t\tawait helpers.startApplication(\"tests/configs/default.js\");\n\t});\n\tafterAll(async () => {\n\t\tawait helpers.stopApplication();\n\t});\n\n\tdescribe(\"Get list vendors\", () => {\n\t\tconst vendors = require(`${global.root_path}/js/vendor.js`);\n\n\t\tObject.keys(vendors).forEach((vendor) => {\n\t\t\tit(`should return 200 HTTP code for vendor \"${vendor}\"`, async () => {\n\t\t\t\tconst urlVendor = `http://localhost:8080/${vendors[vendor]}`;\n\t\t\t\tconst res = await fetch(urlVendor);\n\t\t\t\texpect(res.status).toBe(200);\n\t\t\t});\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/electron/env_spec.js",
    "content": "const events = require(\"node:events\");\nconst helpers = require(\"./helpers/global-setup\");\n\ndescribe(\"Electron app environment\", () => {\n\tbeforeEach(async () => {\n\t\tawait helpers.startApplication(\"tests/configs/modules/display.js\");\n\t});\n\n\tafterEach(async () => {\n\t\tawait helpers.stopApplication();\n\t});\n\n\tit(\"should open browserwindow\", async () => {\n\t\t// Wait for module content to be rendered, not just the module wrapper\n\t\tconst moduleContent = await helpers.getElement(\"#module_0_helloworld .module-content\");\n\t\tawait expect(moduleContent.textContent()).resolves.toContain(\"Test Display Header\");\n\t\texpect(global.electronApp.windows()).toHaveLength(1);\n\t});\n});\n\ndescribe(\"Development console tests\", () => {\n\tbeforeEach(async () => {\n\t\tawait helpers.startApplication(\"tests/configs/modules/display.js\", null, [\"dev\"]);\n\t});\n\n\tafterEach(async () => {\n\t\tawait helpers.stopApplication();\n\t});\n\n\tit(\"should open browserwindow and dev console\", async () => {\n\t\twhile (global.electronApp.windows().length < 2) await events.once(global.electronApp, \"window\");\n\t\tconst pageArray = await global.electronApp.windows();\n\t\texpect(pageArray).toHaveLength(2);\n\t\tfor (const page of pageArray) {\n\t\t\texpect([\"MagicMirror²\", \"DevTools\"]).toContain(await page.title());\n\t\t}\n\t});\n});\n"
  },
  {
    "path": "tests/electron/helpers/global-setup.js",
    "content": "// see https://playwright.dev/docs/api/class-electronapplication\n// https://github.com/microsoft/playwright/issues/6347#issuecomment-1085850728\n// https://www.anycodings.com/1questions/958135/can-i-set-the-date-for-playwright-browser\nconst { _electron: electron } = require(\"playwright\");\n\nexports.startApplication = async (configFilename, systemDate = null, electronParams = [], timezone = \"GMT\") => {\n\tglobal.electronApp = null;\n\tglobal.page = null;\n\tprocess.env.MM_CONFIG_FILE = configFilename;\n\tprocess.env.TZ = timezone;\n\tif (systemDate) {\n\t\tprocess.env.MOCK_DATE = systemDate;\n\t}\n\tprocess.env.mmTestMode = \"true\";\n\n\t// check environment for DISPLAY or WAYLAND_DISPLAY\n\tif (process.env.WAYLAND_DISPLAY) {\n\t\telectronParams.unshift(\"js/electron.js\", \"--enable-features=UseOzonePlatform\", \"--ozone-platform=wayland\");\n\t} else {\n\t\telectronParams.unshift(\"js/electron.js\");\n\t}\n\n\t// Pass environment variables to Electron process\n\tconst env = {\n\t\t...process.env,\n\t\tMM_CONFIG_FILE: configFilename,\n\t\tTZ: timezone,\n\t\tmmTestMode: \"true\"\n\t};\n\tif (systemDate) {\n\t\tenv.MOCK_DATE = systemDate;\n\t}\n\n\tglobal.electronApp = await electron.launch({\n\t\targs: electronParams,\n\t\tenv: env\n\t});\n\n\tawait global.electronApp.firstWindow();\n\n\tfor (const win of global.electronApp.windows()) {\n\t\tconst title = await win.title();\n\t\texpect([\"MagicMirror²\", \"DevTools\"]).toContain(title);\n\t\tif (title === \"MagicMirror²\") {\n\t\t\tglobal.page = win;\n\t\t\tif (systemDate) {\n\t\t\t\tawait global.page.evaluate((systemDate) => {\n\t\t\t\t\tDate.now = () => {\n\t\t\t\t\t\treturn new Date(systemDate).valueOf();\n\t\t\t\t\t};\n\t\t\t\t}, systemDate);\n\t\t\t}\n\t\t}\n\t}\n};\n\nexports.stopApplication = async (timeout = 10000) => {\n\tconst app = global.electronApp;\n\tglobal.electronApp = null;\n\tglobal.page = null;\n\tprocess.env.MOCK_DATE = undefined;\n\n\tif (!app) {\n\t\treturn;\n\t}\n\n\tconst killElectron = () => {\n\t\ttry {\n\t\t\tconst electronProcess = typeof app.process === \"function\" ? app.process() : null;\n\t\t\tif (electronProcess && !electronProcess.killed) {\n\t\t\t\telectronProcess.kill(\"SIGKILL\");\n\t\t\t}\n\t\t} catch (error) {\n\t\t\t// Ignore errors caused by Playwright already tearing down the connection\n\t\t}\n\t};\n\n\ttry {\n\t\tawait Promise.race([\n\t\t\tapp.close(),\n\t\t\tnew Promise((_, reject) => setTimeout(() => reject(new Error(\"Electron close timeout\")), timeout))\n\t\t]);\n\t} catch (error) {\n\t\tkillElectron();\n\t}\n};\n\nexports.getElement = async (selector, state = \"visible\") => {\n\texpect(global.page).not.toBeNull();\n\tconst elem = global.page.locator(selector);\n\tawait elem.waitFor({ state: state });\n\texpect(elem).not.toBeNull();\n\treturn elem;\n};\n"
  },
  {
    "path": "tests/electron/helpers/weather-setup.js",
    "content": "const { injectMockData } = require(\"../../utils/weather_mocker\");\nconst helpers = require(\"./global-setup\");\n\nexports.getText = async (element, result) => {\n\tconst elem = await helpers.getElement(element);\n\tawait expect(elem).not.toBeNull();\n\tconst text = await elem.textContent();\n\tawait expect(\n\t\ttext\n\t\t\t.trim()\n\t\t\t.replace(/(\\r\\n|\\n|\\r)/gm, \"\")\n\t\t\t.replace(/[ ]+/g, \" \")\n\t).toBe(result);\n\treturn true;\n};\n\nexports.startApp = async (configFileName, systemDate) => {\n\tawait helpers.startApplication(injectMockData(configFileName), systemDate);\n};\n"
  },
  {
    "path": "tests/electron/modules/calendar_spec.js",
    "content": "const helpers = require(\"../helpers/global-setup\");\n\ndescribe(\"Calendar module\", () => {\n\n\t/**\n\t * move similar tests in function doTest\n\t * @param {string} cssClass css selector\n\t * @returns {boolean} result\n\t */\n\tconst doTest = async (cssClass) => {\n\t\tconst elem = await helpers.getElement(`.calendar .module-content .event${cssClass}`);\n\t\tawait expect(elem.isVisible()).resolves.toBe(true);\n\t\treturn true;\n\t};\n\n\tconst doTestCount = async (locator = \".calendar .event\") => {\n\t\texpect(global.page).not.toBeNull();\n\t\tconst loc = await global.page.locator(locator);\n\t\tconst elem = loc.first();\n\t\tawait elem.waitFor();\n\t\texpect(elem).not.toBeNull();\n\t\treturn await loc.count();\n\t};\n\n\t/**\n\t * Use this for debugging broken tests, it will console log the text of the calendar module\n\t * @returns {Promise<void>}\n\t */\n\tconst logAllText = async () => {\n\t\texpect(global.page).not.toBeNull();\n\t\tconst loc = await global.page.locator(\".calendar .event\");\n\t\tconst elem = loc.first();\n\t\tawait elem.waitFor();\n\t\texpect(elem).not.toBeNull();\n\t\tconsole.log(await loc.allInnerTexts());\n\t};\n\n\tconst first = 0;\n\tconst second = 1;\n\tconst third = 2;\n\tconst last = -1;\n\n\t// get results of table row and column, can select specific row of results,\n\t// row is 0 based index  -1 is last, 0 is first...  need 10th(human count), use 9 as row\n\t// uses playwright nth locator syntax\n\tconst doTestTableContent = async (table_row, table_column, content, row = first) => {\n\t\tconst elem = await global.page.locator(table_row);\n\t\tconst column = await elem.locator(table_column).locator(`nth=${row}`);\n\t\tawait expect(column.textContent()).resolves.toContain(content);\n\t\treturn true;\n\t};\n\n\tafterEach(async () => {\n\t\tawait helpers.stopApplication();\n\t});\n\n\tdescribe(\"Test css classes\", () => {\n\t\tit(\"has css class dayBeforeYesterday\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/custom.js\", \"03 Jan 2030 12:30:00 GMT\");\n\t\t\tawait expect(doTest(\".dayBeforeYesterday\")).resolves.toBe(true);\n\t\t});\n\n\t\tit(\"has css class yesterday\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/custom.js\", \"02 Jan 2030 12:30:00 GMT\");\n\t\t\tawait expect(doTest(\".yesterday\")).resolves.toBe(true);\n\t\t});\n\n\t\tit(\"has css class today\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/custom.js\", \"01 Jan 2030 12:30:00 GMT\");\n\t\t\tawait expect(doTest(\".today\")).resolves.toBe(true);\n\t\t});\n\n\t\tit(\"has css class tomorrow\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/custom.js\", \"31 Dec 2029 12:30:00 GMT\");\n\t\t\tawait expect(doTest(\".tomorrow\")).resolves.toBe(true);\n\t\t});\n\n\t\tit(\"has css class dayAfterTomorrow\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/custom.js\", \"30 Dec 2029 12:30:00 GMT\");\n\t\t\tawait expect(doTest(\".dayAfterTomorrow\")).resolves.toBe(true);\n\t\t});\n\t});\n\n\tdescribe(\"Events from multiple calendars\", () => {\n\t\tit(\"should show multiple events with the same title and start time from different calendars\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/show-duplicates-in-calendar.js\", \"15 Sep 2024 12:30:00 GMT\");\n\t\t\tawait expect(doTestCount()).resolves.toBe(20);\n\t\t});\n\t});\n\n\t/*\n\t * RRULE TESTS:\n\t * Add any tests that check rrule functionality here.\n\t */\n\tdescribe(\"rrule\", () => {\n\t\tit(\"Issue #3393 recurrence dates past rrule until date\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/rrule_until.js\", \"07 Mar 2024 10:38:00 GMT-07:00\", [], \"America/Los_Angeles\");\n\t\t\tawait expect(doTestCount()).resolves.toBe(1);\n\t\t});\n\t\tit(\"Issue #3781 recurrence rrule until with date only uses timezone offset incorrectly\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/fullday_until.js\", \"01 May 2025\", [], \"America/Los_Angeles\");\n\t\t\tawait expect(doTestCount()).resolves.toBe(1);\n\t\t});\n\t});\n\n\t/*\n\t * LOS ANGELES TESTS:\n\t *  In 2023, DST (GMT-7) was until 5 Nov, after which is standard (STD) (GMT-8) time.\n\t *  Test takes place on Thu 19 Oct, recurring event on a Wednesday. maximumNumberOfDays=28, so there should be\n\t *  4 events (25 Oct, 1 Nov, (switch to STD), 8 Nov, Nov 15), but 1 Nov and 8 Nov are excluded.\n\t *  There are three separate tests:\n\t *  * before midnight GMT (3pm local time)\n\t *  * at midnight GMT in STD time (4pm local time)\n\t *  * at midnight GMT in DST time (5pm local time)\n\t */\n\tdescribe(\"Exdate: LA crossover DST before midnight GMT\", () => {\n\t\tit(\"LA crossover DST before midnight GMT should have 2 events\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/exdate_la_before_midnight.js\", \"19 Oct 2023 12:30:00 GMT-07:00\", [], \"America/Los_Angeles\");\n\t\t\tawait expect(doTestCount()).resolves.toBe(2);\n\t\t});\n\t});\n\n\tdescribe(\"Exdate: LA crossover DST at midnight GMT local STD\", () => {\n\t\tit(\"LA crossover DST before midnight GMT should have 2 events\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/exdate_la_at_midnight_std.js\", \"19 Oct 2023 12:30:00 GMT-07:00\", [], \"America/Los_Angeles\");\n\t\t\tawait expect(doTestCount()).resolves.toBe(2);\n\t\t});\n\t});\n\tdescribe(\"Exdate: LA crossover DST at midnight GMT local DST\", () => {\n\t\tit(\"LA crossover DST before midnight GMT should have 2 events\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/exdate_la_at_midnight_dst.js\", \"19 Oct 2023 12:30:00 GMT-07:00\", [], \"America/Los_Angeles\");\n\t\t\tawait expect(doTestCount()).resolves.toBe(2);\n\t\t});\n\t});\n\n\t/*\n\t * SYDNEY TESTS:\n\t *  In 2023, standard time (STD) (GMT+10) was until 1 Oct, after which is DST (GMT+11).\n\t *  Test takes place on Thu 14 Sep, recurring event on a Wednesday. maximumNumberOfDays=28, so there should be\n\t *  4 events (20 Sep, 27 Sep, (switch to DST), 4 Oct, 11 Oct), but 27 Sep and 4 Oct are excluded.\n\t *  There are three separate tests:\n\t *  * before midnight GMT (9am local time)\n\t *  * at midnight GMT in STD time (10am local time)\n\t *  * at midnight GMT in DST time (11am local time)\n\t */\n\tdescribe(\"Exdate: SYD crossover DST before midnight GMT\", () => {\n\t\tit(\"LA crossover DST before midnight GMT should have 2 events\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/exdate_syd_before_midnight.js\", \"14 Sep 2023 12:30:00 GMT+10:00\", [], \"Australia/Sydney\");\n\t\t\tawait expect(doTestCount()).resolves.toBe(2);\n\t\t});\n\t});\n\tdescribe(\"Exdate: SYD crossover DST at midnight GMT local STD\", () => {\n\t\tit(\"LA crossover DST before midnight GMT should have 2 events\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/exdate_syd_at_midnight_std.js\", \"14 Sep 2023 12:30:00 GMT+10:00\", [], \"Australia/Sydney\");\n\t\t\tawait expect(doTestCount()).resolves.toBe(2);\n\t\t});\n\t});\n\tdescribe(\"Exdate: SYD crossover DST at midnight GMT local DST\", () => {\n\t\tit(\"SYD crossover DST at midnight GMT local DST should have 2 events\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/exdate_syd_at_midnight_dst.js\", \"14 Sep 2023 12:30:00 GMT+10:00\", [], \"Australia/Sydney\");\n\t\t\tawait expect(doTestCount()).resolves.toBe(2);\n\t\t});\n\t});\n\n\t/*\n         * RRULE TESTS:\n         * Add any tests that check rrule functionality here.\n         */\n\tdescribe(\"sliceMultiDayEvents direct count\", () => {\n\t\tit(\"Issue #3452 split multiday in Europe\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/sliceMultiDayEvents.js\", \"01 Sept 2024 10:38:00 GMT+02:00\", [], \"Europe/Berlin\");\n\t\t\tawait expect(doTestCount()).resolves.toBe(6);\n\t\t});\n\t});\n\n\tdescribe(\"germany timezone\", () => {\n\t\tit(\"Issue #unknown fullday timezone East of UTC edge\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/germany_at_end_of_day_repeating.js\", \"01 Oct 2024 10:38:00 GMT+02:00\", [], \"Europe/Berlin\");\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"Oct 22nd, 23:00\", first)).resolves.toBe(true);\n\t\t});\n\t});\n\n\tdescribe(\"germany all day repeating moved (recurrence and exdate)\", () => {\n\t\tit(\"Issue #unknown fullday timezone East of UTC event moved\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/3_move_first_allday_repeating_event.js\", \"01 Oct 2024 10:38:00 GMT+02:00\", [], \"Europe/Berlin\");\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"12th.Oct\")).resolves.toBe(true);\n\t\t});\n\t});\n\n\tdescribe(\"chicago late in timezone\", () => {\n\t\tit(\"Issue #unknown rrule US close to timezone edge\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/chicago_late_in_timezone.js\", \"01 Sept 2024 10:38:00 GMT-5:00\", [], \"America/Chicago\");\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"10th.Sep, 20:15\")).resolves.toBe(true);\n\t\t});\n\t});\n\n\tdescribe(\"berlin late in day event moved, viewed from berlin\", () => {\n\t\tit(\"Issue #unknown rrule ETC+2 close to timezone edge\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/end_of_day_berlin_moved.js\", \"08 Oct 2024 12:30:00 GMT+02:00\", [], \"Europe/Berlin\");\n\t\t\tawait expect(doTestCount()).resolves.toBe(3);\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"22nd.Oct, 23:00-00:00\", first)).resolves.toBe(true);\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"23rd.Oct, 23:00-00:00\", second)).resolves.toBe(true);\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"24th.Oct, 23:00-00:00\", third)).resolves.toBe(true);\n\t\t});\n\t});\n\n\tdescribe(\"berlin late in day event moved, viewed from sydney\", () => {\n\t\tit(\"Issue #unknown rrule ETC+2 close to timezone edge\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/end_of_day_berlin_moved.js\", \"08 Oct 2024 12:30:00 GMT+02:00\", [], \"Australia/Sydney\");\n\t\t\tawait expect(doTestCount()).resolves.toBe(3);\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"23rd.Oct, 08:00-09:00\", first)).resolves.toBe(true);\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"24th.Oct, 08:00-09:00\", second)).resolves.toBe(true);\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"25th.Oct, 08:00-09:00\", third)).resolves.toBe(true);\n\t\t});\n\t});\n\n\tdescribe(\"berlin late in day event moved, viewed from chicago\", () => {\n\t\tit(\"Issue #unknown rrule ETC+2 close to timezone edge\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/end_of_day_berlin_moved.js\", \"08 Oct 2024 12:30:00 GMT+02:00\", [], \"America/Chicago\");\n\t\t\tawait expect(doTestCount()).resolves.toBe(3);\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"22nd.Oct, 16:00-17:00\", first)).resolves.toBe(true);\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"23rd.Oct, 16:00-17:00\", second)).resolves.toBe(true);\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"24th.Oct, 16:00-17:00\", third)).resolves.toBe(true);\n\t\t});\n\t});\n\n\tdescribe(\"berlin multi-events inside offset\", () => {\n\t\tit(\"some events before DST. some after midnight\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/berlin_multi.js\", \"08 Oct 2024 12:30:00 GMT+02:00\", [], \"Europe/Berlin\");\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"30th.Oct, 00:00-01:00\", last)).resolves.toBe(true);\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"21st.Oct, 00:00-01:00\", first)).resolves.toBe(true);\n\t\t});\n\t});\n\n\tdescribe(\"berlin whole day repeating, start moved after end\", () => {\n\t\tit(\"some events before DST. some after\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/berlin_whole_day_event_moved_over_dst_change.js\", \"08 Oct 2024 12:30:00 GMT+02:00\", [], \"Europe/Berlin\");\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"30th.Oct\", last)).resolves.toBe(true);\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"27th.Oct\", first)).resolves.toBe(true);\n\t\t});\n\t});\n\n\tdescribe(\"berlin 11pm-midnight\", () => {\n\t\tit(\"right inside the offset, before midnight\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/berlin_end_of_day_repeating.js\", \"08 Oct 2024 12:30:00 GMT+02:00\", [], \"Europe/Berlin\");\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"24th.Oct, 23:00-00:00\", last)).resolves.toBe(true);\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"22nd.Oct, 23:00-00:00\", first)).resolves.toBe(true);\n\t\t});\n\t});\n\n\tdescribe(\"both moved and delete events in recurring list\", () => {\n\t\tit(\"with moved before and after original\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/exdate_and_recurrence_together.js\", \"08 Oct 2024 12:30:00 GMT-07:00\", [], \"America/Los_Angeles\");\n\t\t\t// moved after end at oct 26\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"27th.Oct, 14:30-15:30\", last)).resolves.toBe(true);\n\t\t\t// moved before start at oct 23\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"22nd.Oct, 14:30-15:30\", first)).resolves.toBe(true);\n\t\t\t// remaining original 4th, now 3rd\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"26th.Oct, 14:30-15:30\", second)).resolves.toBe(true);\n\t\t});\n\t});\n\n\tdescribe(\"one event diff tz\", () => {\n\t\tit(\"start/end in diff timezones\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/diff_tz_start_end.js\", \"08 Oct 2024 12:30:00 GMT-07:00\", [], \"America/Chicago\");\n\t\t\t// just\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"29th.Oct, 05:00-30th.Oct, 18:00\", first)).resolves.toBe(true);\n\t\t});\n\t\tit(\"viewing from further west in diff timezones\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/chicago-looking-at-ny-recurring.js\", \"22 Jan 2025 14:30:00 GMT-06:00\", [], \"America/Chicago\");\n\t\t\t// just\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"22nd.Jan, 17:30-19:30\", first)).resolves.toBe(true);\n\t\t});\n\t});\n\n\tdescribe(\"one event non repeating\", () => {\n\t\tit(\"fullday non-repeating\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/fullday_event_over_multiple_days_nonrepeating.js\", \"08 Oct 2024 12:30:00 GMT-07:00\", [], \"America/Chicago\");\n\t\t\t// just\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"25th.Oct-30th.Oct\", first)).resolves.toBe(true);\n\t\t});\n\t});\n\n\tdescribe(\"one event no end display\", () => {\n\t\tit(\"don't display end\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_no_display_end.js\", \"08 Oct 2024 12:30:00 GMT-07:00\", [], \"America/Chicago\");\n\t\t\t// just\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"25th.Oct, 20:00\", first)).resolves.toBe(true);\n\t\t});\n\t});\n\n\tdescribe(\"display end display end\", () => {\n\t\tit(\"display end\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_display_end.js\", \"08 Oct 2024 12:30:00 GMT-07:00\", [], \"America/Chicago\");\n\t\t\t// just\n\t\t\tawait expect(doTestTableContent(\".calendar .event\", \".time\", \"25th.Oct, 20:00-26th.Oct, 06:00\", first)).resolves.toBe(true);\n\t\t});\n\t});\n\n\tdescribe(\"count and check symbols\", () => {\n\t\tit(\"in array\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/symboltest.js\", \"08 Oct 2024 12:30:00 GMT-07:00\", [], \"America/Chicago\");\n\t\t\t// just\n\t\t\tawait expect(doTestCount(\".calendar .event .symbol .fa-fw\")).resolves.toBe(2);\n\t\t\tawait expect(doTestCount(\".calendar .event .symbol .fa-calendar-check\")).resolves.toBe(1);\n\t\t\tawait expect(doTestCount(\".calendar .event .symbol .fa-google\")).resolves.toBe(1);\n\n\t\t});\n\t});\n\n\tdescribe(\"count events broadcast\", () => {\n\t\tit(\"get 12 with maxentries set to 1\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/calendar/countCalendarEvents.js\", \"01 Jan 2024 12:30:00 GMT-076:00\", [], \"America/Chicago\");\n\t\t\tawait expect(doTestTableContent(\".testNotification\", \".elementCount\", \"12\", first)).resolves.toBe(true);\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/electron/modules/compliments_spec.js",
    "content": "const helpers = require(\"../helpers/global-setup\");\n\ndescribe(\"Compliments module\", () => {\n\n\t/**\n\t * move similar tests in function doTest\n\t * @param {Array} complimentsArray The array of compliments.\n\t * @param {string} state The state of the element (e.g., \"visible\" or \"attached\").\n\t * @returns {boolean} result\n\t */\n\tconst doTest = async (complimentsArray, state = \"visible\") => {\n\t\tawait helpers.getElement(\".compliments\", state);\n\t\tconst elem = await helpers.getElement(\".module-content\", state);\n\t\texpect(elem).not.toBeNull();\n\t\texpect(complimentsArray).toContain(await elem.textContent());\n\t\treturn true;\n\t};\n\n\tafterEach(async () => {\n\t\tawait helpers.stopApplication();\n\t});\n\n\tdescribe(\"parts of days\", () => {\n\t\tit(\"Morning compliments for that part of day\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/compliments/compliments_parts_day.js\", \"01 Oct 2022 10:00:00 GMT\");\n\t\t\tawait expect(doTest([\"Hi\", \"Good Morning\", \"Morning test\"])).resolves.toBe(true);\n\t\t});\n\n\t\tit(\"Afternoon show Compliments for that part of day\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/compliments/compliments_parts_day.js\", \"01 Oct 2022 15:00:00 GMT\");\n\t\t\tawait expect(doTest([\"Hello\", \"Good Afternoon\", \"Afternoon test\"])).resolves.toBe(true);\n\t\t});\n\n\t\tit(\"Evening show Compliments for that part of day\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/compliments/compliments_parts_day.js\", \"01 Oct 2022 20:00:00 GMT\");\n\t\t\tawait expect(doTest([\"Hello There\", \"Good Evening\", \"Evening test\"])).resolves.toBe(true);\n\t\t});\n\n\t\tit(\"doesn't show evening compliments during the day when the other parts of day are not set\", async () => {\n\t\t\tawait helpers.startApplication(\"tests/configs/modules/compliments/compliments_evening.js\", \"01 Oct 2022 08:00:00 GMT\");\n\t\t\tawait expect(doTest([\"\"], \"attached\")).resolves.toBe(true);\n\t\t});\n\t});\n\n\tdescribe(\"Feature date in compliments module\", () => {\n\t\tdescribe(\"Set date and empty compliments for anytime, morning, evening and afternoon\", () => {\n\t\t\tit(\"shows happy new year compliment on new years day\", async () => {\n\t\t\t\tawait helpers.startApplication(\"tests/configs/modules/compliments/compliments_date.js\", \"01 Jan 2022 10:00:00 GMT\");\n\t\t\t\tawait expect(doTest([\"Happy new year!\"])).resolves.toBe(true);\n\t\t\t});\n\t\t});\n\n\t\tdescribe(\"Test only custom date events shown with new property\", () => {\n\t\t\tit(\"shows 'Special day message' on May 6\", async () => {\n\t\t\t\tawait helpers.startApplication(\"tests/configs/modules/compliments/compliments_specialDayUnique_true.js\", \"06 May 2022 10:00:00 GMT\");\n\t\t\t\tawait expect(doTest([\"Special day message\"])).resolves.toBe(true);\n\t\t\t});\n\t\t});\n\n\t\tdescribe(\"Test all date events shown without new property\", () => {\n\t\t\tit(\"shows 'any message' on May 6\", async () => {\n\t\t\t\tawait helpers.startApplication(\"tests/configs/modules/compliments/compliments_specialDayUnique_false.js\", \"06 May 2022 10:00:00 GMT\");\n\t\t\t\tawait expect(doTest([\"Special day message\", \"Typical message 1\", \"Typical message 2\", \"Typical message 3\"])).resolves.toBe(true);\n\t\t\t});\n\t\t});\n\n\t\tdescribe(\"Test only custom cron date event shown with new property\", () => {\n\t\t\tit(\"shows 'any message' on May 6\", async () => {\n\t\t\t\tawait helpers.startApplication(\"tests/configs/modules/compliments/compliments_cron_entry.js\", \"06 May 2022 17:03:00 GMT\");\n\t\t\t\tawait expect(doTest([\"just pub time\"])).resolves.toBe(true);\n\t\t\t});\n\t\t});\n\n\t\tdescribe(\"Test any event shows after time window\", () => {\n\t\t\tit(\"shows 'any message' on May 6\", async () => {\n\t\t\t\tawait helpers.startApplication(\"tests/configs/modules/compliments/compliments_cron_entry.js\", \"06 May 2022 17:11:00 GMT\");\n\t\t\t\tawait expect(doTest([\"just a test\"])).resolves.toBe(true);\n\t\t\t});\n\t\t});\n\n\t\tdescribe(\"Test any event shows different day\", () => {\n\t\t\tit(\"shows 'any message' on May 5\", async () => {\n\t\t\t\tawait helpers.startApplication(\"tests/configs/modules/compliments/compliments_cron_entry.js\", \"05 May 2022 17:00:00 GMT\");\n\t\t\t\tawait expect(doTest([\"just a test\"])).resolves.toBe(true);\n\t\t\t});\n\t\t});\n\t});\n\n\tdescribe(\"Feature remote compliments file\", () => {\n\t\tdescribe(\"get list from remote file\", () => {\n\t\t\tit(\"shows 'Remote compliment file works!' as only anytime list set\", async () => {\n\t\t\t\tawait helpers.startApplication(\"tests/configs/modules/compliments/compliments_file.js\", \"01 Jan 2022 10:00:00 GMT\");\n\t\t\t\tawait expect(doTest([\"Remote compliment file works!\"])).resolves.toBe(true);\n\t\t\t});\n\t\t});\n\t\tdescribe(\"get updated list from remote file\", () => {\n\t\t\tit(\"shows 'test in morning'\", async () => {\n\t\t\t\tawait helpers.startApplication(\"tests/configs/modules/compliments/compliments_file_change.js\", \"01 Jan 2022 10:00:00 GMT\");\n\t\t\t\tawait expect(doTest([\"Remote compliment file works!\"])).resolves.toBe(true);\n\t\t\t\tawait new Promise((r) => setTimeout(r, 10000));\n\t\t\t\tawait expect(doTest([\"test in morning\"])).resolves.toBe(true);\n\t\t\t});\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/electron/modules/weather_spec.js",
    "content": "const helpers = require(\"../helpers/global-setup\");\nconst weatherHelper = require(\"../helpers/weather-setup\");\nconst { cleanupMockData } = require(\"../../utils/weather_mocker\");\n\nconst CURRENT_WEATHER_CONFIG = \"tests/configs/modules/weather/currentweather_default.js\";\nconst SUNRISE_DATE = \"13 Jan 2019 00:30:00 GMT\";\nconst SUNSET_DATE = \"13 Jan 2019 12:30:00 GMT\";\nconst SUN_EVENT_SELECTOR = \".weather .normal.medium span:nth-child(4)\";\nconst EXPECTED_SUNRISE_TEXT = \"7:00 am\";\nconst EXPECTED_SUNSET_TEXT = \"3:45 pm\";\n\ndescribe(\"Weather module\", () => {\n\tafterEach(async () => {\n\t\tawait helpers.stopApplication();\n\t\tcleanupMockData();\n\t});\n\n\tdescribe(\"Current weather with sunrise\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait weatherHelper.startApp(CURRENT_WEATHER_CONFIG, SUNRISE_DATE);\n\t\t});\n\n\t\tit(\"should render sunrise\", async () => {\n\t\t\tconst isSunriseRendered = await weatherHelper.getText(SUN_EVENT_SELECTOR, EXPECTED_SUNRISE_TEXT);\n\t\t\texpect(isSunriseRendered).toBe(true);\n\t\t});\n\t});\n\n\tdescribe(\"Current weather with sunset\", () => {\n\t\tbeforeAll(async () => {\n\t\t\tawait weatherHelper.startApp(CURRENT_WEATHER_CONFIG, SUNSET_DATE);\n\t\t});\n\n\t\tit(\"should render sunset\", async () => {\n\t\t\tconst isSunsetRendered = await weatherHelper.getText(SUN_EVENT_SELECTOR, EXPECTED_SUNSET_TEXT);\n\t\t\texpect(isSunsetRendered).toBe(true);\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/mocks/12_events.ics",
    "content": "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//Calendar Labs//Calendar 1.0//EN\nCALSCALE:GREGORIAN\nMETHOD:PUBLISH\nX-WR-CALNAME:US Holidays\nX-WR-TIMEZONE:Etc/GMT\nBEGIN:VEVENT\nSUMMARY:Start of Month 1\nDTSTART:20190101\nDTEND:20190101\nRRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1\nLOCATION:United States\nDESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \\n\\n Like us on Facebook: http://fb.com/calendarlabs to get updates\nUID:5e52949sada28d231582470298@calendarlabs.com\nDTSTAMP:20200223T150458Z\nSTATUS:CONFIRMED\nTRANSP:TRANSPARENT\nSEQUENCE:0\nEND:VEVENT\nBEGIN:VEVENT\nSUMMARY:Start of Month 2\nDTSTART:20190201\nDTEND:20190201\nRRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1\nLOCATION:United States\nDESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \\n\\n Like us on Facebook: http://fb.com/calendarlabs to get updates\nUID:5e52949a2wds8d231582470298@calendarlabs.com\nDTSTAMP:20200223T150458Z\nSTATUS:CONFIRMED\nTRANSP:TRANSPARENT\nSEQUENCE:0\nEND:VEVENT\nBEGIN:VEVENT\nSUMMARY:Start of Month 3\nDTSTART:20190301\nDTEND:20190301\nRRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1\nLOCATION:United States\nDESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \\n\\n Like us on Facebook: http://fb.com/calendarlabs to get updates\nUID:5e52949a2SDD8d231582470298@calendarlabs.com\nDTSTAMP:20200223T150458Z\nSTATUS:CONFIRMED\nTRANSP:TRANSPARENT\nSEQUENCE:0\nEND:VEVENT\nBEGIN:VEVENT\nSUMMARY:Start of Month 4\nDTSTART:20190401\nDTEND:20190401\nRRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1\nLOCATION:United States\nDESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \\n\\n Like us on Facebook: http://fb.com/calendarlabs to get updates\nUID:5e52949a2SDD8d231582FDSFD470298@calendarlabs.com\nDTSTAMP:20200223T150458Z\nSTATUS:CONFIRMED\nTRANSP:TRANSPARENT\nSEQUENCE:0\nEND:VEVENT\nBEGIN:VEVENT\nSUMMARY:Start of Month 5\nDTSTART:20190501\nDTEND:20190501\nRRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1\nLOCATION:United States\nDESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \\n\\n Like us on Facebook: http://fb.com/calendarlabs to get updates\nUID:5e52949a2SDD8d2DD315824702598@calendarlabs.com\nDTSTAMP:20200223T150458Z\nSTATUS:CONFIRMED\nTRANSP:TRANSPARENT\nSEQUENCE:0\nEND:VEVENT\nBEGIN:VEVENT\nSUMMARY:Start of Month 6\nDTSTART:20190601\nDTEND:20190601\nRRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1\nLOCATION:United States\nDESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \\n\\n Like us on Facebook: http://fb.com/calendarlabs to get updates\nUID:5e52949a2SDD8d2DD31582470298@calendarlabs.com\nDTSTAMP:20200223T150458Z\nSTATUS:CONFIRMED\nTRANSP:TRANSPARENT\nSEQUENCE:0\nEND:VEVENT\nBEGIN:VEVENT\nSUMMARY:Start of Month 7\nDTSTART:20190701\nDTEND:20190701\nRRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1\nLOCATION:United States\nDESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \\n\\n Like us on Facebook: http://fb.com/calendarlabs to get updates\nUID:5e52942SDD8d2DD31582470298@calendarlabs.com\nDTSTAMP:20200223T150458Z\nSTATUS:CONFIRMED\nTRANSP:TRANSPARENT\nSEQUENCE:0\nEND:VEVENT\nBEGIN:VEVENT\nSUMMARY:Start of Month 8\nDTSTART:20190801\nDTEND:20190801\nRRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1\nLOCATION:United States\nDESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \\n\\n Like us on Facebook: http://fb.com/calendarlabs to get updates\nUID:5e52949a2SDD8d2DDt31582470298@calendarlabs.com\nDTSTAMP:20200223T150458Z\nSTATUS:CONFIRMED\nTRANSP:TRANSPARENT\nSEQUENCE:0\nEND:VEVENT\nBEGIN:VEVENT\nSUMMARY:Start of Month 9\nDTSTART:20190901\nDTEND:20190901\nRRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1\nLOCATION:United States\nDESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \\n\\n Like us on Facebook: http://fb.com/calendarlabs to get updates\nUID:5e529449a2SDD8d2DDt315824702798@calendarlabs.com\nDTSTAMP:20200223T150458Z\nSTATUS:CONFIRMED\nTRANSP:TRANSPARENT\nSEQUENCE:0\nEND:VEVENT\nBEGIN:VEVENT\nSUMMARY:Start of Month 10\nDTSTART:20191001\nDTEND:20191001\nRRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1\nLOCATION:United States\nDESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \\n\\n Like us on Facebook: http://fb.com/calendarlabs to get updates\nUID:5e529449a2SDD8d2DDt31582470298@calendarlabs.com\nDTSTAMP:20200223T150458Z\nSTATUS:CONFIRMED\nTRANSP:TRANSPARENT\nSEQUENCE:0\nEND:VEVENT\nBEGIN:VEVENT\nSUMMARY:Start of Month 11\nDTSTART:20191101\nDTEND:20191101\nRRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1\nLOCATION:United States\nDESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \\n\\n Like us on Facebook: http://fb.com/calendarlabs to get updates\nUID:5e5294449a2SDD8d2DDt31582470298@calendarlabs.com\nDTSTAMP:20200223T150458Z\nSTATUS:CONFIRMED\nTRANSP:TRANSPARENT\nSEQUENCE:0\nEND:VEVENT\nBEGIN:VEVENT\nSUMMARY:Start of Month 12\nDTSTART:20191201\nDTEND:20191201\nRRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1\nLOCATION:United States\nDESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \\n\\n Like us on Facebook: http://fb.com/calendarlabs to get updates\nUID:5e5294a2SDD8d2DDt31582470298@calendarlabs.com\nDTSTAMP:20200223T150458Z\nSTATUS:CONFIRMED\nTRANSP:TRANSPARENT\nSEQUENCE:0\nEND:VEVENT\nEND:VCALENDAR\n"
  },
  {
    "path": "tests/mocks/3_move_first_allday_repeating_event.ics",
    "content": "BEGIN:VCALENDAR\nPRODID:-//Google Inc//Google Calendar 70.9054//EN\nVERSION:2.0\nCALSCALE:GREGORIAN\nMETHOD:PUBLISH\nX-WR-CALNAME:TestCal\nX-WR-TIMEZONE:Europe/Berlin\nX-WR-CALDESC:Calendar for testing purposes\nBEGIN:VEVENT\nDTSTART;VALUE=DATE:20241011\nDTEND;VALUE=DATE:20241012\nRRULE:FREQ=WEEKLY;WKST=MO;COUNT=5;BYDAY=FR\nDTSTAMP:20241009T153220Z\nUID:2m6mt1p89l2anl74915ur3hsgm@google.com\nCREATED:20241009T153058Z\nLAST-MODIFIED:20241009T153205Z\nSEQUENCE:0\nSTATUS:CONFIRMED\nSUMMARY:TestCal_AllDayRepeatingEvent\nTRANSP:TRANSPARENT\nEND:VEVENT\nBEGIN:VEVENT\nDTSTART;VALUE=DATE:20241012\nDTEND;VALUE=DATE:20241013\nDTSTAMP:20241009T153220Z\nUID:2m6mt1p89l2anl74915ur3hsgm@google.com\nRECURRENCE-ID;VALUE=DATE:20241011\nCREATED:20241009T153058Z\nLAST-MODIFIED:20241009T153205Z\nSEQUENCE:1\nSTATUS:CONFIRMED\nSUMMARY:TestCal_AllDayRepeatingEvent\nTRANSP:TRANSPARENT\nEND:VEVENT\nEND:VCALENDAR\n"
  },
  {
    "path": "tests/mocks/RepeatingEvent.Oct21.ics",
    "content": "BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART;TZID=Europe/Berlin:20241028T000000\nDTEND;TZID=Europe/Berlin:20241028T010000\nRRULE:FREQ=DAILY;COUNT=3\nDTSTAMP:20241020T093758Z\nUID:053fdshnnibo92lu97rsoeqoti@google.com\nCREATED:20241020T093230Z\nLAST-MODIFIED:20241020T093230Z\nSEQUENCE:0\nSTATUS:CONFIRMED\nSUMMARY:RepeatingEventWeekAfterToday\nTRANSP:OPAQUE\nEND:VEVENT\nBEGIN:VEVENT\nDTSTART;TZID=Europe/Berlin:20241021T000000\nDTEND;TZID=Europe/Berlin:20241021T010000\nRRULE:FREQ=DAILY;COUNT=3\nDTSTAMP:20241020T093758Z\nUID:1a6kk47pp61k4td2h9rlf0lv69@google.com\nCREATED:20241020T093255Z\nLAST-MODIFIED:20241020T093437Z\nSEQUENCE:1\nSTATUS:CONFIRMED\nSUMMARY:RepeatingEventDayAfterToday\nTRANSP:OPAQUE\nEND:VEVENT\nEND:VCALENDAR\n"
  },
  {
    "path": "tests/mocks/bad_rrule.ics",
    "content": "BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTAMP:20210413T203456Z\nUID:E689AEB8C02C4E2CADD8C7D3D303CEAD0\nDTSTART;TZID=\"Amsterdam, Belgrade, Berlin, Brussels, Budapest, Madrid, Paris, Prague, Stockholm\":20210415T190000\nDTEND;TZID=\"Amsterdam, Belgrade, Berlin, Brussels, Budapest, Madrid, Paris, Prague, Stockholm\":20210415T210000\nCLASS:PUBLIC\nLOCATION:albert heijn\nSUMMARY:xxx xxxx\nSEQUENCE:10\nRRULE:FREQ=DAILY;UNTIL=20210418T170000Z\nEXDATE;TZID=\"Amsterdam, Belgrade, Berlin, Brussels, Budapest, Madrid, Paris, Prague, Stockholm\":20210417T190000\nEXDATE;TZID=\"Amsterdam, Belgrade, Berlin, Brussels, Budapest, Madrid, Paris, Prague, Stockholm\":20210416T190000\nEXDATE;TZID=\"Amsterdam, Belgrade, Berlin, Brussels, Budapest, Madrid, Paris, Prague, Stockholm\":20210415T190000\nBEGIN:VALARM\nACTION:DISPLAY\nTRIGGER;RELATED=START:-PT15M\nEND:VALARM\nEND:VEVENT\nEND:VCALENDAR"
  },
  {
    "path": "tests/mocks/calendar_duplicates_1.ics",
    "content": "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//MagicMirror//Test Calendar//EN\nCALSCALE:GREGORIAN\nMETHOD:PUBLISH\nX-WR-CALNAME:Duplicates Test Calendar 1\nBEGIN:VEVENT\nUID:duplicate-event-1@magicmirror.test\nDTSTART:20240916T100000Z\nDTEND:20240916T110000Z\nDTSTAMP:20240915T000000Z\nSUMMARY:Duplicate Event 1\nSTATUS:CONFIRMED\nEND:VEVENT\nBEGIN:VEVENT\nUID:duplicate-event-2@magicmirror.test\nDTSTART:20240917T140000Z\nDTEND:20240917T150000Z\nDTSTAMP:20240915T000000Z\nSUMMARY:Duplicate Event 2\nSTATUS:CONFIRMED\nEND:VEVENT\nBEGIN:VEVENT\nUID:duplicate-event-3@magicmirror.test\nDTSTART:20240918T080000Z\nDTEND:20240918T090000Z\nDTSTAMP:20240915T000000Z\nSUMMARY:Duplicate Event 3\nSTATUS:CONFIRMED\nEND:VEVENT\nBEGIN:VEVENT\nUID:duplicate-event-4@magicmirror.test\nDTSTART:20240919T120000Z\nDTEND:20240919T130000Z\nDTSTAMP:20240915T000000Z\nSUMMARY:Duplicate Event 4\nSTATUS:CONFIRMED\nEND:VEVENT\nBEGIN:VEVENT\nUID:duplicate-event-5@magicmirror.test\nDTSTART:20240920T160000Z\nDTEND:20240920T170000Z\nDTSTAMP:20240915T000000Z\nSUMMARY:Duplicate Event 5\nSTATUS:CONFIRMED\nEND:VEVENT\nBEGIN:VEVENT\nUID:duplicate-event-6@magicmirror.test\nDTSTART:20240921T100000Z\nDTEND:20240921T110000Z\nDTSTAMP:20240915T000000Z\nSUMMARY:Duplicate Event 6\nSTATUS:CONFIRMED\nEND:VEVENT\nBEGIN:VEVENT\nUID:duplicate-event-7@magicmirror.test\nDTSTART:20240922T140000Z\nDTEND:20240922T150000Z\nDTSTAMP:20240915T000000Z\nSUMMARY:Duplicate Event 7\nSTATUS:CONFIRMED\nEND:VEVENT\nBEGIN:VEVENT\nUID:duplicate-event-8@magicmirror.test\nDTSTART:20240923T080000Z\nDTEND:20240923T090000Z\nDTSTAMP:20240915T000000Z\nSUMMARY:Duplicate Event 8\nSTATUS:CONFIRMED\nEND:VEVENT\nBEGIN:VEVENT\nUID:duplicate-event-9@magicmirror.test\nDTSTART:20240924T120000Z\nDTEND:20240924T130000Z\nDTSTAMP:20240915T000000Z\nSUMMARY:Duplicate Event 9\nSTATUS:CONFIRMED\nEND:VEVENT\nBEGIN:VEVENT\nUID:duplicate-event-10@magicmirror.test\nDTSTART:20240925T160000Z\nDTEND:20240925T170000Z\nDTSTAMP:20240915T000000Z\nSUMMARY:Duplicate Event 10\nSTATUS:CONFIRMED\nEND:VEVENT\nEND:VCALENDAR\n"
  },
  {
    "path": "tests/mocks/calendar_duplicates_2.ics",
    "content": "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//MagicMirror//Test Calendar//EN\nCALSCALE:GREGORIAN\nMETHOD:PUBLISH\nX-WR-CALNAME:Duplicates Test Calendar 2\nBEGIN:VEVENT\nUID:duplicate-event-1-clone@magicmirror.test\nDTSTART:20240916T100000Z\nDTEND:20240916T110000Z\nDTSTAMP:20240915T000000Z\nSUMMARY:Duplicate Event 1\nSTATUS:CONFIRMED\nEND:VEVENT\nBEGIN:VEVENT\nUID:duplicate-event-2-clone@magicmirror.test\nDTSTART:20240917T140000Z\nDTEND:20240917T150000Z\nDTSTAMP:20240915T000000Z\nSUMMARY:Duplicate Event 2\nSTATUS:CONFIRMED\nEND:VEVENT\nBEGIN:VEVENT\nUID:duplicate-event-3-clone@magicmirror.test\nDTSTART:20240918T080000Z\nDTEND:20240918T090000Z\nDTSTAMP:20240915T000000Z\nSUMMARY:Duplicate Event 3\nSTATUS:CONFIRMED\nEND:VEVENT\nBEGIN:VEVENT\nUID:duplicate-event-4-clone@magicmirror.test\nDTSTART:20240919T120000Z\nDTEND:20240919T130000Z\nDTSTAMP:20240915T000000Z\nSUMMARY:Duplicate Event 4\nSTATUS:CONFIRMED\nEND:VEVENT\nBEGIN:VEVENT\nUID:duplicate-event-5-clone@magicmirror.test\nDTSTART:20240920T160000Z\nDTEND:20240920T170000Z\nDTSTAMP:20240915T000000Z\nSUMMARY:Duplicate Event 5\nSTATUS:CONFIRMED\nEND:VEVENT\nBEGIN:VEVENT\nUID:duplicate-event-6-clone@magicmirror.test\nDTSTART:20240921T100000Z\nDTEND:20240921T110000Z\nDTSTAMP:20240915T000000Z\nSUMMARY:Duplicate Event 6\nSTATUS:CONFIRMED\nEND:VEVENT\nBEGIN:VEVENT\nUID:duplicate-event-7-clone@magicmirror.test\nDTSTART:20240922T140000Z\nDTEND:20240922T150000Z\nDTSTAMP:20240915T000000Z\nSUMMARY:Duplicate Event 7\nSTATUS:CONFIRMED\nEND:VEVENT\nBEGIN:VEVENT\nUID:duplicate-event-8-clone@magicmirror.test\nDTSTART:20240923T080000Z\nDTEND:20240923T090000Z\nDTSTAMP:20240915T000000Z\nSUMMARY:Duplicate Event 8\nSTATUS:CONFIRMED\nEND:VEVENT\nBEGIN:VEVENT\nUID:duplicate-event-9-clone@magicmirror.test\nDTSTART:20240924T120000Z\nDTEND:20240924T130000Z\nDTSTAMP:20240915T000000Z\nSUMMARY:Duplicate Event 9\nSTATUS:CONFIRMED\nEND:VEVENT\nBEGIN:VEVENT\nUID:duplicate-event-10-clone@magicmirror.test\nDTSTART:20240925T160000Z\nDTEND:20240925T170000Z\nDTSTAMP:20240915T000000Z\nSUMMARY:Duplicate Event 10\nSTATUS:CONFIRMED\nEND:VEVENT\nEND:VCALENDAR\n"
  },
  {
    "path": "tests/mocks/calendar_test.ics",
    "content": "BEGIN:VCALENDAR\r\nPRODID:-//Google Inc//Google Calendar 70.9054//EN\r\nVERSION:2.0\r\nCALSCALE:GREGORIAN\r\nMETHOD:PUBLISH\r\nX-WR-CALNAME:MagicMirrorTest\r\nX-WR-TIMEZONE:America/Santiago\r\nX-WR-CALDESC:Testing propose MagicMirror\r\nBEGIN:VTIMEZONE\r\nTZID:America/Santiago\r\nX-LIC-LOCATION:America/Santiago\r\nBEGIN:STANDARD\r\nTZOFFSETFROM:-0300\r\nTZOFFSETTO:-0400\r\nTZNAME:-04\r\nDTSTART:19700510T000000\r\nRDATE:19700510T030000\r\nRDATE:19710509T030000\r\nRDATE:19720514T030000\r\nRDATE:19730513T030000\r\nRDATE:19740512T030000\r\nRDATE:19750511T030000\r\nRDATE:19760509T030000\r\nRDATE:19770515T030000\r\nRDATE:19780514T030000\r\nRDATE:19790513T030000\r\nRDATE:19800511T030000\r\nRDATE:19810510T030000\r\nRDATE:19820509T030000\r\nRDATE:19830515T030000\r\nRDATE:19840513T030000\r\nRDATE:19850512T030000\r\nRDATE:19860511T030000\r\nRDATE:19870510T030000\r\nRDATE:19880515T030000\r\nRDATE:19890514T030000\r\nRDATE:19900513T030000\r\nRDATE:19910512T030000\r\nRDATE:19920510T030000\r\nRDATE:19930509T030000\r\nRDATE:19940515T030000\r\nRDATE:19950514T030000\r\nRDATE:19960512T030000\r\nRDATE:19970511T030000\r\nRDATE:19980510T030000\r\nRDATE:19990509T030000\r\nRDATE:20000514T030000\r\nRDATE:20010513T030000\r\nRDATE:20020512T030000\r\nRDATE:20030511T030000\r\nRDATE:20040509T030000\r\nRDATE:20050515T030000\r\nRDATE:20060514T030000\r\nRDATE:20070513T030000\r\nRDATE:20080511T030000\r\nRDATE:20090510T030000\r\nRDATE:20100509T030000\r\nRDATE:20110515T030000\r\nRDATE:20120513T030000\r\nRDATE:20130512T030000\r\nRDATE:20140511T030000\r\nRDATE:20150510T030000\r\nRDATE:20160515T030000\r\nRDATE:20170514T030000\r\nRDATE:20180513T030000\r\nRDATE:20190512T030000\r\nRDATE:20200510T030000\r\nRDATE:20210509T030000\r\nRDATE:20220515T030000\r\nRDATE:20230514T030000\r\nRDATE:20240512T030000\r\nRDATE:20250511T030000\r\nRDATE:20260510T030000\r\nRDATE:20270509T030000\r\nRDATE:20280514T030000\r\nRDATE:20290513T030000\r\nRDATE:20300512T030000\r\nRDATE:20310511T030000\r\nRDATE:20320509T030000\r\nRDATE:20330515T030000\r\nRDATE:20340514T030000\r\nRDATE:20350513T030000\r\nRDATE:20360511T030000\r\nRDATE:20370510T030000\r\nEND:STANDARD\r\nBEGIN:STANDARD\r\nTZOFFSETFROM:-0300\r\nTZOFFSETTO:-0400\r\nTZNAME:-04\r\nDTSTART:20380509T000000\r\nRRULE:FREQ=YEARLY;BYMONTH=5;BYDAY=2SU\r\nEND:STANDARD\r\nBEGIN:DAYLIGHT\r\nTZOFFSETFROM:-0400\r\nTZOFFSETTO:-0300\r\nTZNAME:-03\r\nDTSTART:19700809T000000\r\nRDATE:19700809T040000\r\nRDATE:19710815T040000\r\nRDATE:19720813T040000\r\nRDATE:19730812T040000\r\nRDATE:19740811T040000\r\nRDATE:19750810T040000\r\nRDATE:19760815T040000\r\nRDATE:19770814T040000\r\nRDATE:19780813T040000\r\nRDATE:19790812T040000\r\nRDATE:19800810T040000\r\nRDATE:19810809T040000\r\nRDATE:19820815T040000\r\nRDATE:19830814T040000\r\nRDATE:19840812T040000\r\nRDATE:19850811T040000\r\nRDATE:19860810T040000\r\nRDATE:19870809T040000\r\nRDATE:19880814T040000\r\nRDATE:19890813T040000\r\nRDATE:19900812T040000\r\nRDATE:19910811T040000\r\nRDATE:19920809T040000\r\nRDATE:19930815T040000\r\nRDATE:19940814T040000\r\nRDATE:19950813T040000\r\nRDATE:19960811T040000\r\nRDATE:19970810T040000\r\nRDATE:19980809T040000\r\nRDATE:19990815T040000\r\nRDATE:20000813T040000\r\nRDATE:20010812T040000\r\nRDATE:20020811T040000\r\nRDATE:20030810T040000\r\nRDATE:20040815T040000\r\nRDATE:20050814T040000\r\nRDATE:20060813T040000\r\nRDATE:20070812T040000\r\nRDATE:20080810T040000\r\nRDATE:20090809T040000\r\nRDATE:20100815T040000\r\nRDATE:20110814T040000\r\nRDATE:20120812T040000\r\nRDATE:20130811T040000\r\nRDATE:20140810T040000\r\nRDATE:20150809T040000\r\nRDATE:20160814T040000\r\nRDATE:20170813T040000\r\nRDATE:20180812T040000\r\nRDATE:20190811T040000\r\nRDATE:20200809T040000\r\nRDATE:20210815T040000\r\nRDATE:20220814T040000\r\nRDATE:20230813T040000\r\nRDATE:20240811T040000\r\nRDATE:20250810T040000\r\nRDATE:20260809T040000\r\nRDATE:20270815T040000\r\nRDATE:20280813T040000\r\nRDATE:20290812T040000\r\nRDATE:20300811T040000\r\nRDATE:20310810T040000\r\nRDATE:20320815T040000\r\nRDATE:20330814T040000\r\nRDATE:20340813T040000\r\nRDATE:20350812T040000\r\nRDATE:20360810T040000\r\nRDATE:20370809T040000\r\nEND:DAYLIGHT\r\nBEGIN:DAYLIGHT\r\nTZOFFSETFROM:-0400\r\nTZOFFSETTO:-0300\r\nTZNAME:-03\r\nDTSTART:20380815T000000\r\nRRULE:FREQ=YEARLY;BYMONTH=8;BYDAY=2SU\r\nEND:DAYLIGHT\r\nEND:VTIMEZONE\r\nBEGIN:VEVENT\r\nDTSTART;TZID=America/Santiago:20170309T100000\r\nDTEND;TZID=America/Santiago:20170309T110000\r\nRRULE:FREQ=MONTHLY;INTERVAL=30;BYMONTHDAY=9\r\nDTSTAMP:20170310T172720Z\r\nUID:80rl9kuu5bq49gme99eklov27k@google.com\r\nCREATED:20170310T172400Z\r\nDESCRIPTION:\r\nLAST-MODIFIED:20170310T172400Z\r\nLOCATION:\r\nSEQUENCE:0\r\nSTATUS:CONFIRMED\r\nSUMMARY:TestEvent\r\nTRANSP:OPAQUE\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
  },
  {
    "path": "tests/mocks/calendar_test_clone.ics",
    "content": "BEGIN:VCALENDAR\nPRODID:-//Google Inc//Google Calendar 70.9054//EN\nVERSION:2.0\nCALSCALE:GREGORIAN\nMETHOD:PUBLISH\nX-WR-CALNAME:MagicMirrorTest\nX-WR-TIMEZONE:America/Santiago\nX-WR-CALDESC:Testing propose MagicMirror\nBEGIN:VTIMEZONE\nTZID:America/Santiago\nX-LIC-LOCATION:America/Santiago\nBEGIN:STANDARD\nTZOFFSETFROM:-0300\nTZOFFSETTO:-0400\nTZNAME:-04\nDTSTART:19700510T000000\nRDATE:19700510T030000\nRDATE:19710509T030000\nRDATE:19720514T030000\nRDATE:19730513T030000\nRDATE:19740512T030000\nRDATE:19750511T030000\nRDATE:19760509T030000\nRDATE:19770515T030000\nRDATE:19780514T030000\nRDATE:19790513T030000\nRDATE:19800511T030000\nRDATE:19810510T030000\nRDATE:19820509T030000\nRDATE:19830515T030000\nRDATE:19840513T030000\nRDATE:19850512T030000\nRDATE:19860511T030000\nRDATE:19870510T030000\nRDATE:19880515T030000\nRDATE:19890514T030000\nRDATE:19900513T030000\nRDATE:19910512T030000\nRDATE:19920510T030000\nRDATE:19930509T030000\nRDATE:19940515T030000\nRDATE:19950514T030000\nRDATE:19960512T030000\nRDATE:19970511T030000\nRDATE:19980510T030000\nRDATE:19990509T030000\nRDATE:20000514T030000\nRDATE:20010513T030000\nRDATE:20020512T030000\nRDATE:20030511T030000\nRDATE:20040509T030000\nRDATE:20050515T030000\nRDATE:20060514T030000\nRDATE:20070513T030000\nRDATE:20080511T030000\nRDATE:20090510T030000\nRDATE:20100509T030000\nRDATE:20110515T030000\nRDATE:20120513T030000\nRDATE:20130512T030000\nRDATE:20140511T030000\nRDATE:20150510T030000\nRDATE:20160515T030000\nRDATE:20170514T030000\nRDATE:20180513T030000\nRDATE:20190512T030000\nRDATE:20200510T030000\nRDATE:20210509T030000\nRDATE:20220515T030000\nRDATE:20230514T030000\nRDATE:20240512T030000\nRDATE:20250511T030000\nRDATE:20260510T030000\nRDATE:20270509T030000\nRDATE:20280514T030000\nRDATE:20290513T030000\nRDATE:20300512T030000\nRDATE:20310511T030000\nRDATE:20320509T030000\nRDATE:20330515T030000\nRDATE:20340514T030000\nRDATE:20350513T030000\nRDATE:20360511T030000\nRDATE:20370510T030000\nEND:STANDARD\nBEGIN:STANDARD\nTZOFFSETFROM:-0300\nTZOFFSETTO:-0400\nTZNAME:-04\nDTSTART:20380509T000000\nRRULE:FREQ=YEARLY;BYMONTH=5;BYDAY=2SU\nEND:STANDARD\nBEGIN:DAYLIGHT\nTZOFFSETFROM:-0400\nTZOFFSETTO:-0300\nTZNAME:-03\nDTSTART:19700809T000000\nRDATE:19700809T040000\nRDATE:19710815T040000\nRDATE:19720813T040000\nRDATE:19730812T040000\nRDATE:19740811T040000\nRDATE:19750810T040000\nRDATE:19760815T040000\nRDATE:19770814T040000\nRDATE:19780813T040000\nRDATE:19790812T040000\nRDATE:19800810T040000\nRDATE:19810809T040000\nRDATE:19820815T040000\nRDATE:19830814T040000\nRDATE:19840812T040000\nRDATE:19850811T040000\nRDATE:19860810T040000\nRDATE:19870809T040000\nRDATE:19880814T040000\nRDATE:19890813T040000\nRDATE:19900812T040000\nRDATE:19910811T040000\nRDATE:19920809T040000\nRDATE:19930815T040000\nRDATE:19940814T040000\nRDATE:19950813T040000\nRDATE:19960811T040000\nRDATE:19970810T040000\nRDATE:19980809T040000\nRDATE:19990815T040000\nRDATE:20000813T040000\nRDATE:20010812T040000\nRDATE:20020811T040000\nRDATE:20030810T040000\nRDATE:20040815T040000\nRDATE:20050814T040000\nRDATE:20060813T040000\nRDATE:20070812T040000\nRDATE:20080810T040000\nRDATE:20090809T040000\nRDATE:20100815T040000\nRDATE:20110814T040000\nRDATE:20120812T040000\nRDATE:20130811T040000\nRDATE:20140810T040000\nRDATE:20150809T040000\nRDATE:20160814T040000\nRDATE:20170813T040000\nRDATE:20180812T040000\nRDATE:20190811T040000\nRDATE:20200809T040000\nRDATE:20210815T040000\nRDATE:20220814T040000\nRDATE:20230813T040000\nRDATE:20240811T040000\nRDATE:20250810T040000\nRDATE:20260809T040000\nRDATE:20270815T040000\nRDATE:20280813T040000\nRDATE:20290812T040000\nRDATE:20300811T040000\nRDATE:20310810T040000\nRDATE:20320815T040000\nRDATE:20330814T040000\nRDATE:20340813T040000\nRDATE:20350812T040000\nRDATE:20360810T040000\nRDATE:20370809T040000\nEND:DAYLIGHT\nBEGIN:DAYLIGHT\nTZOFFSETFROM:-0400\nTZOFFSETTO:-0300\nTZNAME:-03\nDTSTART:20380815T000000\nRRULE:FREQ=YEARLY;BYMONTH=8;BYDAY=2SU\nEND:DAYLIGHT\nEND:VTIMEZONE\nBEGIN:VEVENT\nDTSTART;TZID=America/Santiago:20170309T100000\nDTEND;TZID=America/Santiago:20170309T110000\nRRULE:FREQ=MONTHLY;INTERVAL=30;BYMONTHDAY=9\nDTSTAMP:20170310T172720Z\nUID:80rl9kuu5bq49gme99eklov27k@google.com\nCREATED:20170310T172400Z\nDESCRIPTION:\nLAST-MODIFIED:20170310T172400Z\nLOCATION:\nSEQUENCE:0\nSTATUS:CONFIRMED\nSUMMARY:TestEvent\nTRANSP:OPAQUE\nEND:VEVENT\nEND:VCALENDAR\n"
  },
  {
    "path": "tests/mocks/calendar_test_full_day_events.ics",
    "content": "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//ical.marudot.com//iCal Event Maker\nCALSCALE:GREGORIAN\nBEGIN:VTIMEZONE\nTZID:Europe/Berlin\nLAST-MODIFIED:20231222T233358Z\nTZURL:https://www.tzurl.org/zoneinfo-outlook/Europe/Berlin\nX-LIC-LOCATION:Europe/Berlin\nBEGIN:DAYLIGHT\nTZNAME:CEST\nTZOFFSETFROM:+0100\nTZOFFSETTO:+0200\nDTSTART:19700329T020000\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\nEND:DAYLIGHT\nBEGIN:STANDARD\nTZNAME:CET\nTZOFFSETFROM:+0200\nTZOFFSETTO:+0100\nDTSTART:19701025T030000\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\nEND:STANDARD\nEND:VTIMEZONE\nBEGIN:VEVENT\nDTSTAMP:20240306T225415Z\nUID:1709765647426-75770@ical.marudot.com\nDTSTART;VALUE=DATE:20240306\nRRULE:FREQ=DAILY\nDTEND;VALUE=DATE:20240307\nSUMMARY:daily full days\nEND:VEVENT\nEND:VCALENDAR\n"
  },
  {
    "path": "tests/mocks/calendar_test_icons.ics",
    "content": "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//ical.marudot.com//iCal Event Maker\r\nX-WR-CALNAME:TestEvents\r\nNAME:TestEvents\r\nCALSCALE:GREGORIAN\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nTZURL:http://tzurl.org/zoneinfo-outlook/Europe/Berlin\r\nX-LIC-LOCATION:Europe/Berlin\r\nBEGIN:DAYLIGHT\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nTZNAME:CEST\r\nDTSTART:19700329T020000\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nTZNAME:CET\r\nDTSTART:19701025T030000\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\nBEGIN:VEVENT\r\nDTSTAMP:20200719T094531Z\r\nUID:20200719T094531Z-1871115387@marudot.com\r\nDTSTART;TZID=Europe/Berlin:20300101T120000\r\nDTEND;TZID=Europe/Berlin:20300101T130000\r\nSUMMARY:TestEvent\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nDTSTAMP:20200719T094531Z\r\nUID:20200719T094531Z-1929725136@marudot.com\r\nDTSTART;TZID=Europe/Berlin:20300701T120000\r\nRRULE:FREQ=YEARLY;BYMONTH=7;BYMONTHDAY=1\r\nDTEND;TZID=Europe/Berlin:20300701T130000\r\nSUMMARY:TestEventRepeat\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nDTSTAMP:20200719T094531Z\r\nUID:20200719T094531Z-371801474@marudot.com\r\nDTSTART;VALUE=DATE:20300401\r\nDTEND;VALUE=DATE:20300402\r\nSUMMARY:TestEventDay\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nDTSTAMP:20200719T094531Z\r\nUID:20200719T094531Z-133401084@marudot.com\r\nDTSTART;VALUE=DATE:20301001\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYMONTHDAY=1\r\nDTEND;VALUE=DATE:20301002\r\nSUMMARY:TestEventRepeatDay\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nDTSTAMP:20200721T094531Z\r\nUID:20200719T094531Z-167389794@marudot.com\r\nDTSTART;TZID=Europe/Berlin:20301112T120000\r\nDTEND;TZID=Europe/Berlin:20301112T130000\r\nSUMMARY:TestEventCustomEventIcon\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
  },
  {
    "path": "tests/mocks/calendar_test_multi_day_starting_today.ics",
    "content": "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//ical.marudot.com//iCal Event Maker\nCALSCALE:GREGORIAN\nBEGIN:VTIMEZONE\nTZID:Europe/Berlin\nLAST-MODIFIED:20231222T233358Z\nTZURL:https://www.tzurl.org/zoneinfo-outlook/Europe/Berlin\nX-LIC-LOCATION:Europe/Berlin\nBEGIN:DAYLIGHT\nTZNAME:CEST\nTZOFFSETFROM:+0100\nTZOFFSETTO:+0200\nDTSTART:19700329T020000\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\nEND:DAYLIGHT\nBEGIN:STANDARD\nTZNAME:CET\nTZOFFSETFROM:+0200\nTZOFFSETTO:+0100\nDTSTART:19701025T030000\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\nEND:STANDARD\nEND:VTIMEZONE\nBEGIN:VEVENT\nDTSTAMP:20240306T222634Z\nUID:1709763965312-82782@ical.marudot.com\nDTSTART;VALUE=DATE:20240301\nRRULE:FREQ=DAILY\nDTEND;VALUE=DATE:20240303\nSUMMARY:2 day events\nEND:VEVENT\nEND:VCALENDAR\n"
  },
  {
    "path": "tests/mocks/calendar_test_recurring.ics",
    "content": "BEGIN:VCALENDAR\nPRODID:-//Google Inc//Google Calendar 70.9054//EN\nVERSION:2.0\nCALSCALE:GREGORIAN\nMETHOD:PUBLISH\nX-WR-CALNAME:xxx@gmail.com\nX-WR-TIMEZONE:Europe/Zurich\nBEGIN:VTIMEZONE\nTZID:Etc/UTC\nX-LIC-LOCATION:Etc/UTC\nBEGIN:STANDARD\nTZOFFSETFROM:+0000\nTZOFFSETTO:+0000\nTZNAME:GMT\nDTSTART:19700101T000000\nEND:STANDARD\nEND:VTIMEZONE\nBEGIN:VEVENT\nDTSTART;VALUE=DATE:20210325\nDTEND;VALUE=DATE:20210326\nRRULE:FREQ=YEARLY;WKST=MO;INTERVAL=1\nDTSTAMP:20210421T154106Z\nUID:zzz@google.com\nREATED:20200831T200244Z\nDESCRIPTION:\nLAST-MODIFIED:20200831T200244Z\nLOCATION:\nSEQUENCE:0\nSTATUS:CONFIRMED\nSUMMARY:Birthday\nTRANSP:OPAQUE\nBEGIN:VALARM\nACTION:DISPLAY\nDESCRIPTION:This is an event reminder\nTRIGGER:-P0DT7H0M0S\nEND:VALARM\nEND:VEVENT\n"
  },
  {
    "path": "tests/mocks/chicago-nyedge.ics",
    "content": "BEGIN:VEVENT\nDTSTART;TZID=America/New_York:20240918T183000\nDTEND;TZID=America/New_York:20240918T203000\nRRULE:FREQ=WEEKLY;BYDAY=WE\nEXDATE;TZID=America/New_York:20241127T183000\nEXDATE;TZID=America/New_York:20241225T183000\nDTSTAMP:20250122T045443Z\nUID:_@google.com\nCREATED:20240916T131843Z\nLAST-MODIFIED:20241222T235014Z\nSEQUENCE:0\nSTATUS:CONFIRMED\nSUMMARY:Derby\nTRANSP:OPAQUE\nEND:VEVENT"
  },
  {
    "path": "tests/mocks/chicago_late_in_timezone.ics",
    "content": "BEGIN:VEVENT\nCREATED:20240904T053053Z\nDTEND;TZID=America/Chicago:20240910T211500\nDTSTAMP:20240925T005517Z\nDTSTART;TZID=America/Chicago:20240910T201500\nLAST-MODIFIED:20240925T005515Z\nLOCATION:Dance Class\nRELATED-TO;RELTYPE=X-CALENDARSERVER-RECURRENCE-SET:2D48CA37-FCE5-4E16-871\n9-1F47160BDBA3\nRRULE:FREQ=WEEKLY;UNTIL=20250601T011500Z\nSEQUENCE:3\nSUMMARY:Wife Barre Class\nUID:39669340-7AFD-4685-9BD6-6CE4B715486E\nX-APPLE-CREATOR-IDENTITY:com.apple.mobilecal\nEND:VEVENT"
  },
  {
    "path": "tests/mocks/compliments_file.json",
    "content": "{\n\t\"anytime\": [\"test in morning\"]\n}\n"
  },
  {
    "path": "tests/mocks/compliments_test.json",
    "content": "{\n\t\"anytime\": [\"Remote compliment file works!\"]\n}\n"
  },
  {
    "path": "tests/mocks/diff_tz_start_end.ics",
    "content": "BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20241029T100000Z\nDTEND:20241030T230000Z\nDTSTAMP:20241022T203806Z\nUID:04ivnntdi20rqsk0iesabsdhmj@google.com\nCREATED:20241022T203738Z\nLAST-MODIFIED:20241022T203738Z\nSEQUENCE:0\nSTATUS:CONFIRMED\nSUMMARY:start/end on diff tz\nTRANSP:OPAQUE\nEND:VEVENT\nEND:VCALENDAR\n"
  },
  {
    "path": "tests/mocks/end_of_day_berlin_moved.ics",
    "content": "BEGIN:VCALENDAR\nPRODID:-//Google Inc//Google Calendar 70.9054//EN\nVERSION:2.0\nCALSCALE:GREGORIAN\nMETHOD:PUBLISH\nX-WR-CALNAME:test for mirror\nX-WR-TIMEZONE:America/Chicago\nX-WR-CALDESC:used to test mirror\nBEGIN:VTIMEZONE\nTZID:Europe/Berlin\nX-LIC-LOCATION:Europe/Berlin\nBEGIN:DAYLIGHT\nTZOFFSETFROM:+0100\nTZOFFSETTO:+0200\nTZNAME:GMT+2\nDTSTART:19700329T020000\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\nEND:DAYLIGHT\nBEGIN:STANDARD\nTZOFFSETFROM:+0200\nTZOFFSETTO:+0100\nTZNAME:GMT+1\nDTSTART:19701025T030000\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\nEND:STANDARD\nEND:VTIMEZONE\nBEGIN:VEVENT\nDTSTART;TZID=Europe/Berlin:20241021T230000\nDTEND;TZID=Europe/Berlin:20241022T000000\nRRULE:FREQ=DAILY;WKST=SU;COUNT=3\nDTSTAMP:20241019T133432Z\nUID:0kj3dtvgskhhpli1392n111145@google.com\nCREATED:20241018T213040Z\nLAST-MODIFIED:20241018T213126Z\nSEQUENCE:1\nSTATUS:CONFIRMED\nSUMMARY:test\nTRANSP:OPAQUE\nEND:VEVENT\nBEGIN:VEVENT\nDTSTART;TZID=Europe/Berlin:20241024T230000\nDTEND;TZID=Europe/Berlin:20241025T000000\nDTSTAMP:20241019T133432Z\nUID:0kj3dtvgskhhpli1392n111145@google.com\nRECURRENCE-ID;TZID=Europe/Berlin:20241021T230000\nCREATED:20241018T213040Z\nLAST-MODIFIED:20241018T213126Z\nSEQUENCE:2\nSTATUS:CONFIRMED\nSUMMARY:test\nTRANSP:OPAQUE\nEND:VEVENT\nEND:VCALENDAR\n\n"
  },
  {
    "path": "tests/mocks/event_with_time_over_multiple_days_non_repeating.ics",
    "content": "BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20241026T010000Z\nDTEND:20241026T110000Z\nDTSTAMP:20241024T153358Z\nUID:4maud6s79m41a99pj2g7j5km0a@google.com\nCREATED:20241024T153313Z\nLAST-MODIFIED:20241024T153330Z\nSEQUENCE:0\nSTATUS:CONFIRMED\nSUMMARY:Sleep over at Bobs\nTRANSP:OPAQUE\nEND:VEVENT\nEND:VCALENDAR\n"
  },
  {
    "path": "tests/mocks/exdate_and_recurrence_together.ics",
    "content": "BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART;TZID=America/Los_Angeles:20241023T143000\nDTEND;TZID=America/Los_Angeles:20241023T153000\nRRULE:FREQ=DAILY;COUNT=4\nEXDATE;TZID=America/Los_Angeles:20241025T143000\nDTSTAMP:20241021T193426Z\nUID:18rd721lfqpue2o08icsqek198@google.com\nCREATED:20241021T192450Z\nDESCRIPTION:we will move one entry and delete another  ending w 3 of the 4 \n  start/end\\, middle moved after end and 3rd deleted\nLAST-MODIFIED:20241021T193419Z\nSEQUENCE:0\nSTATUS:CONFIRMED\nSUMMARY:recurrence and exclusion together\nTRANSP:OPAQUE\nEND:VEVENT\nBEGIN:VEVENT\nDTSTART;TZID=America/Los_Angeles:20241022T143000\nDTEND;TZID=America/Los_Angeles:20241022T153000\nDTSTAMP:20241021T193426Z\nUID:18rd721lfqpue2o08icsqek198@google.com\nRECURRENCE-ID;TZID=America/Los_Angeles:20241023T143000\nCREATED:20241021T192450Z\nDESCRIPTION:we will move one entry and delete another  ending w 3 of the 4 \n  start/end\\, middle moved after end and 3rd deleted\nLAST-MODIFIED:20241021T193419Z\nSEQUENCE:1\nSTATUS:CONFIRMED\nSUMMARY:recurrence and exclusion together\nTRANSP:OPAQUE\nEND:VEVENT\nBEGIN:VEVENT\nDTSTART;TZID=America/Los_Angeles:20241027T143000\nDTEND;TZID=America/Los_Angeles:20241027T153000\nDTSTAMP:20241021T193426Z\nUID:18rd721lfqpue2o08icsqek198@google.com\nRECURRENCE-ID;TZID=America/Los_Angeles:20241024T143000\nCREATED:20241021T192450Z\nDESCRIPTION:we will move one entry and delete another  ending w 3 of the 4 \n  start/end\\, middle moved after end and 3rd deleted\nLAST-MODIFIED:20241021T193419Z\nSEQUENCE:1\nSTATUS:CONFIRMED\nSUMMARY:recurrence and exclusion together\nTRANSP:OPAQUE\nEND:VEVENT\nEND:VCALENDAR\n"
  },
  {
    "path": "tests/mocks/exdate_la_at_midnight_dst.ics",
    "content": "BEGIN:VEVENT\nDTSTART;TZID=America/Los_Angeles:20231025T170000\nDTEND;TZID=America/Los_Angeles:20231025T180000\nRRULE:FREQ=WEEKLY;BYDAY=WE\nEXDATE;TZID=America/Los_Angeles:20231101T170000\nEXDATE;TZID=America/Los_Angeles:20231108T170000\nDTSTAMP:20231025T233434Z\nUID:sdflbkasuhdb5fkauglkb@google.com\nCREATED:20230306T193128Z\nLAST-MODIFIED:20231024T222515Z\nSEQUENCE:0\nSTATUS:CONFIRMED\nSUMMARY:My Event\nTRANSP:OPAQUE\nEND:VEVENT"
  },
  {
    "path": "tests/mocks/exdate_la_at_midnight_std.ics",
    "content": "BEGIN:VEVENT\nDTSTART;TZID=America/Los_Angeles:20231025T160000\nDTEND;TZID=America/Los_Angeles:20231025T170000\nRRULE:FREQ=WEEKLY;BYDAY=WE\nEXDATE;TZID=America/Los_Angeles:20231101T160000\nEXDATE;TZID=America/Los_Angeles:20231108T160000\nDTSTAMP:20231025T233434Z\nUID:sdflbkasuhdb5fkauglkb@google.com\nCREATED:20230306T193128Z\nLAST-MODIFIED:20231024T222515Z\nSEQUENCE:0\nSTATUS:CONFIRMED\nSUMMARY:My Event\nTRANSP:OPAQUE\nEND:VEVENT"
  },
  {
    "path": "tests/mocks/exdate_la_before_midnight.ics",
    "content": "BEGIN:VEVENT\nDTSTART;TZID=America/Los_Angeles:20231025T150000\nDTEND;TZID=America/Los_Angeles:20231025T160000\nRRULE:FREQ=WEEKLY;BYDAY=WE\nEXDATE;TZID=America/Los_Angeles:20231101T150000\nEXDATE;TZID=America/Los_Angeles:20231108T150000\nDTSTAMP:20231025T233434Z\nUID:sdflbkasuhdb5fkauglkb@google.com\nCREATED:20230306T193128Z\nLAST-MODIFIED:20231024T222515Z\nSEQUENCE:0\nSTATUS:CONFIRMED\nSUMMARY:My Event\nTRANSP:OPAQUE\nEND:VEVENT"
  },
  {
    "path": "tests/mocks/exdate_syd_at_midnight_dst.ics",
    "content": "BEGIN:VEVENT\nDTSTART;TZID=Australia/Sydney:20230920T110000\nDTEND;TZID=Australia/Sydney:20230920T111000\nRRULE:FREQ=WEEKLY;BYDAY=WE\nEXDATE;TZID=Australia/Sydney:20230927T110000\nEXDATE;TZID=Australia/Sydney:20231004T110000\nDTSTAMP:20231025T233434Z\nUID:sdflbkasuhdb5fkauglkb@google.com\nCREATED:20230306T193128Z\nLAST-MODIFIED:20231024T222515Z\nSEQUENCE:0\nSTATUS:CONFIRMED\nSUMMARY:My Event\nTRANSP:OPAQUE\nEND:VEVENT"
  },
  {
    "path": "tests/mocks/exdate_syd_at_midnight_std.ics",
    "content": "BEGIN:VEVENT\nDTSTART;TZID=Australia/Sydney:20230920T100000\nDTEND;TZID=Australia/Sydney:20230920T110000\nRRULE:FREQ=WEEKLY;BYDAY=WE\nEXDATE;TZID=Australia/Sydney:20230927T100000\nEXDATE;TZID=Australia/Sydney:20231004T100000\nDTSTAMP:20231025T233434Z\nUID:sdflbkasuhdb5fkauglkb@google.com\nCREATED:20230306T193128Z\nLAST-MODIFIED:20231024T222515Z\nSEQUENCE:0\nSTATUS:CONFIRMED\nSUMMARY:My Event\nTRANSP:OPAQUE\nEND:VEVENT"
  },
  {
    "path": "tests/mocks/exdate_syd_before_midnight.ics",
    "content": "BEGIN:VEVENT\nDTSTART;TZID=Australia/Sydney:20230920T090000\nDTEND;TZID=Australia/Sydney:20230920T100000\nRRULE:FREQ=WEEKLY;BYDAY=WE\nEXDATE;TZID=Australia/Sydney:20230927T090000\nEXDATE;TZID=Australia/Sydney:20231004T090000\nDTSTAMP:20231025T233434Z\nUID:sdflbkasuhdb5fkauglkb@google.com\nCREATED:20230306T193128Z\nLAST-MODIFIED:20231024T222515Z\nSEQUENCE:0\nSTATUS:CONFIRMED\nSUMMARY:My Event\nTRANSP:OPAQUE\nEND:VEVENT"
  },
  {
    "path": "tests/mocks/fullday_event_over_multiple_days_nonrepeating.ics",
    "content": "BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART;VALUE=DATE:20241025\nDTEND;VALUE=DATE:20241031\nDTSTAMP:20241023T141110Z\nUID:60nobfcu0ct96jgsh5nhcia24b@google.com\nCREATED:20241023T141019Z\nDESCRIPTION:test for all day end viewing\nLAST-MODIFIED:20241023T141019Z\nSEQUENCE:0\nSTATUS:CONFIRMED\nSUMMARY:simple all day event over many days (not repeating)\nTRANSP:TRANSPARENT\nEND:VEVENT\nEND:VCALENDAR\n"
  },
  {
    "path": "tests/mocks/fullday_until.ics",
    "content": "BEGIN:VCALENDAR\nBEGIN:VEVENT\nDESCRIPTION:\\n\nRRULE:FREQ=YEARLY;UNTIL=20250505T230000Z;INTERVAL=1;BYMONTHDAY=5;BYMONTH=5\nUID:040000008200E00074C5B7101A82E00800000000DAEF6ED30D9FDA01000000000000000\n 010000000D37F812F0777844A93E97B96AD2D278B\nSUMMARY:Person A's Birthday\nDTSTART;VALUE=DATE:20250505\nDTEND;VALUE=DATE:20250506\nCLASS:PUBLIC\nPRIORITY:5\nDTSTAMP:20250428T133000Z\nTRANSP:TRANSPARENT\nSTATUS:CONFIRMED\nSEQUENCE:0\nLOCATION:\nX-MICROSOFT-CDO-APPT-SEQUENCE:0\nX-MICROSOFT-CDO-BUSYSTATUS:FREE\nX-MICROSOFT-CDO-INTENDEDSTATUS:BUSY\nX-MICROSOFT-CDO-ALLDAYEVENT:TRUE\nX-MICROSOFT-CDO-IMPORTANCE:1\nX-MICROSOFT-CDO-INSTTYPE:1\nX-MICROSOFT-DONOTFORWARDMEETING:FALSE\nX-MICROSOFT-DISALLOW-COUNTER:FALSE\nX-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT\nX-MICROSOFT-ISRESPONSEREQUESTED:FALSE\nEND:VEVENT\nEND:VCALENDAR\n"
  },
  {
    "path": "tests/mocks/germany_at_end_of_day_repeating.ics",
    "content": "BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART;TZID=Europe/Berlin:20241022T230000\nDTEND;TZID=Europe/Berlin:20241023T000000\nRRULE:FREQ=DAILY;WKST=MO;COUNT=4\nDTSTAMP:20241009T153220Z\nUID:2m6mt1p89l2anl74915ur3hsgm@google.com\nCREATED:20241009T153058Z\nLAST-MODIFIED:20241009T153205Z\nSEQUENCE:0\nSTATUS:CONFIRMED\nSUMMARY:TestCal_AllDayRepeatingEvent\nTRANSP:TRANSPARENT\nEND:VEVENT\nEND:VCALENDAR"
  },
  {
    "path": "tests/mocks/newsfeed_test.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rss version=\"2.0\"\n     xmlns:content=\"http://purl.org/rss/1.0/modules/content/\"\n     xmlns:wfw=\"http://wellformedweb.org/CommentAPI/\"\n     xmlns:dc=\"http://purl.org/dc/elements/1.1/\"\n     xmlns:atom=\"http://www.w3.org/2005/Atom\"\n     xmlns:sy=\"http://purl.org/rss/1.0/modules/syndication/\"\n     xmlns:slash=\"http://purl.org/rss/1.0/modules/slash/\"\n>\n  <channel>\n    <title>Rodrigo Ramírez Norambuena</title>\n    <atom:link href=\"https://rodrigoramirez.com/feed/\" rel=\"self\" type=\"application/rss+xml\"/>\n    <link>https://rodrigoramirez.com</link>\n    <description>Temas sobre Linux, VoIP, Open Source, tecnología y lo relacionado.</description>\n    <lastBuildDate>Fri, 21 Oct 2016 21:30:22 +0000</lastBuildDate>\n    <language>es-ES</language>\n    <sy:updatePeriod>hourly</sy:updatePeriod>\n    <sy:updateFrequency>1</sy:updateFrequency>\n    <generator>https://wordpress.org/?v=4.7.3</generator>\n    <item>\n      <title>QPanel 0.13.0</title>\n      <link>https://rodrigoramirez.com/qpanel-0-13-0/</link>\n      <comments>https://rodrigoramirez.com/qpanel-0-13-0/#comments</comments>\n      <pubDate>Tue, 20 Sep 2016 11:16:08 +0000</pubDate>\n      <dc:creator><![CDATA[decipher]]></dc:creator>\n      <category><![CDATA[Software]]></category>\n      <category><![CDATA[app_queue]]></category>\n      <category><![CDATA[asterisk]]></category>\n      <category><![CDATA[FreeSWITCH]]></category>\n      <category><![CDATA[qpanel]]></category>\n      <category><![CDATA[queue]]></category>\n      <category><![CDATA[spy]]></category>\n      <category><![CDATA[supervision]]></category>\n      <category><![CDATA[templates]]></category>\n      <category><![CDATA[whisper]]></category>\n\n      <guid isPermaLink=\"false\">https://rodrigoramirez.com/?p=1299</guid>\n      <description><![CDATA[<p>Ya está disponible la versión 0.13.0 de QPanel Para instalar esta nueva versión, la debes descargar de https://github.com/roramirez/qpanel/tree/0.13.0 En al README.md puedes encontrar las instrucciones para hacer que funcione en tu sistema. En esta nueva versión cuenta con los siguientes cambios: Se establece un limite para el reciclado del tiempo de conexión a la base [&#8230;]</p>\n<p>La entrada <a rel=\"nofollow\" href=\"https://rodrigoramirez.com/qpanel-0-13-0/\">QPanel 0.13.0</a> aparece primero en <a rel=\"nofollow\" href=\"https://rodrigoramirez.com\">Rodrigo Ramírez Norambuena</a>.</p>\n]]></description>\n      <content:encoded><![CDATA[<p><img class=\"aligncenter\" src=\"https://raw.githubusercontent.com/roramirez/qpanel/e55aa16bbd85b579ee82e56469526270c5afa462/samples/animation.gif\" alt=\"Panel monitor callcenter | Qpanel Monitor Colas\" width=\"685\" height=\"385\" />Ya está disponible la versión 0.13.0 de QPanel</p>\n<p>Para instalar esta nueva versión, la debes descargar de</p>\n<ul>\n<li><a href=\"https://github.com/roramirez/qpanel/tree/0.13.0\">https://github.com/roramirez/qpanel/tree/0.13.0</a></li>\n</ul>\n<p>En al README.md puedes encontrar las instrucciones para hacer que funcione en tu sistema.</p>\n<p>En esta nueva versión cuenta con los siguientes cambios:</p>\n<ul>\n<li>Se establece un limite para el reciclado del tiempo de conexión a la base de datos que contenga QueueLog. Esto evita problemas en bases de datos como MySQL que finaliza o da timeout a las conexiones.</li>\n<li>Ahora la py-asterisk va dentro del archivo requirements.txt y no como submodulo del proyecto.</li>\n<li>Se remueven la mayoría de las libs externas para Javascript y CSS para manejarlos desde ahora con <a href=\"https://bower.io/\">Bower</a>.</li>\n<li>Se incluye un script para WSGI que permite su utilización con Apache.</li>\n<li>Actualización para los idiomas Ruso y Portugues.</li>\n</ul>\n<p>Si deseas colaborar con el proyecto puedes agregar nuevas sugerencias mediante un <a href=\"https://github.com/roramirez/qpanel/issues/new?title=[Feature]\">issue</a> ó colaborar mediante <a href=\"https://github.com/roramirez/qpanel/blob/dd42cf0f534408505f57b0d387dffee2f3688711/README.md#how-to-contribute\">mediante un Pull Request.</a></p>\n<p>Ahora si necesitas <a href=\"https://boxtub.com/qpanel/\">soporte comercial para instalaciones, personalizaciones o nuevas características  lo puedes solicitar en https://boxtub.com/qpanel/</a></p>\n<p>&nbsp;</p>\n<p>La entrada <a rel=\"nofollow\" href=\"https://rodrigoramirez.com/qpanel-0-13-0/\">QPanel 0.13.0</a> aparece primero en <a rel=\"nofollow\" href=\"https://rodrigoramirez.com\">Rodrigo Ramírez Norambuena</a>.</p>\n]]></content:encoded>\n      <wfw:commentRss>https://rodrigoramirez.com/qpanel-0-13-0/feed/</wfw:commentRss>\n      <slash:comments>3</slash:comments>\n    </item>\n    <item>\n      <title>Problema VirtualBox &#8220;starting virtual machine&#8221; &#8230;</title>\n      <link>https://rodrigoramirez.com/problema-virtualbox-starting-virtual-machine/</link>\n      <comments>https://rodrigoramirez.com/problema-virtualbox-starting-virtual-machine/#respond</comments>\n      <pubDate>Sat, 10 Sep 2016 22:50:13 +0000</pubDate>\n      <dc:creator><![CDATA[decipher]]></dc:creator>\n      <category><![CDATA[Linux]]></category>\n      <category><![CDATA[no arranca]]></category>\n      <category><![CDATA[Problema]]></category>\n      <category><![CDATA[VirtualBox]]></category>\n\n      <guid isPermaLink=\"false\">https://rodrigoramirez.com/?p=1284</guid>\n      <description><![CDATA[<p>Después de una actualización de Debian, de la rama stretch/sid, tuve un problema con VirtualBox.  La versión que se actualizó fue a la virtualbox 5.1.4-dfsg-1+b1. El gran problema era que ninguna maquina virtual quería arrancar, se quedaba en un largo limbo con el mensaje &#8220;starting virtual machine&#8221;, como el de la imagen de a continuación. [&#8230;]</p>\n<p>La entrada <a rel=\"nofollow\" href=\"https://rodrigoramirez.com/problema-virtualbox-starting-virtual-machine/\">Problema VirtualBox &#8220;starting virtual machine&#8221; &#8230;</a> aparece primero en <a rel=\"nofollow\" href=\"https://rodrigoramirez.com\">Rodrigo Ramírez Norambuena</a>.</p>\n]]></description>\n      <content:encoded><![CDATA[<p>Después de una actualización de Debian, de la rama stretch/sid, tuve un problema con VirtualBox.  La versión que se actualizó fue a la virtualbox 5.1.4-dfsg-1+b1. El gran problema era que ninguna maquina virtual quería arrancar, se quedaba en un largo limbo con el mensaje &#8220;starting virtual machine&#8221;, como el de la imagen de a continuación.</p>\n<p><a href=\"https://rodrigoramirez.com/wp-content/uploads/Screenshot-at-2016-09-10-19-25-09.png\"><img class=\"aligncenter wp-image-1290 size-full\" src=\"https://rodrigoramirez.com/wp-content/uploads/Screenshot-at-2016-09-10-19-25-09.png\" alt=\"Starting virtual machine ... VirtualBox\" width=\"648\" height=\"554\" srcset=\"https://rodrigoramirez.com/wp-content/uploads/Screenshot-at-2016-09-10-19-25-09.png 648w, https://rodrigoramirez.com/wp-content/uploads/Screenshot-at-2016-09-10-19-25-09-300x256.png 300w\" sizes=\"(max-width: 648px) 100vw, 648px\" /></a></p>\n<p>Ninguna, pero ninguna maquina arrancó, se quedaban en ese mensaje. Fue de esos instantes en que sudas helado &#8230; <img src=\"https://s.w.org/images/core/emoji/2.2.1/72x72/1f609.png\" alt=\"😉\" class=\"wp-smiley\" style=\"height: 1em; max-height: 1em;\" /></p>\n<p>Con un poco de investigación fue a parar al archivo<em> ~/.VirtualBox/VBoxSVC.log </em>que indicaba</p>\n<pre>$ tail -f ~/.VirtualBox/VBoxSVC.log\n 00:08:32.932717 nspr-7 Failed to open \"/dev/vboxdrvu\", errno=13, rc=VERR_VM_DRIVER_NOT_ACCESSIBLE\n 00:08:33.555836 nspr-6 Failed to open \"/dev/vboxdrvu\", errno=13, rc=VERR_VM_DRIVER_NOT_ACCESSIBLE</pre>\n<p>&nbsp;</p>\n<p>Fui&#8230; algo de donde agarrarse. Mirando un poco mas se trataba de problemas con los permisos al vboxdrvu, mirando indicaba que tenía 0600.</p>\n<p>&nbsp;</p>\n<pre>$ ls -lh /dev/vboxdrvu\n crw------- 1 root root 10, 56 Sep 10 12:47 /dev/vboxdrvu</pre>\n<p>&nbsp;</p>\n<p>El tema es que deben estar en 0666,  le cambias los permisos y eso soluciona el problema <img src=\"https://s.w.org/images/core/emoji/2.2.1/72x72/1f642.png\" alt=\"🙂\" class=\"wp-smiley\" style=\"height: 1em; max-height: 1em;\" /></p>\n<pre>\n$ sudo chmod 0666 /dev/vboxdrvu\n$ ls -lh /dev/vboxdrvu\n crw-rw-rw- 1 root root 10, 56 Sep 10 12:47 /dev/vboxdrvu</pre>\n<p>La entrada <a rel=\"nofollow\" href=\"https://rodrigoramirez.com/problema-virtualbox-starting-virtual-machine/\">Problema VirtualBox &#8220;starting virtual machine&#8221; &#8230;</a> aparece primero en <a rel=\"nofollow\" href=\"https://rodrigoramirez.com\">Rodrigo Ramírez Norambuena</a>.</p>\n]]></content:encoded>\n      <wfw:commentRss>https://rodrigoramirez.com/problema-virtualbox-starting-virtual-machine/feed/</wfw:commentRss>\n      <slash:comments>0</slash:comments>\n    </item>\n    <item>\n      <title>Mejorando la consola interactiva de Python</title>\n      <link>https://rodrigoramirez.com/mejorando-la-consola-interactiva-python/</link>\n      <comments>https://rodrigoramirez.com/mejorando-la-consola-interactiva-python/#comments</comments>\n      <pubDate>Tue, 06 Sep 2016 04:24:43 +0000</pubDate>\n      <dc:creator><![CDATA[decipher]]></dc:creator>\n      <category><![CDATA[desarrollo]]></category>\n      <category><![CDATA[Desarrollo]]></category>\n      <category><![CDATA[Python]]></category>\n\n      <guid isPermaLink=\"false\">https://rodrigoramirez.com/?p=1247</guid>\n      <description><![CDATA[<p>Cuando estás desarrollando en Python es muy cool estar utilizando la consola interactiva para ir probando cosas antes de ponerlas dentro del archivo de código fuente. La consola de Python funciona y cumple su cometido. Solo al tipear  python  te permite entrar en modo interactivo e ir probando cosas. El punto es que a veces [&#8230;]</p>\n<p>La entrada <a rel=\"nofollow\" href=\"https://rodrigoramirez.com/mejorando-la-consola-interactiva-python/\">Mejorando la consola interactiva de Python</a> aparece primero en <a rel=\"nofollow\" href=\"https://rodrigoramirez.com\">Rodrigo Ramírez Norambuena</a>.</p>\n]]></description>\n      <content:encoded><![CDATA[<p>Cuando estás desarrollando en Python es muy <em>cool</em> estar utilizando la consola interactiva para ir probando cosas antes de ponerlas dentro del archivo de código fuente.</p>\n<p>La consola de Python funciona y cumple su cometido. Solo al tipear  <em>python  </em>te permite entrar en modo interactivo e ir probando cosas.</p>\n<p>El punto es que a veces uno necesita ir un poco más allá. Como autocomentado de código o resaltado de sintaxis, para eso tengo dos truco que utilizo generalmente.</p>\n<h2>Truco a)</h2>\n<p>Este permite añadirle algunos esteriodes a la consolta, en realidad uno, el autocompletado. Esto es de gran ayuda para ir conociendo los metodo que puede tener un objecto, funciones u operaciones.</p>\n<p>Para esto se ocupo <em>rlcompleter</em> y  <em>readline. </em></p>\n<p>&nbsp;</p>\n<p>Lo que hace que hacer luego de tipear python es agregar lo siguiente dentro de la consola interativa</p>\n<p><em>import rlcompleter, readline</em><br />\n<em>readline.parse_and_bind(&#8216;tab:complete&#8217;)</em></p>\n<p>Ya con esto te permite autocomentar código <img src=\"https://s.w.org/images/core/emoji/2.2.1/72x72/1f642.png\" alt=\"🙂\" class=\"wp-smiley\" style=\"height: 1em; max-height: 1em;\" /></p>\n<p><a href=\"https://rodrigoramirez.com/wp-content/uploads/Screenshot-at-2016-09-03-01-14-32.png\"><img class=\"aligncenter wp-image-1279 size-full\" src=\"https://rodrigoramirez.com/wp-content/uploads/Screenshot-at-2016-09-03-01-14-32.png\" width=\"689\" height=\"421\" srcset=\"https://rodrigoramirez.com/wp-content/uploads/Screenshot-at-2016-09-03-01-14-32.png 689w, https://rodrigoramirez.com/wp-content/uploads/Screenshot-at-2016-09-03-01-14-32-300x183.png 300w\" sizes=\"(max-width: 689px) 100vw, 689px\" /></a></p>\n<p>&nbsp;</p>\n<h2>Truco b)</h2>\n<p>Esto es mejorar un poco más. Es utilizar embed de <a href=\"https://ipython.org/\">IPython,</a>  ya en la consola digita (copias o pegas) lo siguiente</p>\n<p><em>from IPython import embed</em><br />\n<em>embed()</em></p>\n<p>Y el resultado será lo que se ve a continuación&#8230; bueno, no?</p>\n<p>&nbsp;</p>\n<p><a href=\"https://rodrigoramirez.com/wp-content/uploads/Screenshot-at-2016-07-25-15-48-39.png\"><img class=\"aligncenter wp-image-1262 size-full\" src=\"https://rodrigoramirez.com/wp-content/uploads/Screenshot-at-2016-07-25-15-48-39.png\" width=\"743\" height=\"293\" srcset=\"https://rodrigoramirez.com/wp-content/uploads/Screenshot-at-2016-07-25-15-48-39.png 743w, https://rodrigoramirez.com/wp-content/uploads/Screenshot-at-2016-07-25-15-48-39-300x118.png 300w\" sizes=\"(max-width: 743px) 100vw, 743px\" /></a></p>\n<p>&nbsp;</p>\n<p>Si no quieres estar escribiendo cada vez que entras, agregas estas instrucciones en tu archivo  <em>~/.pythonrc.py </em> y lo hará cada vez que entras en el modo interactivo de la consola de Python. Lo que si, tu archivo pythonrc.py debe estar seteado en variable de entorno PYTHONSTARTUP</p>\n<p>ejemplo</p>\n<p><em>export  PYTHONSTARTUP=~/.pythonrc.py</em></p>\n<p>O lo agregas a un bashrc, zshrc o la shell que ocupes.</p>\n<p>La entrada <a rel=\"nofollow\" href=\"https://rodrigoramirez.com/mejorando-la-consola-interactiva-python/\">Mejorando la consola interactiva de Python</a> aparece primero en <a rel=\"nofollow\" href=\"https://rodrigoramirez.com\">Rodrigo Ramírez Norambuena</a>.</p>\n]]></content:encoded>\n      <wfw:commentRss>https://rodrigoramirez.com/mejorando-la-consola-interactiva-python/feed/</wfw:commentRss>\n      <slash:comments>4</slash:comments>\n    </item>\n    <item>\n      <title>QPanel 0.12.0 con estadísticas</title>\n      <link>https://rodrigoramirez.com/qpanel-0-12-0-estadisticas/</link>\n      <comments>https://rodrigoramirez.com/qpanel-0-12-0-estadisticas/#respond</comments>\n      <pubDate>Mon, 22 Aug 2016 04:19:03 +0000</pubDate>\n      <dc:creator><![CDATA[decipher]]></dc:creator>\n      <category><![CDATA[Software]]></category>\n      <category><![CDATA[app_queue]]></category>\n      <category><![CDATA[asterisk]]></category>\n      <category><![CDATA[FreeSWITCH]]></category>\n      <category><![CDATA[qpanel]]></category>\n      <category><![CDATA[queue]]></category>\n      <category><![CDATA[spy]]></category>\n      <category><![CDATA[supervision]]></category>\n      <category><![CDATA[templates]]></category>\n      <category><![CDATA[whisper]]></category>\n\n      <guid isPermaLink=\"false\">https://rodrigoramirez.com/?p=1268</guid>\n      <description><![CDATA[<p>Ya está disponible una nueva versión de QPanel, esta es la 0.12.0 Para instalar esta nueva versión, debes visitar la siguiente URL https://github.com/roramirez/qpanel/tree/0.12.0 En esta nueva versión las funcionalidades agregadas son: Permite remover los agentes de las cola Posibilidad de cancelar llamadas que están en espera de atención Estadísticas por rango de fecha obtenidas desde [&#8230;]</p>\n<p>La entrada <a rel=\"nofollow\" href=\"https://rodrigoramirez.com/qpanel-0-12-0-estadisticas/\">QPanel 0.12.0 con estadísticas</a> aparece primero en <a rel=\"nofollow\" href=\"https://rodrigoramirez.com\">Rodrigo Ramírez Norambuena</a>.</p>\n]]></description>\n      <content:encoded><![CDATA[<p><img class=\"aligncenter\" src=\"https://raw.githubusercontent.com/roramirez/qpanel/e55aa16bbd85b579ee82e56469526270c5afa462/samples/animation.gif\" alt=\"Panel monitor callcenter | Qpanel Monitor Colas\" width=\"685\" height=\"385\" />Ya está disponible una nueva versión de QPanel, esta es la 0.12.0</p>\n<p>Para instalar esta nueva versión, debes visitar la siguiente URL</p>\n<ul>\n<li><a href=\"https://github.com/roramirez/qpanel/tree/0.12.0\">https://github.com/roramirez/qpanel/tree/0.12.0</a></li>\n</ul>\n<p>En esta nueva versión las funcionalidades agregadas son:</p>\n<ul>\n<li>Permite remover los agentes de las cola</li>\n<li>Posibilidad de cancelar llamadas que están en espera de atención</li>\n<li>Estadísticas por rango de fecha obtenidas desde el queue_log de Asterisk</li>\n<li>Se actualiza a Flask 0.11</li>\n</ul>\n<p>Si deseas colaborar con el proyecto puedes agregar nuevas sugerencias mediante un <a href=\"https://github.com/roramirez/qpanel/issues/new?title=[Feature]\">issue</a> ó colaborar mediante <a href=\"https://github.com/roramirez/qpanel/blob/dd42cf0f534408505f57b0d387dffee2f3688711/README.md#how-to-contribute\">mediante un Pull Request</a></p>\n<p>La entrada <a rel=\"nofollow\" href=\"https://rodrigoramirez.com/qpanel-0-12-0-estadisticas/\">QPanel 0.12.0 con estadísticas</a> aparece primero en <a rel=\"nofollow\" href=\"https://rodrigoramirez.com\">Rodrigo Ramírez Norambuena</a>.</p>\n]]></content:encoded>\n      <wfw:commentRss>https://rodrigoramirez.com/qpanel-0-12-0-estadisticas/feed/</wfw:commentRss>\n      <slash:comments>0</slash:comments>\n    </item>\n    <item>\n      <title>QPanel 0.11.0 con Spy, Whisper y mas</title>\n      <link>https://rodrigoramirez.com/qpanel-spy-supervisor/</link>\n      <comments>https://rodrigoramirez.com/qpanel-spy-supervisor/#comments</comments>\n      <pubDate>Thu, 21 Jul 2016 01:53:21 +0000</pubDate>\n      <dc:creator><![CDATA[decipher]]></dc:creator>\n      <category><![CDATA[Software]]></category>\n      <category><![CDATA[app_queue]]></category>\n      <category><![CDATA[asterisk]]></category>\n      <category><![CDATA[FreeSWITCH]]></category>\n      <category><![CDATA[qpanel]]></category>\n      <category><![CDATA[queue]]></category>\n      <category><![CDATA[spy]]></category>\n      <category><![CDATA[supervision]]></category>\n      <category><![CDATA[templates]]></category>\n      <category><![CDATA[whisper]]></category>\n\n      <guid isPermaLink=\"false\">https://rodrigoramirez.com/?p=1245</guid>\n      <description><![CDATA[<p>Ya está disponible una nueva versión de QPanel, esta es la 0.11.0 Para instalar esta nueva versión, debes visitar la siguiente URL https://github.com/roramirez/qpanel/tree/0.11.0 Esta versión hemos agregado  algunas funcionalidades que los usuarios  han ido solicitando. Para esta versión es posible realizar Spy, Whisper o Barge a un canal para la supervisión de los miembros que [&#8230;]</p>\n<p>La entrada <a rel=\"nofollow\" href=\"https://rodrigoramirez.com/qpanel-spy-supervisor/\">QPanel 0.11.0 con Spy, Whisper  y mas</a> aparece primero en <a rel=\"nofollow\" href=\"https://rodrigoramirez.com\">Rodrigo Ramírez Norambuena</a>.</p>\n]]></description>\n      <content:encoded><![CDATA[<p><img class=\"aligncenter\" src=\"https://raw.githubusercontent.com/roramirez/qpanel/e55aa16bbd85b579ee82e56469526270c5afa462/samples/animation.gif\" alt=\"Panel monitor callcenter | Qpanel Monitor Colas\" width=\"685\" height=\"385\" />Ya está disponible una nueva versión de QPanel, esta es la 0.11.0</p>\n<p>Para instalar esta nueva versión, debes visitar la siguiente URL</p>\n<ul>\n<li><a href=\"https://github.com/roramirez/qpanel/tree/0.11.0\">https://github.com/roramirez/qpanel/tree/0.11.0</a></li>\n</ul>\n<p>Esta versión hemos agregado  algunas funcionalidades que los usuarios  han ido solicitando.</p>\n<p>Para esta versión es posible realizar Spy, Whisper o Barge a un canal para la supervisión de los miembros que están en una cola.</p>\n<p>También el sistema de plantillas se hecho una refactorización para eliminar exceso de codigo HTML usando uno de base.</p>\n<p>Se han agregado una suite de tests unitarios que al contar del avance del proyecto deberían ir incrementando.</p>\n<p>Se ha solucionado un bug con la actualización del color del estado del agente cuando es uno nuevo agregado a la cola.</p>\n<p>&nbsp;</p>\n<p>El proyecto siempre está abierto a nuevas sugerencias las cuales puedes agregar mediante un <a href=\"https://github.com/roramirez/qpanel/issues/new?title=[Feature]\">issue</a>.</p>\n<p>La entrada <a rel=\"nofollow\" href=\"https://rodrigoramirez.com/qpanel-spy-supervisor/\">QPanel 0.11.0 con Spy, Whisper  y mas</a> aparece primero en <a rel=\"nofollow\" href=\"https://rodrigoramirez.com\">Rodrigo Ramírez Norambuena</a>.</p>\n]]></content:encoded>\n      <wfw:commentRss>https://rodrigoramirez.com/qpanel-spy-supervisor/feed/</wfw:commentRss>\n      <slash:comments>4</slash:comments>\n    </item>\n    <item>\n      <title>Añadir Swap a un sistema</title>\n      <link>https://rodrigoramirez.com/crear-swap/</link>\n      <comments>https://rodrigoramirez.com/crear-swap/#respond</comments>\n      <pubDate>Fri, 15 Jul 2016 05:07:43 +0000</pubDate>\n      <dc:creator><![CDATA[decipher]]></dc:creator>\n      <category><![CDATA[Linux]]></category>\n\n      <guid isPermaLink=\"false\">https://rodrigoramirez.com/?p=1234</guid>\n      <description><![CDATA[<p>Algo que me toma generalmente hacer es cuando trabajo con maquina virtuales es asignar una cantidad determinada de Swap. La  memoria swap es un espacio de intercambio en disco para cuando el sistema ya no puede utilizar más memoria RAM. El problema para mi es que algunos sistemas de maquinas virtuales no asignan por defecto [&#8230;]</p>\n<p>La entrada <a rel=\"nofollow\" href=\"https://rodrigoramirez.com/crear-swap/\">Añadir Swap a un sistema</a> aparece primero en <a rel=\"nofollow\" href=\"https://rodrigoramirez.com\">Rodrigo Ramírez Norambuena</a>.</p>\n]]></description>\n      <content:encoded><![CDATA[<p>Algo que me toma generalmente hacer es cuando trabajo con maquina virtuales es asignar una cantidad determinada de Swap.</p>\n<p>La  memoria swap es un espacio de intercambio en disco para cuando el sistema ya no puede utilizar más memoria RAM.</p>\n<p>El problema para mi es que algunos sistemas de maquinas virtuales no asignan por defecto un espacio para la Swap, lo que te lleva a que el sistema pueda tener crash durante la ejecución.</p>\n<p>Para comprobar la asignación de memoria, al ejecutar el comando <em>free</em> nos debería mostrar como algo similar a lo siguiente</p>\n<p>&nbsp;</p>\n<pre>$  free -m\n             total       used       free     shared    buffers     cached\nMem:           494        488          6          1         54         75\n-/+ buffers/cache:        357        136\nSwap:            0          0          0</pre>\n<p>En la zona de swap indica que no asignada, valor 0.</p>\n<p>Para asignar swap al sistema se debe  un archivo en disco para que sea utilizado como espacio de intercambio, en este caso lo vamos  crear uno  de 3GB en la raíz del sistema</p>\n<pre class=\"code-pre \"><code>fallocate -l 3G /swapfile</code></pre>\n<p>Comprobamos que ha sido creado</p>\n<pre>$ ls -lh /swapfile\n-rw-r--r-- 1 root root 3.0G Jul 11 13:10 /swapfile\n</pre>\n<h3>Habilitación del archivo Swap</h3>\n<p>Ahora nos toca habilitar el archivo creado. Para eso le asignaremos los permisos</p>\n<pre>chmod 600 /swapfile</pre>\n<p>Lo siguiente es para convertir el  archivo para swap</p>\n<pre>mkswap /swapfile</pre>\n<p>Para habilitar y asignarla eso como memoria swap al sistema usamos</p>\n<pre>swapon /swapfile</pre>\n<p>Ya con esto podrémos ver en nuestro sistema la memoria asignada para swap</p>\n<pre>$ free -m\n             total       used       free     shared    buffers     cached\nMem:           494        486          7          1         51         77\n-/+ buffers/cache:        358        136\nSwap:         3071          0       3071</pre>\n<p>&nbsp;</p>\n<p>Para que al reiniciar el sistema esto se mantenga, debemos agregar la siguiente línea al archivo /etc/fstab</p>\n<pre><span class=\"pl-s\">/swapfile none swap sw 0 0</span></pre>\n<p>&nbsp;</p>\n<p>Podemos editar /etc/fstab con algún editor como vim, nano o podemos agregar la linea directamente en la desde la cli de la siguiente manera</p>\n<pre><span class=\"pl-c1\">echo</span> <span class=\"pl-s\"><span class=\"pl-pds\">\"</span>/swapfile none swap sw 0 0<span class=\"pl-pds\">\"</span></span> <span class=\"pl-k\">&gt;&gt;</span> /etc/fstab</pre>\n<p>&nbsp;</p>\n<p>&nbsp;</p>\n<p>La entrada <a rel=\"nofollow\" href=\"https://rodrigoramirez.com/crear-swap/\">Añadir Swap a un sistema</a> aparece primero en <a rel=\"nofollow\" href=\"https://rodrigoramirez.com\">Rodrigo Ramírez Norambuena</a>.</p>\n]]></content:encoded>\n      <wfw:commentRss>https://rodrigoramirez.com/crear-swap/feed/</wfw:commentRss>\n      <slash:comments>0</slash:comments>\n    </item>\n    <item>\n      <title>QPanel 0.10.0 con vista consolidada</title>\n      <link>https://rodrigoramirez.com/qpanel-0-10-0-vista-consolidada/</link>\n      <comments>https://rodrigoramirez.com/qpanel-0-10-0-vista-consolidada/#respond</comments>\n      <pubDate>Mon, 20 Jun 2016 19:32:55 +0000</pubDate>\n      <dc:creator><![CDATA[decipher]]></dc:creator>\n      <category><![CDATA[Linux]]></category>\n      <category><![CDATA[app_queue]]></category>\n      <category><![CDATA[asterisk]]></category>\n      <category><![CDATA[FreeSWITCH]]></category>\n      <category><![CDATA[qpanel]]></category>\n      <category><![CDATA[queue]]></category>\n\n      <guid isPermaLink=\"false\">https://rodrigoramirez.com/?p=1227</guid>\n      <description><![CDATA[<p>Ya con la release numero 28 la nueva versión 0.10.0 de QPanel ya está disponible. Para instalar esta nueva versión, debes visitar la siguiente URL https://github.com/roramirez/qpanel/tree/0.10.0 Esta versión versión nos preocupamos de realizar mejoras, refactorizaciones y agregamos una nueva funcionalidad. La nueva funcionalidad incluida es  que ahora es posible contar con una vista consolidada para [&#8230;]</p>\n<p>La entrada <a rel=\"nofollow\" href=\"https://rodrigoramirez.com/qpanel-0-10-0-vista-consolidada/\">QPanel 0.10.0 con vista consolidada</a> aparece primero en <a rel=\"nofollow\" href=\"https://rodrigoramirez.com\">Rodrigo Ramírez Norambuena</a>.</p>\n]]></description>\n      <content:encoded><![CDATA[<p><img class=\"alignleft\" src=\"https://raw.githubusercontent.com/roramirez/qpanel/0.10.0/samples/animation.gif\" alt=\"Panel monitor callcenter | Qpanel Monitor Colas\" width=\"403\" height=\"227\" />Ya con la release numero 28 la nueva versión 0.10.0 de QPanel ya está disponible.</p>\n<p>Para instalar esta nueva versión, debes visitar la siguiente URL</p>\n<ul>\n<li><a href=\"https://github.com/roramirez/qpanel/tree/0.10.0\">https://github.com/roramirez/qpanel/tree/0.10.0</a></li>\n</ul>\n<p>Esta versión versión nos preocupamos de realizar mejoras, refactorizaciones y agregamos una nueva funcionalidad.</p>\n<p>La nueva funcionalidad incluida es  que ahora es posible contar con una vista consolidada para la información de todas las colas. Que hace tener un mejor control y visualización de lo que está pasando en las colas.</p>\n<p>El proyecto siempre está abierto a nuevas sugerencias las cuales puedes agregar mediante un <a href=\"https://github.com/roramirez/qpanel/issues/new?title=[Feature]\">issue</a>.</p>\n<p>La entrada <a rel=\"nofollow\" href=\"https://rodrigoramirez.com/qpanel-0-10-0-vista-consolidada/\">QPanel 0.10.0 con vista consolidada</a> aparece primero en <a rel=\"nofollow\" href=\"https://rodrigoramirez.com\">Rodrigo Ramírez Norambuena</a>.</p>\n]]></content:encoded>\n      <wfw:commentRss>https://rodrigoramirez.com/qpanel-0-10-0-vista-consolidada/feed/</wfw:commentRss>\n      <slash:comments>0</slash:comments>\n    </item>\n    <item>\n      <title>Nerdearla 2016, WebRTC Glue</title>\n      <link>https://rodrigoramirez.com/nerdearla-2016/</link>\n      <comments>https://rodrigoramirez.com/nerdearla-2016/#respond</comments>\n      <pubDate>Wed, 15 Jun 2016 17:55:41 +0000</pubDate>\n      <dc:creator><![CDATA[decipher]]></dc:creator>\n      <category><![CDATA[Linux]]></category>\n      <category><![CDATA[baires]]></category>\n      <category><![CDATA[charla]]></category>\n      <category><![CDATA[Computación]]></category>\n      <category><![CDATA[informatica]]></category>\n      <category><![CDATA[tech]]></category>\n      <category><![CDATA[ti]]></category>\n      <category><![CDATA[webrtc]]></category>\n\n      <guid isPermaLink=\"false\">https://rodrigoramirez.com/?p=1218</guid>\n      <description><![CDATA[<p>Días atrás estuve participando en el evento llamado Nerdearla en Buenos Aires.  El ambiente era genial si eres de esas personas que desde niño sintio curiosidad por ver como funcionan las cosas, donde desarmabas para volver armar lo juguetes. Habían muchas cosas interesantes tanto en las presentaciones, co-working y workshop que se hubieron. Si te [&#8230;]</p>\n<p>La entrada <a rel=\"nofollow\" href=\"https://rodrigoramirez.com/nerdearla-2016/\">Nerdearla 2016, WebRTC Glue</a> aparece primero en <a rel=\"nofollow\" href=\"https://rodrigoramirez.com\">Rodrigo Ramírez Norambuena</a>.</p>\n]]></description>\n      <content:encoded><![CDATA[<p>Días atrás estuve participando en el evento llamado <a href=\"https://nerdear.la/\">Nerdearla</a> en Buenos Aires.  El ambiente era genial si eres de esas personas que desde niño sintio curiosidad por ver como funcionan las cosas, donde desarmabas para volver armar lo juguetes.</p>\n<p>Habían muchas cosas interesantes tanto en las presentaciones, co-working y workshop que se hubieron. Si te lo perdiste te recomiendo que estés pendiente para el proximo año.</p>\n<p>&nbsp;</p>\n<p>Te podias encontrar con una nuestra como esta<a href=\"https://rodrigoramirez.com/wp-content/uploads/CkhnO83XAAAfaxS.jpg\"><img class=\"aligncenter size-medium wp-image-1221\" src=\"https://rodrigoramirez.com/wp-content/uploads/CkhnO83XAAAfaxS-300x169.jpg\" alt=\"Kaypro II\" width=\"300\" height=\"169\" srcset=\"https://rodrigoramirez.com/wp-content/uploads/CkhnO83XAAAfaxS-300x169.jpg 300w, https://rodrigoramirez.com/wp-content/uploads/CkhnO83XAAAfaxS-768x432.jpg 768w, https://rodrigoramirez.com/wp-content/uploads/CkhnO83XAAAfaxS-1024x576.jpg 1024w, https://rodrigoramirez.com/wp-content/uploads/CkhnO83XAAAfaxS.jpg 1200w\" sizes=\"(max-width: 300px) 100vw, 300px\" /></a></p>\n<p>Puedes dar un vistaso a lo registrado por algunos <a href=\"https://twitter.com/hashtag/nerdearla?f=tweets&amp;vertical=default&amp;src=hash\">usuarios en Twitter</a></p>\n<p>El primer día hice un workshop denominado WebRTC Glue, donde muestra como hacer como unificar la experiencia de atención del centro de contacto directamente en la web. Es una presentación práctica donde puedes ver los ejemplos y usarlos como gustes. Están en <a href=\"https://gitlab.com/roramirez/webrtc-glue\">el repositorio en Gitlab</a>. La presentación <a href=\"/charlas/webrtc-glue/index.html\">la puedes ver aquí</a></p>\n<p>&nbsp;</p>\n<p><a href=\"https://rodrigoramirez.com/wp-content/uploads/CkhxulgWYAAp7fW.jpg\"><img class=\"aligncenter size-medium wp-image-1222\" src=\"https://rodrigoramirez.com/wp-content/uploads/CkhxulgWYAAp7fW-300x169.jpg\" alt=\"WebRTC Glue\" width=\"300\" height=\"169\" srcset=\"https://rodrigoramirez.com/wp-content/uploads/CkhxulgWYAAp7fW-300x169.jpg 300w, https://rodrigoramirez.com/wp-content/uploads/CkhxulgWYAAp7fW-768x432.jpg 768w, https://rodrigoramirez.com/wp-content/uploads/CkhxulgWYAAp7fW-1024x576.jpg 1024w, https://rodrigoramirez.com/wp-content/uploads/CkhxulgWYAAp7fW.jpg 1200w\" sizes=\"(max-width: 300px) 100vw, 300px\" /></a></p>\n<p>Haber si nos vemos el próximo año.</p>\n<p>&nbsp;</p>\n<p><strong>Update</strong>: Puedes ver una parte sin la demostración del workshop</p>\n<p><iframe src=\"https://www.youtube.com/embed/qDreeYk-UK4?list=PLTTdzfRyGY1vQukK8L4QeAfYdbURSzFq5\" width=\"560\" height=\"315\" frameborder=\"0\" allowfullscreen=\"allowfullscreen\"></iframe><br />\n&nbsp;</p>\n<p>La entrada <a rel=\"nofollow\" href=\"https://rodrigoramirez.com/nerdearla-2016/\">Nerdearla 2016, WebRTC Glue</a> aparece primero en <a rel=\"nofollow\" href=\"https://rodrigoramirez.com\">Rodrigo Ramírez Norambuena</a>.</p>\n]]></content:encoded>\n      <wfw:commentRss>https://rodrigoramirez.com/nerdearla-2016/feed/</wfw:commentRss>\n      <slash:comments>0</slash:comments>\n    </item>\n    <item>\n      <title>QPanel 0.9.0</title>\n      <link>https://rodrigoramirez.com/qpanel-0-9-0/</link>\n      <comments>https://rodrigoramirez.com/qpanel-0-9-0/#respond</comments>\n      <pubDate>Mon, 09 May 2016 18:40:23 +0000</pubDate>\n      <dc:creator><![CDATA[decipher]]></dc:creator>\n      <category><![CDATA[Software]]></category>\n      <category><![CDATA[asterisk]]></category>\n      <category><![CDATA[callcenter]]></category>\n      <category><![CDATA[colas]]></category>\n      <category><![CDATA[monitor]]></category>\n      <category><![CDATA[monitoreo]]></category>\n      <category><![CDATA[panel]]></category>\n      <category><![CDATA[qpanel]]></category>\n      <category><![CDATA[queues]]></category>\n\n      <guid isPermaLink=\"false\">https://rodrigoramirez.com/?p=1206</guid>\n      <description><![CDATA[<p>El Panel monitor callcenter para colas de Asterisk ya cuenta con una nueva versión, la 0.9.0 Para instalar esta nueva versión, debes visitar la siguiente URL https://github.com/roramirez/qpanel/tree/0.9.0 Esta versión versión nos preocupamos de realizar mejoras y refactorizaciones en el codigo para dar un mejor rendimiento, como también de la compatibilidad con la versión 11 de [&#8230;]</p>\n<p>La entrada <a rel=\"nofollow\" href=\"https://rodrigoramirez.com/qpanel-0-9-0/\">QPanel 0.9.0</a> aparece primero en <a rel=\"nofollow\" href=\"https://rodrigoramirez.com\">Rodrigo Ramírez Norambuena</a>.</p>\n]]></description>\n      <content:encoded><![CDATA[<p><img class=\"alignleft\" src=\"https://raw.githubusercontent.com/roramirez/qpanel/0.9.0/samples/animation.gif\" alt=\"Panel monitor callcenter | Qpanel Monitor Colas\" width=\"403\" height=\"227\" />El Panel monitor callcenter para colas de Asterisk ya cuenta con una nueva versión, la 0.9.0</p>\n<p>Para instalar esta nueva versión, debes visitar la siguiente URL</p>\n<ul>\n<li><a href=\"https://github.com/roramirez/qpanel/tree/0.9.0\">https://github.com/roramirez/qpanel/tree/0.9.0</a></li>\n</ul>\n<p>Esta versión versión nos preocupamos de realizar mejoras y refactorizaciones en el codigo para dar un mejor rendimiento, como también de la compatibilidad con la versión 11 de Asterisk.</p>\n<p>Dentro de las cosas que podamos mencionar:</p>\n<ul>\n<li> Actualización del repositorio y versión de py-asterisk, biblioteca para trabajar con Asterisk. Acá la ocupamos principalmente para uso del Manager.</li>\n<li>Portación de parche de funcionalidades como pausa, tiempo, razón de una pausa para Asterisk 11.</li>\n<li>Cambio del comportamiento en el conteo cuando el participante en una cola está ocupado (busy)</li>\n</ul>\n<p>El proyecto siempre está abierto a nuevas sugerencias las cuales puedes agregar mediante un <a href=\"https://github.com/roramirez/qpanel/issues/new?title=[Feature]\">issue</a>.</p>\n<p>La entrada <a rel=\"nofollow\" href=\"https://rodrigoramirez.com/qpanel-0-9-0/\">QPanel 0.9.0</a> aparece primero en <a rel=\"nofollow\" href=\"https://rodrigoramirez.com\">Rodrigo Ramírez Norambuena</a>.</p>\n]]></content:encoded>\n      <wfw:commentRss>https://rodrigoramirez.com/qpanel-0-9-0/feed/</wfw:commentRss>\n      <slash:comments>0</slash:comments>\n    </item>\n    <item>\n      <title>Mandar un email desde la shell</title>\n      <link>https://rodrigoramirez.com/mandar-un-email-desde-la-shell/</link>\n      <comments>https://rodrigoramirez.com/mandar-un-email-desde-la-shell/#comments</comments>\n      <pubDate>Wed, 13 Apr 2016 13:05:13 +0000</pubDate>\n      <dc:creator><![CDATA[decipher]]></dc:creator>\n      <category><![CDATA[Linux]]></category>\n      <category><![CDATA[mini-tips]]></category>\n      <category><![CDATA[bash]]></category>\n      <category><![CDATA[cli]]></category>\n      <category><![CDATA[Email]]></category>\n      <category><![CDATA[mail]]></category>\n      <category><![CDATA[sh]]></category>\n      <category><![CDATA[shell]]></category>\n\n      <guid isPermaLink=\"false\">https://rodrigoramirez.com/?p=1172</guid>\n      <description><![CDATA[<p>Dejo esto por acá ya que es algo que siempre me olvido como es. El tema es enviar un email mediante el comando mail en un servidor con Linux. Si usas mail a secas te va pidiendo los datos para crear el correo, principalmente el body del correo. Para automatizar esto a través de un [&#8230;]</p>\n<p>La entrada <a rel=\"nofollow\" href=\"https://rodrigoramirez.com/mandar-un-email-desde-la-shell/\">Mandar un email desde la shell</a> aparece primero en <a rel=\"nofollow\" href=\"https://rodrigoramirez.com\">Rodrigo Ramírez Norambuena</a>.</p>\n]]></description>\n      <content:encoded><![CDATA[<p>Dejo esto por acá ya que es algo que siempre me olvido como es. El tema es enviar un email mediante el comando <em>mail</em> en un servidor con Linux.</p>\n<p>Si usas mail a secas te va pidiendo los datos para crear el correo, principalmente el body del correo. Para automatizar esto a través de un <em>echo</em> le pasas por pipe a <em>mail</em></p>\n<pre>echo \"Cuerpo del mensaje\" | mail -s Asunto a@rodrigoramirez.com</pre>\n<p>La entrada <a rel=\"nofollow\" href=\"https://rodrigoramirez.com/mandar-un-email-desde-la-shell/\">Mandar un email desde la shell</a> aparece primero en <a rel=\"nofollow\" href=\"https://rodrigoramirez.com\">Rodrigo Ramírez Norambuena</a>.</p>\n]]></content:encoded>\n      <wfw:commentRss>https://rodrigoramirez.com/mandar-un-email-desde-la-shell/feed/</wfw:commentRss>\n      <slash:comments>4</slash:comments>\n    </item>\n  </channel>\n</rss>\n"
  },
  {
    "path": "tests/mocks/rrule_until.ics",
    "content": "BEGIN:VEVENT\nDTSTART;TZID=America/Los_Angeles:20240229T160000\nDTEND;TZID=America/Los_Angeles:20240229T190000\nRRULE:FREQ=WEEKLY;WKST=MO;UNTIL=20240307T075959Z;BYDAY=TH\nDTSTAMP:20240307T180618Z\nCREATED:20231231T000501Z\nLAST-MODIFIED:20231231T005623Z\nSEQUENCE:2\nSTATUS:CONFIRMED\nSUMMARY:My event\nTRANSP:OPAQUE\nEND:VEVENT\nBEGIN:VEVENT\nDTSTART;TZID=America/Los_Angeles:20240307T160000\nDTEND;TZID=America/Los_Angeles:20240307T190000\nRRULE:FREQ=WEEKLY;WKST=MO;UNTIL=20240316T065959Z;BYDAY=TH\nDTSTAMP:20240307T180618Z\nCREATED:20231231T000501Z\nLAST-MODIFIED:20231231T005623Z\nSEQUENCE:3\nSTATUS:CONFIRMED\nSUMMARY:My event\nTRANSP:OPAQUE\nEND:VEVENT"
  },
  {
    "path": "tests/mocks/sliceMultiDayEvents.ics",
    "content": "BEGIN:VCALENDAR\nPRODID:-//Google Inc//Google Calendar 70.9054//EN\nVERSION:2.0\nCALSCALE:GREGORIAN\nMETHOD:PUBLISH\nX-WR-CALNAME:Dirk Test\nX-WR-TIMEZONE:Europe/Berlin\nBEGIN:VEVENT\nDTSTART;VALUE=DATE:20240918\nDTEND;VALUE=DATE:20240919\nDTSTAMP:20240916T084410Z\nUID:2crbv1ijljc2kt9jclkgu5hqa0@google.com\nCREATED:20240916T083831Z\nLAST-MODIFIED:20240916T083831Z\nSEQUENCE:0\nSTATUS:CONFIRMED\nSUMMARY:1 day single\nTRANSP:TRANSPARENT\nEND:VEVENT\nBEGIN:VEVENT\nDTSTART;VALUE=DATE:20240919\nDTEND;VALUE=DATE:20240920\nRRULE:FREQ=YEARLY\nDTSTAMP:20240916T084410Z\nUID:6gb19havnq6vp2qput51e5rmml@google.com\nCREATED:20240916T083850Z\nLAST-MODIFIED:20240916T083850Z\nSEQUENCE:0\nSTATUS:CONFIRMED\nSUMMARY:1 day repeat\nTRANSP:TRANSPARENT\nEND:VEVENT\nBEGIN:VEVENT\nDTSTART;VALUE=DATE:20240920\nDTEND;VALUE=DATE:20240922\nDTSTAMP:20240916T084410Z\nUID:06e9u1trbqi3jbvstvq4qqutau@google.com\nCREATED:20240916T083902Z\nLAST-MODIFIED:20240916T083902Z\nSEQUENCE:0\nSTATUS:CONFIRMED\nSUMMARY:2 day single\nTRANSP:TRANSPARENT\nEND:VEVENT\nBEGIN:VEVENT\nDTSTART;VALUE=DATE:20240923\nDTEND;VALUE=DATE:20240925\nRRULE:FREQ=YEARLY\nDTSTAMP:20240916T084410Z\nUID:0ui78rk6hpcv8rmbb6nuonhmgg@google.com\nCREATED:20240916T083919Z\nLAST-MODIFIED:20240916T083919Z\nSEQUENCE:0\nSTATUS:CONFIRMED\nSUMMARY:2 day repeat\nTRANSP:TRANSPARENT\nEND:VEVENT\nEND:VCALENDAR"
  },
  {
    "path": "tests/mocks/testNotification/testNotification.js",
    "content": "Module.register(\"testNotification\", {\n\tdefaults: {\n\t\tdebug: false,\n\t\tmatch: {\n\t\t\tnotificationID: \"\",\n\t\t\tmatchtype: \"count\"\n\t\t\t//or\n\t\t\t// type: 'contents' // look for item in field of content\n\t\t}\n\t},\n\tcount: 0,\n\ttable: null,\n\tnotificationReceived (notification, payload) {\n\t\tif (notification === this.config.match.notificationID) {\n\t\t\tif (this.config.match.matchtype === \"count\") {\n\t\t\t\tthis.count = payload.length;\n\t\t\t\tif (this.count) {\n\t\t\t\t\tthis.table = document.createElement(\"table\");\n\t\t\t\t\tthis.addTableRow(this.table, null, `${this.count}:elementCount`);\n\t\t\t\t\tif (this.config.debug) {\n\t\t\t\t\t\tpayload.forEach((e, i) => {\n\t\t\t\t\t\t\tthis.addTableRow(this.table, i, e.title);\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tthis.updateDom();\n\t\t\t}\n\t\t}\n\t},\n\tmaketd (row, info) {\n\t\tlet td = document.createElement(\"td\");\n\t\trow.appendChild(td);\n\t\tif (info !== null) {\n\t\t\tlet colinfo = info.toString().split(\":\");\n\t\t\tif (colinfo.length === 2) td.className = colinfo[1];\n\t\t\ttd.innerText = colinfo[0];\n\t\t}\n\t\treturn td;\n\t},\n\taddTableRow (table, col1 = null, col2 = null, col3 = null) {\n\t\tlet tableRow = document.createElement(\"tr\");\n\t\ttable.appendChild(tableRow);\n\n\t\tlet tablecol1 = this.maketd(tableRow, col1);\n\t\tlet tablecol2 = this.maketd(tableRow, col2);\n\t\tlet tablecol3 = this.maketd(tableRow, col3);\n\n\t\treturn tableRow;\n\t},\n\tgetDom () {\n\t\tlet wrapper = document.createElement(\"div\");\n\t\tif (this.table) {\n\t\t\twrapper.appendChild(this.table);\n\t\t}\n\t\treturn wrapper;\n\t}\n\n});\n"
  },
  {
    "path": "tests/mocks/translation_test.json",
    "content": "{\n\t\"LOADING\": \"Loading …\",\n\n\t\"TODAY\": \"Today\",\n\t\"TOMORROW\": \"Tomorrow\",\n\t\"DAYAFTERTOMORROW\": \"In 2 days\",\n\t\"RUNNING\": \"Ends in\",\n\t\"EMPTY\": \"No upcoming events.\",\n\n\t\"WEEK\": \"Week {weekNumber}\",\n\n\t\"N\": \"N\",\n\t\"NNE\": \"NNE\",\n\t\"NE\": \"NE\",\n\t\"ENE\": \"ENE\",\n\t\"E\": \"E\",\n\t\"ESE\": \"ESE\",\n\t\"SE\": \"SE\",\n\t\"SSE\": \"SSE\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SSW\",\n\t\"SW\": \"SW\",\n\t\"WSW\": \"WSW\",\n\t\"W\": \"W\",\n\t\"WNW\": \"WNW\",\n\t\"NW\": \"NW\",\n\t\"NNW\": \"NNW\",\n\n\t\"UPDATE_NOTIFICATION\": \"MagicMirror² update available.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Update available for MODULE_NAME module.\",\n\t\"UPDATE_INFO_SINGLE\": \"The current installation is COMMIT_COUNT commit behind on the BRANCH_NAME branch.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"The current installation is COMMIT_COUNT commits behind on the BRANCH_NAME branch.\"\n}\n"
  },
  {
    "path": "tests/mocks/weather_current.json",
    "content": "{\n\t\"coord\": {\n\t\t\"lon\": 11.58,\n\t\t\"lat\": 48.14\n\t},\n\t\"weather\": [\n\t\t{\n\t\t\t\"id\": 615,\n\t\t\t\"main\": \"Snow\",\n\t\t\t\"description\": \"light rain and snow\",\n\t\t\t\"icon\": \"13d\"\n\t\t},\n\t\t{\n\t\t\t\"id\": 500,\n\t\t\t\"main\": \"Rain\",\n\t\t\t\"description\": \"light rain\",\n\t\t\t\"icon\": \"10d\"\n\t\t}\n\t],\n\t\"base\": \"stations\",\n\t\"main\": {\n\t\t\"temp\": 1.49,\n\t\t\"pressure\": 1005,\n\t\t\"humidity\": 93.7,\n\t\t\"temp_min\": 1,\n\t\t\"temp_max\": 2\n\t},\n\t\"visibility\": 7000,\n\t\"wind\": {\n\t\t\"speed\": 11.8,\n\t\t\"deg\": 250\n\t},\n\t\"clouds\": {\n\t\t\"all\": 75\n\t},\n\t\"dt\": 1547387400,\n\t\"sys\": {\n\t\t\"type\": 1,\n\t\t\"id\": 1267,\n\t\t\"message\": 0.0031,\n\t\t\"country\": \"DE\",\n\t\t\"sunrise\": 1547362817,\n\t\t\"sunset\": 1547394301\n\t},\n\t\"id\": 2867714,\n\t\"name\": \"Munich\",\n\t\"cod\": 200\n}\n"
  },
  {
    "path": "tests/mocks/weather_forecast.json",
    "content": "{\n\t\"city\": {\n\t\t\"id\": 2867714,\n\t\t\"name\": \"Munich\",\n\t\t\"coord\": {\n\t\t\t\"lon\": 11.5754,\n\t\t\t\"lat\": 48.1371\n\t\t},\n\t\t\"country\": \"DE\",\n\t\t\"population\": 1260391,\n\t\t\"timezone\": 7200\n\t},\n\t\"cod\": \"200\",\n\t\"message\": 0.9653487,\n\t\"cnt\": 7,\n\t\"list\": [\n\t\t{\n\t\t\t\"dt\": 1568372400,\n\t\t\t\"sunrise\": 1568350044,\n\t\t\t\"sunset\": 1568395948,\n\t\t\t\"temp\": {\n\t\t\t\t\"day\": 24.44,\n\t\t\t\t\"min\": 15.35,\n\t\t\t\t\"max\": 24.44,\n\t\t\t\t\"night\": 15.35,\n\t\t\t\t\"eve\": 18,\n\t\t\t\t\"morn\": 23.03\n\t\t\t},\n\t\t\t\"pressure\": 1031.65,\n\t\t\t\"humidity\": 70,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 801,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"few clouds\",\n\t\t\t\t\t\"icon\": \"02d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"speed\": 3.35,\n\t\t\t\"deg\": 314,\n\t\t\t\"clouds\": 21\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1568458800,\n\t\t\t\"sunrise\": 1568436525,\n\t\t\t\"sunset\": 1568482223,\n\t\t\t\"temp\": {\n\t\t\t\t\"day\": 20.81,\n\t\t\t\t\"min\": 13.56,\n\t\t\t\t\"max\": 21.02,\n\t\t\t\t\"night\": 13.56,\n\t\t\t\t\"eve\": 16.6,\n\t\t\t\t\"morn\": 15.88\n\t\t\t},\n\t\t\t\"pressure\": 1028.81,\n\t\t\t\"humidity\": 72,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 500,\n\t\t\t\t\t\"main\": \"Rain\",\n\t\t\t\t\t\"description\": \"light rain\",\n\t\t\t\t\t\"icon\": \"10d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"speed\": 2.21,\n\t\t\t\"deg\": 81,\n\t\t\t\"clouds\": 100,\n\t\t\t\"pop\": 0.7,\n\t\t\t\"rain\": 2.51\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1568545200,\n\t\t\t\"sunrise\": 1568523007,\n\t\t\t\"sunset\": 1568568497,\n\t\t\t\"temp\": {\n\t\t\t\t\"day\": 22.65,\n\t\t\t\t\"min\": 13.76,\n\t\t\t\t\"max\": 22.88,\n\t\t\t\t\"night\": 15.27,\n\t\t\t\t\"eve\": 17.45,\n\t\t\t\t\"morn\": 13.76\n\t\t\t},\n\t\t\t\"pressure\": 1023.75,\n\t\t\t\"humidity\": 64,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 800,\n\t\t\t\t\t\"main\": \"Clear\",\n\t\t\t\t\t\"description\": \"sky is clear\",\n\t\t\t\t\t\"icon\": \"01d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"speed\": 1.15,\n\t\t\t\"deg\": 7,\n\t\t\t\"clouds\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1568631600,\n\t\t\t\"sunrise\": 1568609489,\n\t\t\t\"sunset\": 1568654771,\n\t\t\t\"temp\": {\n\t\t\t\t\"day\": 23.45,\n\t\t\t\t\"min\": 13.95,\n\t\t\t\t\"max\": 23.45,\n\t\t\t\t\"night\": 13.95,\n\t\t\t\t\"eve\": 17.75,\n\t\t\t\t\"morn\": 15.21\n\t\t\t},\n\t\t\t\"pressure\": 1020.41,\n\t\t\t\"humidity\": 64,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 800,\n\t\t\t\t\t\"main\": \"Clear\",\n\t\t\t\t\t\"description\": \"sky is clear\",\n\t\t\t\t\t\"icon\": \"01d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"speed\": 3.07,\n\t\t\t\"deg\": 298,\n\t\t\t\"clouds\": 7\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1568718000,\n\t\t\t\"sunrise\": 1568695970,\n\t\t\t\"sunset\": 1568741045,\n\t\t\t\"temp\": {\n\t\t\t\t\"day\": 20.55,\n\t\t\t\t\"min\": 10.95,\n\t\t\t\t\"max\": 20.55,\n\t\t\t\t\"night\": 10.95,\n\t\t\t\t\"eve\": 14.82,\n\t\t\t\t\"morn\": 13.24\n\t\t\t},\n\t\t\t\"pressure\": 1019.4,\n\t\t\t\"humidity\": 66,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 800,\n\t\t\t\t\t\"main\": \"Clear\",\n\t\t\t\t\t\"description\": \"sky is clear\",\n\t\t\t\t\t\"icon\": \"01d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"speed\": 2.8,\n\t\t\t\"deg\": 333,\n\t\t\t\"clouds\": 2\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1568804400,\n\t\t\t\"sunrise\": 1568782452,\n\t\t\t\"sunset\": 1568827319,\n\t\t\t\"temp\": {\n\t\t\t\t\"day\": 18.15,\n\t\t\t\t\"min\": 7.75,\n\t\t\t\t\"max\": 18.15,\n\t\t\t\t\"night\": 7.75,\n\t\t\t\t\"eve\": 12.45,\n\t\t\t\t\"morn\": 9.41\n\t\t\t},\n\t\t\t\"pressure\": 1017.56,\n\t\t\t\"humidity\": 52,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 800,\n\t\t\t\t\t\"main\": \"Clear\",\n\t\t\t\t\t\"description\": \"sky is clear\",\n\t\t\t\t\t\"icon\": \"01d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"speed\": 2.92,\n\t\t\t\"deg\": 34,\n\t\t\t\"clouds\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1568890800,\n\t\t\t\"sunrise\": 1568868934,\n\t\t\t\"sunset\": 1568913593,\n\t\t\t\"temp\": {\n\t\t\t\t\"day\": 14.85,\n\t\t\t\t\"min\": 5.56,\n\t\t\t\t\"max\": 15.05,\n\t\t\t\t\"night\": 5.56,\n\t\t\t\t\"eve\": 9.56,\n\t\t\t\t\"morn\": 6.25\n\t\t\t},\n\t\t\t\"pressure\": 1022.7,\n\t\t\t\"humidity\": 59,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 800,\n\t\t\t\t\t\"main\": \"Clear\",\n\t\t\t\t\t\"description\": \"sky is clear\",\n\t\t\t\t\t\"icon\": \"01d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"speed\": 2.89,\n\t\t\t\"deg\": 51,\n\t\t\t\"clouds\": 1\n\t\t}\n\t]\n}\n"
  },
  {
    "path": "tests/mocks/weather_hourly.json",
    "content": "{\n\t\"hourly\": [\n\t\t{\n\t\t\t\"dt\": 1673204400,\n\t\t\t\"temp\": 27.31,\n\t\t\t\"feels_like\": 29.59,\n\t\t\t\"pressure\": 1013,\n\t\t\t\"humidity\": 72,\n\t\t\t\"dew_point\": 21.82,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 31,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 2.05,\n\t\t\t\"wind_deg\": 200,\n\t\t\t\"wind_gust\": 1.91,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 802,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Mäßig bewölkt\",\n\t\t\t\t\t\"icon\": \"03n\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673208000,\n\t\t\t\"temp\": 27.31,\n\t\t\t\"feels_like\": 29.69,\n\t\t\t\"pressure\": 1013,\n\t\t\t\"humidity\": 73,\n\t\t\t\"dew_point\": 22.04,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 30,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 2.14,\n\t\t\t\"wind_deg\": 186,\n\t\t\t\"wind_gust\": 1.9,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 802,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Mäßig bewölkt\",\n\t\t\t\t\t\"icon\": \"03n\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673211600,\n\t\t\t\"temp\": 27.29,\n\t\t\t\"feels_like\": 29.65,\n\t\t\t\"pressure\": 1013,\n\t\t\t\"humidity\": 73,\n\t\t\t\"dew_point\": 22.03,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 31,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 2.16,\n\t\t\t\"wind_deg\": 193,\n\t\t\t\"wind_gust\": 1.91,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 802,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Mäßig bewölkt\",\n\t\t\t\t\t\"icon\": \"03n\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0.12\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673215200,\n\t\t\t\"temp\": 27.21,\n\t\t\t\"feels_like\": 29.6,\n\t\t\t\"pressure\": 1013,\n\t\t\t\"humidity\": 74,\n\t\t\t\"dew_point\": 22.17,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 32,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 2.13,\n\t\t\t\"wind_deg\": 206,\n\t\t\t\"wind_gust\": 1.91,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 500,\n\t\t\t\t\t\"main\": \"Rain\",\n\t\t\t\t\t\"description\": \"Leichter Regen\",\n\t\t\t\t\t\"icon\": \"10n\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0.36,\n\t\t\t\"rain\": {\n\t\t\t\t\"1h\": 0.13\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673218800,\n\t\t\t\"temp\": 27.1,\n\t\t\t\"feels_like\": 29.39,\n\t\t\t\"pressure\": 1014,\n\t\t\t\"humidity\": 74,\n\t\t\t\"dew_point\": 22.07,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 38,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 1.41,\n\t\t\t\"wind_deg\": 227,\n\t\t\t\"wind_gust\": 1.3,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 500,\n\t\t\t\t\t\"main\": \"Rain\",\n\t\t\t\t\t\"description\": \"Leichter Regen\",\n\t\t\t\t\t\"icon\": \"10n\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0.44,\n\t\t\t\"rain\": {\n\t\t\t\t\"1h\": 0.13\n\t\t\t}\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673222400,\n\t\t\t\"temp\": 26.95,\n\t\t\t\"feels_like\": 29.19,\n\t\t\t\"pressure\": 1013,\n\t\t\t\"humidity\": 75,\n\t\t\t\"dew_point\": 22.14,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 41,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 1.65,\n\t\t\t\"wind_deg\": 227,\n\t\t\t\"wind_gust\": 1.5,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 802,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Mäßig bewölkt\",\n\t\t\t\t\t\"icon\": \"03n\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0.52\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673226000,\n\t\t\t\"temp\": 26.72,\n\t\t\t\"feels_like\": 28.83,\n\t\t\t\"pressure\": 1012,\n\t\t\t\"humidity\": 76,\n\t\t\t\"dew_point\": 22.15,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 22,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 1.88,\n\t\t\t\"wind_deg\": 218,\n\t\t\t\"wind_gust\": 1.71,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 801,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Ein paar Wolken\",\n\t\t\t\t\t\"icon\": \"02n\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0.08\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673229600,\n\t\t\t\"temp\": 26.57,\n\t\t\t\"feels_like\": 26.57,\n\t\t\t\"pressure\": 1012,\n\t\t\t\"humidity\": 76,\n\t\t\t\"dew_point\": 22.05,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 20,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 1.51,\n\t\t\t\"wind_deg\": 221,\n\t\t\t\"wind_gust\": 1.3,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 801,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Ein paar Wolken\",\n\t\t\t\t\t\"icon\": \"02n\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0.08\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673233200,\n\t\t\t\"temp\": 26.46,\n\t\t\t\"feels_like\": 26.46,\n\t\t\t\"pressure\": 1011,\n\t\t\t\"humidity\": 77,\n\t\t\t\"dew_point\": 22.12,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 32,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 1.71,\n\t\t\t\"wind_deg\": 210,\n\t\t\t\"wind_gust\": 1.52,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 802,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Mäßig bewölkt\",\n\t\t\t\t\t\"icon\": \"03n\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0.04\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673236800,\n\t\t\t\"temp\": 26.38,\n\t\t\t\"feels_like\": 26.38,\n\t\t\t\"pressure\": 1011,\n\t\t\t\"humidity\": 78,\n\t\t\t\"dew_point\": 22.22,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 49,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 1.84,\n\t\t\t\"wind_deg\": 213,\n\t\t\t\"wind_gust\": 1.61,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 802,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Mäßig bewölkt\",\n\t\t\t\t\t\"icon\": \"03n\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673240400,\n\t\t\t\"temp\": 26.32,\n\t\t\t\"feels_like\": 26.32,\n\t\t\t\"pressure\": 1012,\n\t\t\t\"humidity\": 78,\n\t\t\t\"dew_point\": 22.12,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 48,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 1.83,\n\t\t\t\"wind_deg\": 216,\n\t\t\t\"wind_gust\": 1.6,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 802,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Mäßig bewölkt\",\n\t\t\t\t\t\"icon\": \"03n\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673244000,\n\t\t\t\"temp\": 26.32,\n\t\t\t\"feels_like\": 26.32,\n\t\t\t\"pressure\": 1012,\n\t\t\t\"humidity\": 78,\n\t\t\t\"dew_point\": 22.26,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 43,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 2.11,\n\t\t\t\"wind_deg\": 205,\n\t\t\t\"wind_gust\": 1.72,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 802,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Mäßig bewölkt\",\n\t\t\t\t\t\"icon\": \"03n\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673247600,\n\t\t\t\"temp\": 26.44,\n\t\t\t\"feels_like\": 26.44,\n\t\t\t\"pressure\": 1013,\n\t\t\t\"humidity\": 79,\n\t\t\t\"dew_point\": 22.44,\n\t\t\t\"uvi\": 0.53,\n\t\t\t\"clouds\": 90,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 2.78,\n\t\t\t\"wind_deg\": 207,\n\t\t\t\"wind_gust\": 2.51,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 804,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Bedeckt\",\n\t\t\t\t\t\"icon\": \"04d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673251200,\n\t\t\t\"temp\": 26.45,\n\t\t\t\"feels_like\": 26.45,\n\t\t\t\"pressure\": 1013,\n\t\t\t\"humidity\": 78,\n\t\t\t\"dew_point\": 22.22,\n\t\t\t\"uvi\": 2.13,\n\t\t\t\"clouds\": 93,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 2.43,\n\t\t\t\"wind_deg\": 190,\n\t\t\t\"wind_gust\": 2.21,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 804,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Bedeckt\",\n\t\t\t\t\t\"icon\": \"04d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673254800,\n\t\t\t\"temp\": 26.54,\n\t\t\t\"feels_like\": 26.54,\n\t\t\t\"pressure\": 1014,\n\t\t\t\"humidity\": 78,\n\t\t\t\"dew_point\": 22.32,\n\t\t\t\"uvi\": 4.92,\n\t\t\t\"clouds\": 68,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 3.04,\n\t\t\t\"wind_deg\": 188,\n\t\t\t\"wind_gust\": 2.91,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 803,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Überwiegend bewölkt\",\n\t\t\t\t\t\"icon\": \"04d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673258400,\n\t\t\t\"temp\": 26.61,\n\t\t\t\"feels_like\": 26.61,\n\t\t\t\"pressure\": 1013,\n\t\t\t\"humidity\": 77,\n\t\t\t\"dew_point\": 22.28,\n\t\t\t\"uvi\": 8.04,\n\t\t\t\"clouds\": 56,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 3.37,\n\t\t\t\"wind_deg\": 183,\n\t\t\t\"wind_gust\": 3.22,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 803,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Überwiegend bewölkt\",\n\t\t\t\t\t\"icon\": \"04d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673262000,\n\t\t\t\"temp\": 26.76,\n\t\t\t\"feels_like\": 28.9,\n\t\t\t\"pressure\": 1013,\n\t\t\t\"humidity\": 76,\n\t\t\t\"dew_point\": 22.24,\n\t\t\t\"uvi\": 10.6,\n\t\t\t\"clouds\": 62,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 3.51,\n\t\t\t\"wind_deg\": 175,\n\t\t\t\"wind_gust\": 3.4,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 803,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Überwiegend bewölkt\",\n\t\t\t\t\t\"icon\": \"04d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673265600,\n\t\t\t\"temp\": 26.91,\n\t\t\t\"feels_like\": 29.11,\n\t\t\t\"pressure\": 1012,\n\t\t\t\"humidity\": 75,\n\t\t\t\"dew_point\": 22.24,\n\t\t\t\"uvi\": 11.58,\n\t\t\t\"clouds\": 54,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 3.82,\n\t\t\t\"wind_deg\": 174,\n\t\t\t\"wind_gust\": 3.8,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 803,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Überwiegend bewölkt\",\n\t\t\t\t\t\"icon\": \"04d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673269200,\n\t\t\t\"temp\": 27.04,\n\t\t\t\"feels_like\": 29.27,\n\t\t\t\"pressure\": 1011,\n\t\t\t\"humidity\": 74,\n\t\t\t\"dew_point\": 22.02,\n\t\t\t\"uvi\": 10.65,\n\t\t\t\"clouds\": 84,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 4.06,\n\t\t\t\"wind_deg\": 177,\n\t\t\t\"wind_gust\": 4.02,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 803,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Überwiegend bewölkt\",\n\t\t\t\t\t\"icon\": \"04d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673272800,\n\t\t\t\"temp\": 27.12,\n\t\t\t\"feels_like\": 29.33,\n\t\t\t\"pressure\": 1011,\n\t\t\t\"humidity\": 73,\n\t\t\t\"dew_point\": 21.94,\n\t\t\t\"uvi\": 8.07,\n\t\t\t\"clouds\": 81,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 3.75,\n\t\t\t\"wind_deg\": 187,\n\t\t\t\"wind_gust\": 3.6,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 803,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Überwiegend bewölkt\",\n\t\t\t\t\t\"icon\": \"04d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673276400,\n\t\t\t\"temp\": 27.17,\n\t\t\t\"feels_like\": 29.33,\n\t\t\t\"pressure\": 1010,\n\t\t\t\"humidity\": 72,\n\t\t\t\"dew_point\": 21.8,\n\t\t\t\"uvi\": 4.84,\n\t\t\t\"clouds\": 87,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 3.35,\n\t\t\t\"wind_deg\": 177,\n\t\t\t\"wind_gust\": 3.2,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 804,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Bedeckt\",\n\t\t\t\t\t\"icon\": \"04d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673280000,\n\t\t\t\"temp\": 27.28,\n\t\t\t\"feels_like\": 29.43,\n\t\t\t\"pressure\": 1011,\n\t\t\t\"humidity\": 71,\n\t\t\t\"dew_point\": 21.56,\n\t\t\t\"uvi\": 2.16,\n\t\t\t\"clouds\": 90,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 2.35,\n\t\t\t\"wind_deg\": 177,\n\t\t\t\"wind_gust\": 2.21,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 804,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Bedeckt\",\n\t\t\t\t\t\"icon\": \"04d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673283600,\n\t\t\t\"temp\": 27.28,\n\t\t\t\"feels_like\": 29.43,\n\t\t\t\"pressure\": 1011,\n\t\t\t\"humidity\": 71,\n\t\t\t\"dew_point\": 21.52,\n\t\t\t\"uvi\": 0.54,\n\t\t\t\"clouds\": 88,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 2.36,\n\t\t\t\"wind_deg\": 173,\n\t\t\t\"wind_gust\": 2.22,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 804,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Bedeckt\",\n\t\t\t\t\t\"icon\": \"04d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673287200,\n\t\t\t\"temp\": 27.34,\n\t\t\t\"feels_like\": 29.54,\n\t\t\t\"pressure\": 1012,\n\t\t\t\"humidity\": 71,\n\t\t\t\"dew_point\": 21.62,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 77,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 2.14,\n\t\t\t\"wind_deg\": 172,\n\t\t\t\"wind_gust\": 2.01,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 803,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Überwiegend bewölkt\",\n\t\t\t\t\t\"icon\": \"04d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673290800,\n\t\t\t\"temp\": 27.25,\n\t\t\t\"feels_like\": 29.38,\n\t\t\t\"pressure\": 1013,\n\t\t\t\"humidity\": 71,\n\t\t\t\"dew_point\": 21.55,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 47,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 1.62,\n\t\t\t\"wind_deg\": 158,\n\t\t\t\"wind_gust\": 1.51,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 802,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Mäßig bewölkt\",\n\t\t\t\t\t\"icon\": \"03n\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673294400,\n\t\t\t\"temp\": 27.25,\n\t\t\t\"feels_like\": 29.38,\n\t\t\t\"pressure\": 1014,\n\t\t\t\"humidity\": 71,\n\t\t\t\"dew_point\": 21.52,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 29,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 1.53,\n\t\t\t\"wind_deg\": 126,\n\t\t\t\"wind_gust\": 1.41,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 802,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Mäßig bewölkt\",\n\t\t\t\t\t\"icon\": \"03n\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673298000,\n\t\t\t\"temp\": 27.17,\n\t\t\t\"feels_like\": 29.24,\n\t\t\t\"pressure\": 1015,\n\t\t\t\"humidity\": 71,\n\t\t\t\"dew_point\": 21.55,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 24,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 1.16,\n\t\t\t\"wind_deg\": 115,\n\t\t\t\"wind_gust\": 1,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 801,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Ein paar Wolken\",\n\t\t\t\t\t\"icon\": \"02n\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673301600,\n\t\t\t\"temp\": 27.07,\n\t\t\t\"feels_like\": 29.06,\n\t\t\t\"pressure\": 1015,\n\t\t\t\"humidity\": 71,\n\t\t\t\"dew_point\": 21.45,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 21,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 1.13,\n\t\t\t\"wind_deg\": 164,\n\t\t\t\"wind_gust\": 1,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 801,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Ein paar Wolken\",\n\t\t\t\t\t\"icon\": \"02n\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673305200,\n\t\t\t\"temp\": 26.99,\n\t\t\t\"feels_like\": 29.09,\n\t\t\t\"pressure\": 1014,\n\t\t\t\"humidity\": 73,\n\t\t\t\"dew_point\": 21.77,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 19,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 1.85,\n\t\t\t\"wind_deg\": 173,\n\t\t\t\"wind_gust\": 1.72,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 801,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Ein paar Wolken\",\n\t\t\t\t\t\"icon\": \"02n\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673308800,\n\t\t\t\"temp\": 26.83,\n\t\t\t\"feels_like\": 28.8,\n\t\t\t\"pressure\": 1014,\n\t\t\t\"humidity\": 73,\n\t\t\t\"dew_point\": 21.66,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 26,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 1.83,\n\t\t\t\"wind_deg\": 170,\n\t\t\t\"wind_gust\": 1.71,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 802,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Mäßig bewölkt\",\n\t\t\t\t\t\"icon\": \"03n\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673312400,\n\t\t\t\"temp\": 26.68,\n\t\t\t\"feels_like\": 28.54,\n\t\t\t\"pressure\": 1013,\n\t\t\t\"humidity\": 73,\n\t\t\t\"dew_point\": 21.52,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 80,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 0.93,\n\t\t\t\"wind_deg\": 164,\n\t\t\t\"wind_gust\": 0.9,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 803,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Überwiegend bewölkt\",\n\t\t\t\t\t\"icon\": \"04n\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673316000,\n\t\t\t\"temp\": 26.54,\n\t\t\t\"feels_like\": 26.54,\n\t\t\t\"pressure\": 1013,\n\t\t\t\"humidity\": 74,\n\t\t\t\"dew_point\": 21.46,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 70,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 0.98,\n\t\t\t\"wind_deg\": 156,\n\t\t\t\"wind_gust\": 0.91,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 803,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Überwiegend bewölkt\",\n\t\t\t\t\t\"icon\": \"04n\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673319600,\n\t\t\t\"temp\": 26.54,\n\t\t\t\"feels_like\": 26.54,\n\t\t\t\"pressure\": 1012,\n\t\t\t\"humidity\": 75,\n\t\t\t\"dew_point\": 21.8,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 52,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 2.26,\n\t\t\t\"wind_deg\": 173,\n\t\t\t\"wind_gust\": 2.2,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 803,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Überwiegend bewölkt\",\n\t\t\t\t\t\"icon\": \"04n\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673323200,\n\t\t\t\"temp\": 26.43,\n\t\t\t\"feels_like\": 26.43,\n\t\t\t\"pressure\": 1012,\n\t\t\t\"humidity\": 75,\n\t\t\t\"dew_point\": 21.75,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 43,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 2.12,\n\t\t\t\"wind_deg\": 173,\n\t\t\t\"wind_gust\": 2,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 802,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Mäßig bewölkt\",\n\t\t\t\t\t\"icon\": \"03n\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673326800,\n\t\t\t\"temp\": 26.38,\n\t\t\t\"feels_like\": 26.38,\n\t\t\t\"pressure\": 1013,\n\t\t\t\"humidity\": 76,\n\t\t\t\"dew_point\": 21.91,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 42,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 2.57,\n\t\t\t\"wind_deg\": 165,\n\t\t\t\"wind_gust\": 2.5,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 802,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Mäßig bewölkt\",\n\t\t\t\t\t\"icon\": \"03n\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673330400,\n\t\t\t\"temp\": 26.36,\n\t\t\t\"feels_like\": 26.36,\n\t\t\t\"pressure\": 1013,\n\t\t\t\"humidity\": 77,\n\t\t\t\"dew_point\": 21.97,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 42,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 2.92,\n\t\t\t\"wind_deg\": 167,\n\t\t\t\"wind_gust\": 2.91,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 802,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Mäßig bewölkt\",\n\t\t\t\t\t\"icon\": \"03n\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673334000,\n\t\t\t\"temp\": 26.45,\n\t\t\t\"feels_like\": 26.45,\n\t\t\t\"pressure\": 1014,\n\t\t\t\"humidity\": 77,\n\t\t\t\"dew_point\": 22.06,\n\t\t\t\"uvi\": 0.52,\n\t\t\t\"clouds\": 96,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 3.09,\n\t\t\t\"wind_deg\": 185,\n\t\t\t\"wind_gust\": 3.1,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 804,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Bedeckt\",\n\t\t\t\t\t\"icon\": \"04d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673337600,\n\t\t\t\"temp\": 26.54,\n\t\t\t\"feels_like\": 26.54,\n\t\t\t\"pressure\": 1014,\n\t\t\t\"humidity\": 77,\n\t\t\t\"dew_point\": 22.14,\n\t\t\t\"uvi\": 2.1,\n\t\t\t\"clouds\": 87,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 3.38,\n\t\t\t\"wind_deg\": 176,\n\t\t\t\"wind_gust\": 3.4,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 804,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Bedeckt\",\n\t\t\t\t\t\"icon\": \"04d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673341200,\n\t\t\t\"temp\": 26.63,\n\t\t\t\"feels_like\": 26.63,\n\t\t\t\"pressure\": 1014,\n\t\t\t\"humidity\": 77,\n\t\t\t\"dew_point\": 22.24,\n\t\t\t\"uvi\": 4.86,\n\t\t\t\"clouds\": 83,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 3.4,\n\t\t\t\"wind_deg\": 179,\n\t\t\t\"wind_gust\": 3.4,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 803,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Überwiegend bewölkt\",\n\t\t\t\t\t\"icon\": \"04d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673344800,\n\t\t\t\"temp\": 26.62,\n\t\t\t\"feels_like\": 26.62,\n\t\t\t\"pressure\": 1014,\n\t\t\t\"humidity\": 77,\n\t\t\t\"dew_point\": 22.23,\n\t\t\t\"uvi\": 8.38,\n\t\t\t\"clouds\": 72,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 3.47,\n\t\t\t\"wind_deg\": 178,\n\t\t\t\"wind_gust\": 3.5,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 803,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Überwiegend bewölkt\",\n\t\t\t\t\t\"icon\": \"04d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673348400,\n\t\t\t\"temp\": 26.71,\n\t\t\t\"feels_like\": 28.81,\n\t\t\t\"pressure\": 1014,\n\t\t\t\"humidity\": 76,\n\t\t\t\"dew_point\": 22.32,\n\t\t\t\"uvi\": 11.06,\n\t\t\t\"clouds\": 62,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 3.82,\n\t\t\t\"wind_deg\": 178,\n\t\t\t\"wind_gust\": 3.81,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 803,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Überwiegend bewölkt\",\n\t\t\t\t\t\"icon\": \"04d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673352000,\n\t\t\t\"temp\": 26.81,\n\t\t\t\"feels_like\": 29,\n\t\t\t\"pressure\": 1013,\n\t\t\t\"humidity\": 76,\n\t\t\t\"dew_point\": 22.32,\n\t\t\t\"uvi\": 12.08,\n\t\t\t\"clouds\": 57,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 4.38,\n\t\t\t\"wind_deg\": 181,\n\t\t\t\"wind_gust\": 4.42,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 803,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Überwiegend bewölkt\",\n\t\t\t\t\t\"icon\": \"04d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673355600,\n\t\t\t\"temp\": 26.91,\n\t\t\t\"feels_like\": 29.19,\n\t\t\t\"pressure\": 1012,\n\t\t\t\"humidity\": 76,\n\t\t\t\"dew_point\": 22.32,\n\t\t\t\"uvi\": 11.21,\n\t\t\t\"clouds\": 14,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 4.96,\n\t\t\t\"wind_deg\": 183,\n\t\t\t\"wind_gust\": 5.01,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 801,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Ein paar Wolken\",\n\t\t\t\t\t\"icon\": \"02d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673359200,\n\t\t\t\"temp\": 27.02,\n\t\t\t\"feels_like\": 29.32,\n\t\t\t\"pressure\": 1012,\n\t\t\t\"humidity\": 75,\n\t\t\t\"dew_point\": 22.23,\n\t\t\t\"uvi\": 8.49,\n\t\t\t\"clouds\": 13,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 4.72,\n\t\t\t\"wind_deg\": 179,\n\t\t\t\"wind_gust\": 4.82,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 801,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Ein paar Wolken\",\n\t\t\t\t\t\"icon\": \"02d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673362800,\n\t\t\t\"temp\": 27.03,\n\t\t\t\"feels_like\": 29.25,\n\t\t\t\"pressure\": 1011,\n\t\t\t\"humidity\": 74,\n\t\t\t\"dew_point\": 22.14,\n\t\t\t\"uvi\": 5.1,\n\t\t\t\"clouds\": 14,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 4.15,\n\t\t\t\"wind_deg\": 180,\n\t\t\t\"wind_gust\": 4.22,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 801,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Ein paar Wolken\",\n\t\t\t\t\t\"icon\": \"02d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673366400,\n\t\t\t\"temp\": 27.12,\n\t\t\t\"feels_like\": 29.42,\n\t\t\t\"pressure\": 1011,\n\t\t\t\"humidity\": 74,\n\t\t\t\"dew_point\": 22.03,\n\t\t\t\"uvi\": 2.21,\n\t\t\t\"clouds\": 13,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 3.61,\n\t\t\t\"wind_deg\": 174,\n\t\t\t\"wind_gust\": 3.71,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 801,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Ein paar Wolken\",\n\t\t\t\t\t\"icon\": \"02d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673370000,\n\t\t\t\"temp\": 27.1,\n\t\t\t\"feels_like\": 29.29,\n\t\t\t\"pressure\": 1012,\n\t\t\t\"humidity\": 73,\n\t\t\t\"dew_point\": 21.92,\n\t\t\t\"uvi\": 0.55,\n\t\t\t\"clouds\": 11,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 3.48,\n\t\t\t\"wind_deg\": 171,\n\t\t\t\"wind_gust\": 3.5,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 801,\n\t\t\t\t\t\"main\": \"Clouds\",\n\t\t\t\t\t\"description\": \"Ein paar Wolken\",\n\t\t\t\t\t\"icon\": \"02d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t},\n\t\t{\n\t\t\t\"dt\": 1673373600,\n\t\t\t\"temp\": 27.18,\n\t\t\t\"feels_like\": 29.54,\n\t\t\t\"pressure\": 1012,\n\t\t\t\"humidity\": 74,\n\t\t\t\"dew_point\": 22.05,\n\t\t\t\"uvi\": 0,\n\t\t\t\"clouds\": 9,\n\t\t\t\"visibility\": 10000,\n\t\t\t\"wind_speed\": 3.39,\n\t\t\t\"wind_deg\": 170,\n\t\t\t\"wind_gust\": 3.51,\n\t\t\t\"weather\": [\n\t\t\t\t{\n\t\t\t\t\t\"id\": 800,\n\t\t\t\t\t\"main\": \"Clear\",\n\t\t\t\t\t\"description\": \"Klarer Himmel\",\n\t\t\t\t\t\"icon\": \"01d\"\n\t\t\t\t}\n\t\t\t],\n\t\t\t\"pop\": 0\n\t\t}\n\t]\n}\n"
  },
  {
    "path": "tests/mocks/whole_day_moved_over_dst_change_berlin.ics",
    "content": "BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART;VALUE=DATE:20241027\nDTEND;VALUE=DATE:20241028\nRRULE:FREQ=DAILY;WKST=SU;COUNT=3\nDTSTAMP:20241020T152634Z\nUID:14nv8jl8d6dvdbl477lod4fftf@google.com\nCREATED:20241020T152434Z\nLAST-MODIFIED:20241020T152536Z\nSEQUENCE:1\nSTATUS:CONFIRMED\nSUMMARY:test whole day moved\nTRANSP:TRANSPARENT\nEND:VEVENT\nBEGIN:VEVENT\nDTSTART;VALUE=DATE:20241030\nDTEND;VALUE=DATE:20241031\nDTSTAMP:20241020T152634Z\nUID:14nv8jl8d6dvdbl477lod4fftf@google.com\nRECURRENCE-ID;VALUE=DATE:20241028\nCREATED:20241020T152434Z\nLAST-MODIFIED:20241020T152536Z\nSEQUENCE:2\nSTATUS:CONFIRMED\nSUMMARY:test whole day moved\nTRANSP:TRANSPARENT\nEND:VEVENT\nEND:VCALENDAR\n"
  },
  {
    "path": "tests/unit/classes/class_spec.js",
    "content": "const path = require(\"node:path\");\nconst { JSDOM } = require(\"jsdom\");\n\ndescribe(\"File js/class\", () => {\n\tdescribe(\"Test function cloneObject\", () => {\n\t\tlet clone;\n\t\tlet dom;\n\n\t\tbeforeAll(() => {\n\t\t\treturn new Promise((done) => {\n\t\t\t\tdom = new JSDOM(\n\t\t\t\t\t`<script>var Log = {log: () => {}};</script>\\\n\t\t\t\t\t<script src=\"file://${path.join(__dirname, \"..\", \"..\", \"..\", \"js\", \"class.js\")}\">`,\n\t\t\t\t\t{ runScripts: \"dangerously\", resources: \"usable\" }\n\t\t\t\t);\n\t\t\t\tdom.window.onload = () => {\n\t\t\t\t\tconst { cloneObject } = dom.window;\n\t\t\t\t\tclone = cloneObject;\n\t\t\t\t\tdone();\n\t\t\t\t};\n\t\t\t});\n\t\t});\n\n\t\tit(\"should clone object\", () => {\n\t\t\tconst expected = { name: \"Rodrigo\", web: \"https://rodrigoramirez.com\", project: \"MagicMirror\" };\n\t\t\tconst obj = clone(expected);\n\t\t\texpect(obj).toEqual(expected);\n\t\t\texpect(expected === obj).toBe(false);\n\t\t});\n\n\t\tit(\"should clone array\", () => {\n\t\t\tconst expected = [1, null, undefined, \"TEST\"];\n\t\t\tconst obj = clone(expected);\n\t\t\texpect(obj).toEqual(expected);\n\t\t\texpect(expected === obj).toBe(false);\n\t\t});\n\n\t\tit(\"should clone number\", () => {\n\t\t\tlet expected = 1;\n\t\t\tlet obj = clone(expected);\n\t\t\texpect(obj).toBe(expected);\n\n\t\t\texpected = 1.23;\n\t\t\tobj = clone(expected);\n\t\t\texpect(obj).toBe(expected);\n\t\t});\n\n\t\tit(\"should clone string\", () => {\n\t\t\tconst expected = \"Perfect stranger\";\n\t\t\tconst obj = clone(expected);\n\t\t\texpect(obj).toBe(expected);\n\t\t});\n\n\t\tit(\"should clone regex\", () => {\n\t\t\tconst expected = /.*Magic/;\n\t\t\tconst obj = clone(expected);\n\t\t\texpect(obj).toEqual(expected);\n\t\t\texpect(expected === obj).toBe(false);\n\t\t});\n\n\t\tit(\"should clone undefined\", () => {\n\t\t\tconst expected = undefined;\n\t\t\tconst obj = clone(expected);\n\t\t\texpect(obj).toBe(expected);\n\t\t});\n\n\t\tit(\"should clone null\", () => {\n\t\t\tconst expected = null;\n\t\t\tconst obj = clone(expected);\n\t\t\texpect(obj).toBe(expected);\n\t\t});\n\n\t\tit(\"should clone nested object\", () => {\n\t\t\tconst expected = {\n\t\t\t\tname: \"fewieden\",\n\t\t\t\tlink: \"https://github.com/fewieden\",\n\t\t\t\tversions: [\"2.0\", \"2.1\", \"2.2\"],\n\t\t\t\tanswerForAllQuestions: 42,\n\t\t\t\tproperties: {\n\t\t\t\t\titems: [{ foo: \"bar\" }, { lorem: \"ipsum\" }],\n\t\t\t\t\tinvalid: undefined,\n\t\t\t\t\tnothing: null\n\t\t\t\t}\n\t\t\t};\n\t\t\tconst obj = clone(expected);\n\t\t\texpect(obj).toEqual(expected);\n\t\t\texpect(expected === obj).toBe(false);\n\t\t\texpect(expected.versions === obj.versions).toBe(false);\n\t\t\texpect(expected.properties === obj.properties).toBe(false);\n\t\t\texpect(expected.properties.items === obj.properties.items).toBe(false);\n\t\t\texpect(expected.properties.items[0] === obj.properties.items[0]).toBe(false);\n\t\t\texpect(expected.properties.items[1] === obj.properties.items[1]).toBe(false);\n\t\t});\n\n\t\tdescribe(\"Test lockstring code\", () => {\n\t\t\tlet log;\n\n\t\t\tbeforeAll(() => {\n\t\t\t\tlog = dom.window.Log.log;\n\t\t\t\tdom.window.Log.log = (str) => {\n\t\t\t\t\texpect(str).toBe(\"lockStrings\");\n\t\t\t\t};\n\t\t\t});\n\n\t\t\tafterAll(() => {\n\t\t\t\tdom.window.Log.log = log;\n\t\t\t});\n\n\t\t\tit(\"should clone object and log lockStrings\", () => {\n\t\t\t\tconst expected = { name: \"Module\", lockStrings: \"stringLock\" };\n\t\t\t\tconst obj = clone(expected);\n\t\t\t\texpect(obj).toEqual(expected);\n\t\t\t\texpect(expected === obj).toBe(false);\n\t\t\t});\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/unit/classes/deprecated_spec.js",
    "content": "const deprecated = require(\"../../../js/deprecated\");\n\ndescribe(\"Deprecated\", () => {\n\tit(\"should be an object\", () => {\n\t\texpect(typeof deprecated).toBe(\"object\");\n\t});\n\n\tit(\"should contain configs array with deprecated options as strings\", () => {\n\t\texpect(Array.isArray([\"deprecated.configs\"])).toBe(true);\n\t\tfor (let option of deprecated.configs) {\n\t\t\texpect(typeof option).toBe(\"string\");\n\t\t}\n\t\texpect(deprecated.configs).toEqual(expect.arrayContaining([\"kioskmode\"]));\n\t});\n});\n"
  },
  {
    "path": "tests/unit/classes/translator_spec.js",
    "content": "const fs = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst helmet = require(\"helmet\");\nconst { JSDOM } = require(\"jsdom\");\nconst express = require(\"express\");\n\n/**\n * Helper function to create a fresh Translator instance with DOM environment.\n * @returns {object} Object containing window and Translator\n */\nfunction createTranslationTestEnvironment () {\n\tconst translatorJs = fs.readFileSync(path.join(__dirname, \"..\", \"..\", \"..\", \"js\", \"translator.js\"), \"utf-8\");\n\tconst dom = new JSDOM(\"\", { url: \"http://localhost:3001\", runScripts: \"outside-only\" });\n\n\tdom.window.Log = { log: vi.fn(), error: vi.fn() };\n\tdom.window.fetch = fetch;\n\tdom.window.eval(translatorJs);\n\n\treturn { window: dom.window, Translator: dom.window.Translator };\n}\n\ndescribe(\"Translator\", () => {\n\tlet server;\n\tconst sockets = new Set();\n\tconst translationTestData = JSON.parse(fs.readFileSync(path.join(__dirname, \"..\", \"..\", \"..\", \"tests\", \"mocks\", \"translation_test.json\"), \"utf8\"));\n\n\tbeforeAll(() => {\n\t\tconst app = express();\n\t\tapp.use(helmet());\n\t\tapp.use((req, res, next) => {\n\t\t\tres.header(\"Access-Control-Allow-Origin\", \"*\");\n\t\t\tnext();\n\t\t});\n\t\tapp.use(\"/translations\", express.static(path.join(__dirname, \"..\", \"..\", \"..\", \"tests\", \"mocks\")));\n\n\t\tserver = app.listen(3001);\n\n\t\tserver.on(\"connection\", (socket) => {\n\t\t\tsockets.add(socket);\n\t\t});\n\t});\n\n\tafterAll(async () => {\n\t\tfor (const socket of sockets) {\n\t\t\tsocket.destroy();\n\t\t\tsockets.delete(socket);\n\t\t}\n\n\t\tawait server.close();\n\t});\n\n\tdescribe(\"translate\", () => {\n\t\tconst translations = {\n\t\t\t\"MMM-Module\": {\n\t\t\t\tHello: \"Hallo\",\n\t\t\t\t\"Hello {username}\": \"Hallo {username}\"\n\t\t\t}\n\t\t};\n\n\t\tconst coreTranslations = {\n\t\t\tHello: \"XXX\",\n\t\t\t\"Hello {username}\": \"XXX\",\n\t\t\tFOO: \"Foo\",\n\t\t\t\"BAR {something}\": \"Bar {something}\"\n\t\t};\n\n\t\tconst translationsFallback = {\n\t\t\t\"MMM-Module\": {\n\t\t\t\tHello: \"XXX\",\n\t\t\t\t\"Hello {username}\": \"XXX\",\n\t\t\t\tFOO: \"XXX\",\n\t\t\t\t\"BAR {something}\": \"XXX\",\n\t\t\t\t\"A key\": \"A translation\"\n\t\t\t}\n\t\t};\n\n\t\tconst coreTranslationsFallback = {\n\t\t\tFOO: \"XXX\",\n\t\t\t\"BAR {something}\": \"XXX\",\n\t\t\tHello: \"XXX\",\n\t\t\t\"Hello {username}\": \"XXX\",\n\t\t\t\"A key\": \"XXX\",\n\t\t\tFallback: \"core fallback\"\n\t\t};\n\n\t\t/**\n\t\t * @param {object} Translator the global Translator object\n\t\t */\n\t\tconst setTranslations = (Translator) => {\n\t\t\tTranslator.translations = translations;\n\t\t\tTranslator.coreTranslations = coreTranslations;\n\t\t\tTranslator.translationsFallback = translationsFallback;\n\t\t\tTranslator.coreTranslationsFallback = coreTranslationsFallback;\n\t\t};\n\n\t\tit(\"should return custom module translation\", async () => {\n\t\t\tconst { Translator } = createTranslationTestEnvironment();\n\t\t\tsetTranslations(Translator);\n\n\t\t\tlet translation = Translator.translate({ name: \"MMM-Module\" }, \"Hello\");\n\t\t\texpect(translation).toBe(\"Hallo\");\n\n\t\t\ttranslation = Translator.translate({ name: \"MMM-Module\" }, \"Hello {username}\", { username: \"fewieden\" });\n\t\t\texpect(translation).toBe(\"Hallo fewieden\");\n\t\t});\n\n\t\tit(\"should return core translation\", async () => {\n\t\t\tconst { Translator } = createTranslationTestEnvironment();\n\t\t\tsetTranslations(Translator);\n\t\t\tlet translation = Translator.translate({ name: \"MMM-Module\" }, \"FOO\");\n\t\t\texpect(translation).toBe(\"Foo\");\n\t\t\ttranslation = Translator.translate({ name: \"MMM-Module\" }, \"BAR {something}\", { something: \"Lorem Ipsum\" });\n\t\t\texpect(translation).toBe(\"Bar Lorem Ipsum\");\n\t\t});\n\n\t\tit(\"should return custom module translation fallback\", async () => {\n\t\t\tconst { Translator } = createTranslationTestEnvironment();\n\t\t\tsetTranslations(Translator);\n\t\t\tconst translation = Translator.translate({ name: \"MMM-Module\" }, \"A key\");\n\t\t\texpect(translation).toBe(\"A translation\");\n\t\t});\n\n\t\tit(\"should return core translation fallback\", async () => {\n\t\t\tconst { Translator } = createTranslationTestEnvironment();\n\t\t\tsetTranslations(Translator);\n\t\t\tconst translation = Translator.translate({ name: \"MMM-Module\" }, \"Fallback\");\n\t\t\texpect(translation).toBe(\"core fallback\");\n\t\t});\n\n\t\tit(\"should return translation with placeholder for missing variables\", async () => {\n\t\t\tconst { Translator } = createTranslationTestEnvironment();\n\t\t\tsetTranslations(Translator);\n\t\t\tconst translation = Translator.translate({ name: \"MMM-Module\" }, \"Hello {username}\");\n\t\t\texpect(translation).toBe(\"Hallo {username}\");\n\t\t});\n\n\t\tit(\"should return key if no translation was found\", async () => {\n\t\t\tconst { Translator } = createTranslationTestEnvironment();\n\t\t\tsetTranslations(Translator);\n\t\t\tconst translation = Translator.translate({ name: \"MMM-Module\" }, \"MISSING\");\n\t\t\texpect(translation).toBe(\"MISSING\");\n\t\t});\n\t});\n\n\tdescribe(\"load\", () => {\n\t\tconst mmm = {\n\t\t\tname: \"TranslationTest\",\n\t\t\tfile (file) {\n\t\t\t\treturn `http://localhost:3001/translations/${file}`;\n\t\t\t}\n\t\t};\n\n\t\tit(\"should load translations\", async () => {\n\t\t\tconst { Translator } = createTranslationTestEnvironment();\n\t\t\tconst file = \"translation_test.json\";\n\n\t\t\tawait Translator.load(mmm, file, false);\n\t\t\tconst json = JSON.parse(fs.readFileSync(path.join(__dirname, \"..\", \"..\", \"..\", \"tests\", \"mocks\", file), \"utf8\"));\n\t\t\texpect(Translator.translations[mmm.name]).toEqual(json);\n\t\t});\n\n\t\tit(\"should load translation fallbacks\", async () => {\n\t\t\tconst { Translator } = createTranslationTestEnvironment();\n\t\t\tconst file = \"translation_test.json\";\n\n\t\t\tawait Translator.load(mmm, file, true);\n\t\t\tconst json = JSON.parse(fs.readFileSync(path.join(__dirname, \"..\", \"..\", \"..\", \"tests\", \"mocks\", file), \"utf8\"));\n\t\t\texpect(Translator.translationsFallback[mmm.name]).toEqual(json);\n\t\t});\n\n\t\tit(\"should not load translations, if module fallback exists\", async () => {\n\t\t\tconst { Translator } = createTranslationTestEnvironment();\n\t\t\tconst file = \"translation_test.json\";\n\n\t\t\tTranslator.translationsFallback[mmm.name] = {\n\t\t\t\tHello: \"Hallo\"\n\t\t\t};\n\n\t\t\tawait Translator.load(mmm, file, false);\n\t\t\texpect(Translator.translations[mmm.name]).toBeUndefined();\n\t\t\texpect(Translator.translationsFallback[mmm.name]).toEqual({\n\t\t\t\tHello: \"Hallo\"\n\t\t\t});\n\t\t});\n\t});\n\n\tdescribe(\"loadCoreTranslations\", () => {\n\t\tit(\"should load core translations and fallback\", async () => {\n\t\t\tconst { window, Translator } = createTranslationTestEnvironment();\n\t\t\twindow.translations = { en: \"http://localhost:3001/translations/translation_test.json\" };\n\t\t\tawait Translator.loadCoreTranslations(\"en\");\n\n\t\t\tconst en = translationTestData;\n\n\t\t\texpect(Translator.coreTranslations).toEqual(en);\n\t\t\texpect(Translator.coreTranslationsFallback).toEqual(en);\n\t\t});\n\n\t\tit(\"should load core fallback if language cannot be found\", async () => {\n\t\t\tconst { window, Translator } = createTranslationTestEnvironment();\n\t\t\twindow.translations = { en: \"http://localhost:3001/translations/translation_test.json\" };\n\t\t\tawait Translator.loadCoreTranslations(\"MISSINGLANG\");\n\n\t\t\tconst en = translationTestData;\n\n\t\t\texpect(Translator.coreTranslations).toEqual({});\n\t\t\texpect(Translator.coreTranslationsFallback).toEqual(en);\n\t\t});\n\t});\n\n\tdescribe(\"loadCoreTranslationsFallback\", () => {\n\t\tit(\"should load core translations fallback\", async () => {\n\t\t\tconst { window, Translator } = createTranslationTestEnvironment();\n\t\t\twindow.translations = { en: \"http://localhost:3001/translations/translation_test.json\" };\n\t\t\tawait Translator.loadCoreTranslationsFallback();\n\n\t\t\tconst en = translationTestData;\n\n\t\t\texpect(Translator.coreTranslationsFallback).toEqual(en);\n\t\t});\n\n\t\tit(\"should load core fallback if language cannot be found\", async () => {\n\t\t\tconst { window, Translator } = createTranslationTestEnvironment();\n\t\t\twindow.translations = {};\n\t\t\tawait Translator.loadCoreTranslations();\n\n\t\t\texpect(Translator.coreTranslationsFallback).toEqual({});\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/unit/classes/utils_spec.js",
    "content": "const Utils = require(\"../../../js/utils\");\n\ndescribe(\"Utils\", () => {\n\tit(\"should output system information\", async () => {\n\t\tawait expect(Utils.logSystemInformation()).resolves.toContain(\"platform: linux\");\n\t});\n});\n"
  },
  {
    "path": "tests/unit/functions/__snapshots__/updatenotification_spec.js.snap",
    "content": "// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html\n\nexports[`Updatenotification > MagicMirror on develop > returns status information 1`] = `\n{\n  \"behind\": 5,\n  \"current\": \"develop\",\n  \"hash\": \"332e429a41f1a2339afd4f0ae96dd125da6beada\",\n  \"isBehindInStatus\": false,\n  \"module\": \"MagicMirror\",\n  \"tracking\": \"origin/develop\",\n}\n`;\n\nexports[`Updatenotification > MagicMirror on develop > returns status information early if isBehindInStatus 1`] = `\n{\n  \"behind\": 5,\n  \"current\": \"develop\",\n  \"hash\": \"332e429a41f1a2339afd4f0ae96dd125da6beada\",\n  \"isBehindInStatus\": true,\n  \"module\": \"MagicMirror\",\n  \"tracking\": \"origin/develop\",\n}\n`;\n\nexports[`Updatenotification > MagicMirror on master (empty taglist) > returns status information 1`] = `\n{\n  \"behind\": 5,\n  \"current\": \"master\",\n  \"hash\": \"332e429a41f1a2339afd4f0ae96dd125da6beada\",\n  \"isBehindInStatus\": false,\n  \"module\": \"MagicMirror\",\n  \"tracking\": \"origin/master\",\n}\n`;\n\nexports[`Updatenotification > MagicMirror on master (empty taglist) > returns status information early if isBehindInStatus 1`] = `\n{\n  \"behind\": 5,\n  \"current\": \"master\",\n  \"hash\": \"332e429a41f1a2339afd4f0ae96dd125da6beada\",\n  \"isBehindInStatus\": true,\n  \"module\": \"MagicMirror\",\n  \"tracking\": \"origin/master\",\n}\n`;\n\nexports[`Updatenotification > MagicMirror on master with match in taglist > returns status information 1`] = `\n{\n  \"behind\": 5,\n  \"current\": \"master\",\n  \"hash\": \"332e429a41f1a2339afd4f0ae96dd125da6beada\",\n  \"isBehindInStatus\": false,\n  \"module\": \"MagicMirror\",\n  \"tracking\": \"origin/master\",\n}\n`;\n\nexports[`Updatenotification > MagicMirror on master with match in taglist > returns status information early if isBehindInStatus 1`] = `\n{\n  \"behind\": 5,\n  \"current\": \"master\",\n  \"hash\": \"332e429a41f1a2339afd4f0ae96dd125da6beada\",\n  \"isBehindInStatus\": true,\n  \"module\": \"MagicMirror\",\n  \"tracking\": \"origin/master\",\n}\n`;\n\nexports[`Updatenotification > MagicMirror on master without match in taglist > returns status information 1`] = `\n{\n  \"behind\": 0,\n  \"current\": \"master\",\n  \"hash\": \"332e429a41f1a2339afd4f0ae96dd125da6beada\",\n  \"isBehindInStatus\": false,\n  \"module\": \"MagicMirror\",\n  \"tracking\": \"origin/master\",\n}\n`;\n\nexports[`Updatenotification > MagicMirror on master without match in taglist > returns status information early if isBehindInStatus 1`] = `\n{\n  \"behind\": 0,\n  \"current\": \"master\",\n  \"hash\": \"332e429a41f1a2339afd4f0ae96dd125da6beada\",\n  \"isBehindInStatus\": true,\n  \"module\": \"MagicMirror\",\n  \"tracking\": \"origin/master\",\n}\n`;\n\nexports[`Updatenotification > custom module > returns status information without hash 1`] = `\n{\n  \"behind\": 7,\n  \"current\": \"master\",\n  \"hash\": \"\",\n  \"isBehindInStatus\": false,\n  \"module\": \"MMM-Fuel\",\n  \"tracking\": \"origin/master\",\n}\n`;\n"
  },
  {
    "path": "tests/unit/functions/cmp_versions_spec.js",
    "content": "const path = require(\"node:path\");\nconst { JSDOM } = require(\"jsdom\");\n\ndescribe(\"Test function cmpVersions in js/module.js\", () => {\n\tlet cmp;\n\n\tbeforeAll(() => {\n\t\treturn new Promise((done) => {\n\t\t\tconst dom = new JSDOM(\n\t\t\t\t`<script>var Class = {extend: () => { return {}; }};</script>\\\n\t\t\t\t<script src=\"file://${path.join(__dirname, \"..\", \"..\", \"..\", \"js\", \"module.js\")}\">`,\n\t\t\t\t{ runScripts: \"dangerously\", resources: \"usable\" }\n\t\t\t);\n\t\t\tdom.window.onload = () => {\n\t\t\t\tconst { cmpVersions } = dom.window;\n\t\t\t\tcmp = cmpVersions;\n\t\t\t\tdone();\n\t\t\t};\n\t\t});\n\t});\n\n\tit(\"should return -1 when comparing 2.1 to 2.2\", () => {\n\t\texpect(cmp(\"2.1\", \"2.2\")).toBe(-1);\n\t});\n\n\tit(\"should be return 0 when comparing 2.2 to 2.2\", () => {\n\t\texpect(cmp(\"2.2\", \"2.2\")).toBe(0);\n\t});\n\n\tit(\"should be return 1 when comparing 1.1 to 1.0\", () => {\n\t\texpect(cmp(\"1.1\", \"1.0\")).toBe(1);\n\t});\n});\n"
  },
  {
    "path": "tests/unit/functions/server_functions_spec.js",
    "content": "const { cors, getUserAgent } = require(\"#server_functions\");\n\ndescribe(\"server_functions tests\", () => {\n\tdescribe(\"The cors method\", () => {\n\t\tlet fetchResponse;\n\t\tlet fetchResponseHeadersGet;\n\t\tlet fetchResponseHeadersText;\n\t\tlet corsResponse;\n\t\tlet request;\n\t\tlet fetchMock;\n\n\t\tbeforeEach(() => {\n\t\t\tfetchResponseHeadersGet = vi.fn(() => {});\n\t\t\tfetchResponseHeadersText = vi.fn(() => {});\n\t\t\tfetchResponse = {\n\t\t\t\theaders: {\n\t\t\t\t\tget: fetchResponseHeadersGet\n\t\t\t\t},\n\t\t\t\ttext: fetchResponseHeadersText,\n\t\t\t\tok: true\n\t\t\t};\n\n\t\t\tfetch = vi.fn();\n\t\t\tfetch.mockImplementation(() => fetchResponse);\n\n\t\t\tfetchMock = fetch;\n\n\t\t\tcorsResponse = {\n\t\t\t\tset: vi.fn(() => {}),\n\t\t\t\tsend: vi.fn(() => {}),\n\t\t\t\tstatus: vi.fn(function (code) {\n\t\t\t\t\tthis.statusCode = code;\n\t\t\t\t\treturn this;\n\t\t\t\t}),\n\t\t\t\tjson: vi.fn(() => {})\n\t\t\t};\n\n\t\t\trequest = {\n\t\t\t\turl: \"/cors?url=www.test.com\"\n\t\t\t};\n\t\t});\n\n\t\tit(\"Calls correct URL once\", async () => {\n\t\t\tconst urlToCall = \"http://www.test.com/path?param1=value1\";\n\t\t\trequest.url = `/cors?url=${urlToCall}`;\n\n\t\t\tawait cors(request, corsResponse);\n\n\t\t\texpect(fetchMock.mock.calls).toHaveLength(1);\n\t\t\texpect(fetchMock.mock.calls[0][0]).toBe(urlToCall);\n\t\t});\n\n\t\tit(\"Forwards Content-Type if json\", async () => {\n\t\t\tfetchResponseHeadersGet.mockImplementation(() => \"json\");\n\n\t\t\tawait cors(request, corsResponse);\n\n\t\t\texpect(fetchResponseHeadersGet.mock.calls).toHaveLength(1);\n\t\t\texpect(fetchResponseHeadersGet.mock.calls[0][0]).toBe(\"Content-Type\");\n\n\t\t\texpect(corsResponse.set.mock.calls).toHaveLength(1);\n\t\t\texpect(corsResponse.set.mock.calls[0][0]).toBe(\"Content-Type\");\n\t\t\texpect(corsResponse.set.mock.calls[0][1]).toBe(\"json\");\n\t\t});\n\n\t\tit(\"Forwards Content-Type if xml\", async () => {\n\t\t\tfetchResponseHeadersGet.mockImplementation(() => \"xml\");\n\n\t\t\tawait cors(request, corsResponse);\n\n\t\t\texpect(fetchResponseHeadersGet.mock.calls).toHaveLength(1);\n\t\t\texpect(fetchResponseHeadersGet.mock.calls[0][0]).toBe(\"Content-Type\");\n\n\t\t\texpect(corsResponse.set.mock.calls).toHaveLength(1);\n\t\t\texpect(corsResponse.set.mock.calls[0][0]).toBe(\"Content-Type\");\n\t\t\texpect(corsResponse.set.mock.calls[0][1]).toBe(\"xml\");\n\t\t});\n\n\t\tit(\"Sends correct data from response\", async () => {\n\t\t\tconst responseData = \"some data\";\n\t\t\tfetchResponseHeadersText.mockImplementation(() => responseData);\n\n\t\t\tlet sentData;\n\t\t\tcorsResponse.send = vi.fn((input) => {\n\t\t\t\tsentData = input;\n\t\t\t});\n\n\t\t\tawait cors(request, corsResponse);\n\n\t\t\texpect(fetchResponseHeadersText.mock.calls).toHaveLength(1);\n\t\t\texpect(sentData).toBe(responseData);\n\t\t});\n\n\t\tit(\"Sends error data from response\", async () => {\n\t\t\tconst error = new Error(\"error data\");\n\t\t\tfetchResponseHeadersText.mockImplementation(() => {\n\t\t\t\tthrow error;\n\t\t\t});\n\n\t\t\tawait cors(request, corsResponse);\n\n\t\t\texpect(fetchResponseHeadersText.mock.calls).toHaveLength(1);\n\t\t\texpect(corsResponse.status).toHaveBeenCalledWith(500);\n\t\t\texpect(corsResponse.json).toHaveBeenCalledWith({ error: error.message });\n\t\t});\n\n\t\tit(\"Fetches with user agent by default\", async () => {\n\t\t\tawait cors(request, corsResponse);\n\n\t\t\texpect(fetchMock.mock.calls).toHaveLength(1);\n\t\t\texpect(fetchMock.mock.calls[0][1]).toHaveProperty(\"headers\");\n\t\t\texpect(fetchMock.mock.calls[0][1].headers).toHaveProperty(\"User-Agent\");\n\t\t});\n\n\t\tit(\"Fetches with specified headers\", async () => {\n\t\t\tconst headersParam = \"sendheaders=header1:value1,header2:value2\";\n\t\t\tconst urlParam = \"http://www.test.com/path?param1=value1\";\n\t\t\trequest.url = `/cors?${headersParam}&url=${urlParam}`;\n\n\t\t\tawait cors(request, corsResponse);\n\n\t\t\texpect(fetchMock.mock.calls).toHaveLength(1);\n\t\t\texpect(fetchMock.mock.calls[0][1]).toHaveProperty(\"headers\");\n\t\t\texpect(fetchMock.mock.calls[0][1].headers).toHaveProperty(\"header1\", \"value1\");\n\t\t\texpect(fetchMock.mock.calls[0][1].headers).toHaveProperty(\"header2\", \"value2\");\n\t\t});\n\n\t\tit(\"Sends specified headers\", async () => {\n\t\t\tfetchResponseHeadersGet.mockImplementation((input) => input.replace(\"header\", \"value\"));\n\n\t\t\tconst expectedheaders = \"expectedheaders=header1,header2\";\n\t\t\tconst urlParam = \"http://www.test.com/path?param1=value1\";\n\t\t\trequest.url = `/cors?${expectedheaders}&url=${urlParam}`;\n\n\t\t\tawait cors(request, corsResponse);\n\n\t\t\texpect(fetchMock.mock.calls).toHaveLength(1);\n\t\t\texpect(fetchMock.mock.calls[0][1]).toHaveProperty(\"headers\");\n\t\t\texpect(corsResponse.set.mock.calls).toHaveLength(3);\n\t\t\texpect(corsResponse.set.mock.calls[0][0]).toBe(\"Content-Type\");\n\t\t\texpect(corsResponse.set.mock.calls[1][0]).toBe(\"header1\");\n\t\t\texpect(corsResponse.set.mock.calls[1][1]).toBe(\"value1\");\n\t\t\texpect(corsResponse.set.mock.calls[2][0]).toBe(\"header2\");\n\t\t\texpect(corsResponse.set.mock.calls[2][1]).toBe(\"value2\");\n\t\t});\n\n\t\tit(\"Gets User-Agent from configuration\", async () => {\n\t\t\tglobal.config = {};\n\t\t\tlet userAgent;\n\n\t\t\tuserAgent = getUserAgent();\n\t\t\texpect(userAgent).toContain(\"Mozilla/5.0 (Node.js \");\n\n\t\t\tglobal.config.userAgent = \"Mozilla/5.0 (Foo)\";\n\t\t\tuserAgent = getUserAgent();\n\t\t\texpect(userAgent).toBe(\"Mozilla/5.0 (Foo)\");\n\n\t\t\tglobal.config.userAgent = () => \"Mozilla/5.0 (Bar)\";\n\t\t\tuserAgent = getUserAgent();\n\t\t\texpect(userAgent).toBe(\"Mozilla/5.0 (Bar)\");\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/unit/functions/updatenotification_spec.js",
    "content": "import { vi, describe, beforeEach, afterEach, it, expect } from \"vitest\";\n\n/**\n * Creates a fresh GitHelper instance with isolated mocks for each test run.\n * @param {{ current: import(\"vitest\").Mock | null }} fsStatSyncMockRef reference to the mocked fs.statSync.\n * @param {{ current: { error: import(\"vitest\").Mock; info: import(\"vitest\").Mock } | null }} loggerMockRef reference to logger stubs.\n * @param {{ current: import(\"vitest\").MockInstance | null }} execShellSpyRef reference to the execShell spy.\n * @returns {Promise<unknown>} resolved GitHelper instance.\n */\nasync function createGitHelper (fsStatSyncMockRef, loggerMockRef, execShellSpyRef) {\n\tvi.resetModules();\n\n\tfsStatSyncMockRef.current = vi.fn();\n\tloggerMockRef.current = { error: vi.fn(), info: vi.fn() };\n\n\tvi.doMock(\"node:fs\", () => ({\n\t\tstatSync: fsStatSyncMockRef.current\n\t}));\n\n\tvi.doMock(\"logger\", () => loggerMockRef.current);\n\n\tconst gitHelperModule = await import(\"../../../modules/default/updatenotification/git_helper\");\n\tconst GitHelper = gitHelperModule.default || gitHelperModule;\n\tconst instance = new GitHelper();\n\texecShellSpyRef.current = vi.spyOn(instance, \"execShell\");\n\tinstance.__loggerMock = loggerMockRef.current;\n\treturn instance;\n}\n\ndescribe(\"Updatenotification\", () => {\n\tconst fsStatSyncMockRef = { current: null };\n\tconst loggerMockRef = { current: null };\n\tconst execShellSpyRef = { current: null };\n\tlet gitHelper;\n\n\tlet gitRemoteOut;\n\tlet gitRevParseOut;\n\tlet gitStatusOut;\n\tlet gitFetchOut;\n\tlet gitRevListCountOut;\n\tlet gitRevListOut;\n\tlet gitFetchErr;\n\tlet gitTagListOut;\n\n\tconst getExecutedCommands = () => execShellSpyRef.current.mock.calls.map(([command]) => command);\n\n\tbeforeEach(async () => {\n\t\tgitHelper = await createGitHelper(fsStatSyncMockRef, loggerMockRef, execShellSpyRef);\n\n\t\tfsStatSyncMockRef.current.mockReturnValue({ isDirectory: () => true });\n\n\t\tgitRemoteOut = \"\";\n\t\tgitRevParseOut = \"\";\n\t\tgitStatusOut = \"\";\n\t\tgitFetchOut = \"\";\n\t\tgitRevListCountOut = \"\";\n\t\tgitRevListOut = \"\";\n\t\tgitFetchErr = \"\";\n\t\tgitTagListOut = \"\";\n\n\t\texecShellSpyRef.current.mockImplementation((command) => {\n\t\t\tif (command.includes(\"git remote -v\")) {\n\t\t\t\treturn Promise.resolve({ stdout: gitRemoteOut, stderr: \"\" });\n\t\t\t}\n\n\t\t\tif (command.includes(\"git rev-parse HEAD\")) {\n\t\t\t\treturn Promise.resolve({ stdout: gitRevParseOut, stderr: \"\" });\n\t\t\t}\n\n\t\t\tif (command.includes(\"git status -sb\")) {\n\t\t\t\treturn Promise.resolve({ stdout: gitStatusOut, stderr: \"\" });\n\t\t\t}\n\n\t\t\tif (command.includes(\"git fetch -n --dry-run\")) {\n\t\t\t\treturn Promise.resolve({ stdout: gitFetchOut, stderr: gitFetchErr });\n\t\t\t}\n\n\t\t\tif (command.includes(\"git rev-list --ancestry-path --count\")) {\n\t\t\t\treturn Promise.resolve({ stdout: gitRevListCountOut, stderr: \"\" });\n\t\t\t}\n\n\t\t\tif (command.includes(\"git rev-list --ancestry-path\")) {\n\t\t\t\treturn Promise.resolve({ stdout: gitRevListOut, stderr: \"\" });\n\t\t\t}\n\n\t\t\tif (command.includes(\"git ls-remote -q --tags --refs\")) {\n\t\t\t\treturn Promise.resolve({ stdout: gitTagListOut, stderr: \"\" });\n\t\t\t}\n\n\t\t\treturn Promise.resolve({ stdout: \"\", stderr: \"\" });\n\t\t});\n\n\t\tif (gitHelper.execShell !== execShellSpyRef.current) {\n\t\t\tthrow new Error(\"execShell spy not applied\");\n\t\t}\n\t});\n\n\tafterEach(() => {\n\t\tgitHelper.gitRepos = [];\n\t\tvi.resetAllMocks();\n\t});\n\n\tdescribe(\"MagicMirror on develop\", () => {\n\t\tconst moduleName = \"MagicMirror\";\n\n\t\tbeforeEach(() => {\n\t\t\tgitRemoteOut = \"origin\\tgit@github.com:MagicMirrorOrg/MagicMirror.git (fetch)\\norigin\\tgit@github.com:MagicMirrorOrg/MagicMirror.git (push)\\n\";\n\t\t\tgitRevParseOut = \"332e429a41f1a2339afd4f0ae96dd125da6beada\";\n\t\t\tgitStatusOut = \"## develop...origin/develop\\n M tests/unit/functions/updatenotification_spec.js\\n\";\n\t\t\tgitFetchErr = \"From github.com:MagicMirrorOrg/MagicMirror\\n60e0377..332e429  develop          -> origin/develop\\n\";\n\t\t\tgitRevListCountOut = \"5\";\n\n\t\t\tgitHelper.gitRepos = [{ module: moduleName, folder: \"mock-path\" }];\n\t\t});\n\n\t\tit(\"returns status information\", async () => {\n\t\t\tconst repos = await gitHelper.getRepos();\n\t\t\texpect(repos[0]).toMatchSnapshot();\n\t\t\texpect(getExecutedCommands()).toMatchInlineSnapshot(`\n\t\t\t\t[\n\t\t\t\t  \"cd mock-path && git rev-parse HEAD\",\n\t\t\t\t  \"cd mock-path && git status -sb\",\n\t\t\t\t  \"cd mock-path && git fetch -n --dry-run\",\n\t\t\t\t  \"cd mock-path && git rev-list --ancestry-path --count 60e0377..332e429  develop\",\n\t\t\t\t]\n\t\t\t`);\n\t\t});\n\n\t\tit(\"returns status information early if isBehindInStatus\", async () => {\n\t\t\tgitStatusOut = \"## develop...origin/develop [behind 5]\";\n\n\t\t\tconst repos = await gitHelper.getRepos();\n\t\t\texpect(repos[0]).toMatchSnapshot();\n\t\t\texpect(getExecutedCommands()).toMatchInlineSnapshot(`\n\t\t\t\t[\n\t\t\t\t  \"cd mock-path && git rev-parse HEAD\",\n\t\t\t\t  \"cd mock-path && git status -sb\",\n\t\t\t\t]\n\t\t\t`);\n\t\t});\n\n\t\tit(\"excludes repo if status can't be retrieved\", async () => {\n\t\t\tconst errorMessage = \"Failed to retrieve status\";\n\t\t\texecShellSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage)));\n\n\t\t\texpect(gitHelper.gitRepos).toHaveLength(1);\n\t\t\tconst repos = await gitHelper.getRepos();\n\t\t\texpect(repos).toHaveLength(0);\n\t\t\texpect(execShellSpyRef.current.mock.calls.length).toBeGreaterThan(0);\n\t\t});\n\t});\n\n\tdescribe(\"MagicMirror on master (empty taglist)\", () => {\n\t\tconst moduleName = \"MagicMirror\";\n\n\t\tbeforeEach(() => {\n\t\t\tgitRemoteOut = \"origin\\tgit@github.com:MagicMirrorOrg/MagicMirror.git (fetch)\\norigin\\tgit@github.com:MagicMirrorOrg/MagicMirror.git (push)\\n\";\n\t\t\tgitRevParseOut = \"332e429a41f1a2339afd4f0ae96dd125da6beada\";\n\t\t\tgitStatusOut = \"## master...origin/master\\n M tests/unit/functions/updatenotification_spec.js\\n\";\n\t\t\tgitFetchErr = \"From github.com:MagicMirrorOrg/MagicMirror\\n60e0377..332e429  master          -> origin/master\\n\";\n\t\t\tgitRevListCountOut = \"5\";\n\n\t\t\tgitHelper.gitRepos = [{ module: moduleName, folder: \"mock-path\" }];\n\t\t});\n\n\t\tit(\"returns status information\", async () => {\n\t\t\tconst repos = await gitHelper.getRepos();\n\t\t\texpect(repos[0]).toMatchSnapshot();\n\t\t\texpect(getExecutedCommands()).toMatchInlineSnapshot(`\n\t\t\t\t[\n\t\t\t\t  \"cd mock-path && git rev-parse HEAD\",\n\t\t\t\t  \"cd mock-path && git status -sb\",\n\t\t\t\t  \"cd mock-path && git fetch -n --dry-run\",\n\t\t\t\t  \"cd mock-path && git rev-list --ancestry-path --count 60e0377..332e429  master\",\n\t\t\t\t  \"cd mock-path && git ls-remote -q --tags --refs\",\n\t\t\t\t  \"cd mock-path && git rev-list --ancestry-path 60e0377..332e429  master\",\n\t\t\t\t]\n\t\t\t`);\n\t\t});\n\n\t\tit(\"returns status information early if isBehindInStatus\", async () => {\n\t\t\tgitStatusOut = \"## master...origin/master [behind 5]\";\n\n\t\t\tconst repos = await gitHelper.getRepos();\n\t\t\texpect(repos[0]).toMatchSnapshot();\n\t\t\texpect(getExecutedCommands()).toMatchInlineSnapshot(`\n\t\t\t[\n\t\t\t  \"cd mock-path && git rev-parse HEAD\",\n\t\t\t  \"cd mock-path && git status -sb\",\n\t\t\t  \"cd mock-path && git fetch -n --dry-run\",\n\t\t\t  \"cd mock-path && git rev-list --ancestry-path --count 60e0377..332e429  master\",\n\t\t\t  \"cd mock-path && git ls-remote -q --tags --refs\",\n\t\t\t  \"cd mock-path && git rev-list --ancestry-path 60e0377..332e429  master\",\n\t\t\t]\n\t\t`);\n\t\t});\n\n\t\tit(\"excludes repo if status can't be retrieved\", async () => {\n\t\t\tconst errorMessage = \"Failed to retrieve status\";\n\t\t\texecShellSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage)));\n\n\t\t\tconst repos = await gitHelper.getRepos();\n\t\t\texpect(repos).toHaveLength(0);\n\t\t});\n\t});\n\n\tdescribe(\"MagicMirror on master with match in taglist\", () => {\n\t\tconst moduleName = \"MagicMirror\";\n\n\t\tbeforeEach(() => {\n\t\t\tgitRemoteOut = \"origin\\tgit@github.com:MagicMirrorOrg/MagicMirror.git (fetch)\\norigin\\tgit@github.com:MagicMirrorOrg/MagicMirror.git (push)\\n\";\n\t\t\tgitRevParseOut = \"332e429a41f1a2339afd4f0ae96dd125da6beada\";\n\t\t\tgitStatusOut = \"## master...origin/master\\n M tests/unit/functions/updatenotification_spec.js\\n\";\n\t\t\tgitFetchErr = \"From github.com:MagicMirrorOrg/MagicMirror\\n60e0377..332e429  master          -> origin/master\\n\";\n\t\t\tgitRevListCountOut = \"5\";\n\t\t\tgitTagListOut = \"332e429a41f1a2339afd4f0ae96dd125da6beada\\ttag\\n\";\n\t\t\tgitRevListOut = \"332e429a41f1a2339afd4f0ae96dd125da6beada\\n\";\n\n\t\t\tgitHelper.gitRepos = [{ module: moduleName, folder: \"mock-path\" }];\n\t\t});\n\n\t\tit(\"returns status information\", async () => {\n\t\t\tconst repos = await gitHelper.getRepos();\n\t\t\texpect(repos[0]).toMatchSnapshot();\n\t\t\texpect(getExecutedCommands()).toMatchInlineSnapshot(`\n\t\t\t[\n\t\t\t  \"cd mock-path && git rev-parse HEAD\",\n\t\t\t  \"cd mock-path && git status -sb\",\n\t\t\t  \"cd mock-path && git fetch -n --dry-run\",\n\t\t\t  \"cd mock-path && git rev-list --ancestry-path --count 60e0377..332e429  master\",\n\t\t\t  \"cd mock-path && git ls-remote -q --tags --refs\",\n\t\t\t  \"cd mock-path && git rev-list --ancestry-path 60e0377..332e429  master\",\n\t\t\t]\n\t\t`);\n\t\t});\n\n\t\tit(\"returns status information early if isBehindInStatus\", async () => {\n\t\t\tgitStatusOut = \"## master...origin/master [behind 5]\";\n\n\t\t\tconst repos = await gitHelper.getRepos();\n\t\t\texpect(repos[0]).toMatchSnapshot();\n\t\t\texpect(getExecutedCommands()).toMatchInlineSnapshot(`\n\t\t\t[\n\t\t\t  \"cd mock-path && git rev-parse HEAD\",\n\t\t\t  \"cd mock-path && git status -sb\",\n\t\t\t  \"cd mock-path && git fetch -n --dry-run\",\n\t\t\t  \"cd mock-path && git rev-list --ancestry-path --count 60e0377..332e429  master\",\n\t\t\t  \"cd mock-path && git ls-remote -q --tags --refs\",\n\t\t\t  \"cd mock-path && git rev-list --ancestry-path 60e0377..332e429  master\",\n\t\t\t]\n\t\t`);\n\t\t});\n\n\t\tit(\"excludes repo if status can't be retrieved\", async () => {\n\t\t\tconst errorMessage = \"Failed to retrieve status\";\n\t\t\texecShellSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage)));\n\n\t\t\tconst repos = await gitHelper.getRepos();\n\t\t\texpect(repos).toHaveLength(0);\n\t\t});\n\t});\n\n\tdescribe(\"MagicMirror on master without match in taglist\", () => {\n\t\tconst moduleName = \"MagicMirror\";\n\n\t\tbeforeEach(() => {\n\t\t\tgitRemoteOut = \"origin\\tgit@github.com:MagicMirrorOrg/MagicMirror.git (fetch)\\norigin\\tgit@github.com:MagicMirrorOrg/MagicMirror.git (push)\\n\";\n\t\t\tgitRevParseOut = \"332e429a41f1a2339afd4f0ae96dd125da6beada\";\n\t\t\tgitStatusOut = \"## master...origin/master\\n M tests/unit/functions/updatenotification_spec.js\\n\";\n\t\t\tgitFetchErr = \"From github.com:MagicMirrorOrg/MagicMirror\\n60e0377..332e429  master          -> origin/master\\n\";\n\t\t\tgitRevListCountOut = \"5\";\n\t\t\tgitTagListOut = \"xxxe429a41f1a2339afd4f0ae96dd125da6beada\\ttag\\n\";\n\t\t\tgitRevListOut = \"332e429a41f1a2339afd4f0ae96dd125da6beada\\n\";\n\n\t\t\tgitHelper.gitRepos = [{ module: moduleName, folder: \"mock-path\" }];\n\t\t});\n\n\t\tit(\"returns status information\", async () => {\n\t\t\tconst repos = await gitHelper.getRepos();\n\t\t\texpect(repos[0]).toMatchSnapshot();\n\t\t\texpect(getExecutedCommands()).toMatchInlineSnapshot(`\n\t\t\t[\n\t\t\t  \"cd mock-path && git rev-parse HEAD\",\n\t\t\t  \"cd mock-path && git status -sb\",\n\t\t\t  \"cd mock-path && git fetch -n --dry-run\",\n\t\t\t  \"cd mock-path && git rev-list --ancestry-path --count 60e0377..332e429  master\",\n\t\t\t  \"cd mock-path && git ls-remote -q --tags --refs\",\n\t\t\t  \"cd mock-path && git rev-list --ancestry-path 60e0377..332e429  master\",\n\t\t\t]\n\t\t`);\n\t\t});\n\n\t\tit(\"returns status information early if isBehindInStatus\", async () => {\n\t\t\tgitStatusOut = \"## master...origin/master [behind 5]\";\n\n\t\t\tconst repos = await gitHelper.getRepos();\n\t\t\texpect(repos[0]).toMatchSnapshot();\n\t\t\texpect(getExecutedCommands()).toMatchInlineSnapshot(`\n\t\t\t[\n\t\t\t  \"cd mock-path && git rev-parse HEAD\",\n\t\t\t  \"cd mock-path && git status -sb\",\n\t\t\t  \"cd mock-path && git fetch -n --dry-run\",\n\t\t\t  \"cd mock-path && git rev-list --ancestry-path --count 60e0377..332e429  master\",\n\t\t\t  \"cd mock-path && git ls-remote -q --tags --refs\",\n\t\t\t  \"cd mock-path && git rev-list --ancestry-path 60e0377..332e429  master\",\n\t\t\t]\n\t\t`);\n\t\t});\n\n\t\tit(\"excludes repo if status can't be retrieved\", async () => {\n\t\t\tconst errorMessage = \"Failed to retrieve status\";\n\t\t\texecShellSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage)));\n\n\t\t\tconst repos = await gitHelper.getRepos();\n\t\t\texpect(repos).toHaveLength(0);\n\t\t});\n\t});\n\n\tdescribe(\"custom module\", () => {\n\t\tconst moduleName = \"MMM-Fuel\";\n\n\t\tbeforeEach(async () => {\n\t\t\tgitRemoteOut = `origin\\thttps://github.com/fewieden/${moduleName}.git (fetch)\\norigin\\thttps://github.com/fewieden/${moduleName}.git (push)\\n`;\n\t\t\tgitRevParseOut = \"9d8310163da94441073a93cead711ba43e8888d0\";\n\t\t\tgitStatusOut = \"## master...origin/master\";\n\t\t\tgitFetchErr = `From https://github.com/fewieden/${moduleName}\\n19f7faf..9d83101  master      -> origin/master`;\n\t\t\tgitRevListCountOut = \"7\";\n\n\t\t\tgitHelper.gitRepos = [{ module: moduleName, folder: \"mock-path\" }];\n\t\t});\n\n\t\tit(\"returns status information without hash\", async () => {\n\t\t\tconst repos = await gitHelper.getRepos();\n\t\t\texpect(repos[0]).toMatchSnapshot();\n\t\t\texpect(getExecutedCommands()).toMatchInlineSnapshot(`\n\t\t\t[\n\t\t\t  \"cd mock-path && git status -sb\",\n\t\t\t  \"cd mock-path && git fetch -n --dry-run\",\n\t\t\t  \"cd mock-path && git rev-list --ancestry-path --count 19f7faf..9d83101  master\",\n\t\t\t]\n\t\t`);\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/unit/global_vars/defaults_modules_spec.js",
    "content": "const fs = require(\"node:fs\");\nconst path = require(\"node:path\");\n\nconst root_path = path.join(__dirname, \"../../..\");\n\ndescribe(\"Default modules set in modules/default/defaultmodules.js\", () => {\n\tconst expectedDefaultModules = require(`${root_path}/modules/default/defaultmodules`);\n\n\tfor (const defaultModule of expectedDefaultModules) {\n\t\tit(`contains a folder for modules/default/${defaultModule}\"`, () => {\n\t\t\texpect(fs.existsSync(path.join(root_path, \"modules/default\", defaultModule))).toBe(true);\n\t\t});\n\t}\n});\n"
  },
  {
    "path": "tests/unit/global_vars/root_path_spec.js",
    "content": "const fs = require(\"node:fs\");\nconst path = require(\"node:path\");\n\nconst root_path = path.join(__dirname, \"../../..\");\nconst version = require(`${root_path}/package.json`).version;\n\ndescribe(\"'global.root_path' set in js/app.js\", () => {\n\tconst expectedSubPaths = [\"modules\", \"serveronly\", \"js\", \"js/app.js\", \"js/main.js\", \"js/electron.js\", \"config\"];\n\n\texpectedSubPaths.forEach((subpath) => {\n\t\tit(`contains a file/folder \"${subpath}\"`, () => {\n\t\t\texpect(fs.existsSync(path.join(root_path, subpath))).toBe(true);\n\t\t});\n\t});\n\n\tit(\"should not modify global.root_path for testing\", () => {\n\t\texpect(global.root_path).toBeUndefined();\n\t});\n\n\tit(\"should not modify global.version for testing\", () => {\n\t\texpect(global.version).toBeUndefined();\n\t});\n\n\tit(\"should expect the global.version equals package.json file\", () => {\n\t\tconst versionPackage = JSON.parse(fs.readFileSync(\"package.json\", \"utf8\")).version;\n\t\texpect(version).toBe(versionPackage);\n\t});\n});\n"
  },
  {
    "path": "tests/unit/helpers/global-setup.js",
    "content": "module.exports = async () => {\n\tprocess.env.TZ = \"UTC\";\n};\n"
  },
  {
    "path": "tests/unit/modules/default/calendar/calendar_fetcher_utils_bad_rrule.js",
    "content": "global.moment = require(\"moment-timezone\");\n\nconst CalendarFetcherUtils = require(\"../../../../../modules/default/calendar/calendarfetcherutils\");\n\ndescribe(\"Calendar fetcher utils test\", () => {\n\tconst defaultConfig = {\n\t\texcludedEvents: []\n\t};\n\n\tdescribe(\"filterEvents\", () => {\n\t\tit(\"no events, not crash\", () => {\n\t\t\tconst base = moment().startOf(\"day\").add(12, \"hours\");\n\t\t\tconst minusOneHour = base.clone().subtract(1, \"hours\").toDate();\n\t\t\tconst minusTwoHours = base.clone().subtract(2, \"hours\").toDate();\n\t\t\tconst plusOneHour = base.clone().add(1, \"hours\").toDate();\n\t\t\tconst plusTwoHours = base.clone().add(2, \"hours\").toDate();\n\n\t\t\tconst filteredEvents = CalendarFetcherUtils.filterEvents(\n\t\t\t\t{\n\t\t\t\t\tpastEvent: { type: \"VEVENT\", start: minusTwoHours, end: minusOneHour, summary: \"pastEvent\" },\n\t\t\t\t\tongoingEvent: { type: \"VEVENT\", start: minusOneHour, end: plusOneHour, summary: \"ongoingEvent\" },\n\t\t\t\t\tupcomingEvent: { type: \"VEVENT\", start: plusOneHour, end: plusTwoHours, summary: \"upcomingEvent\" }\n\t\t\t\t},\n\t\t\t\tdefaultConfig\n\t\t\t);\n\n\t\t\texpect(filteredEvents).toHaveLength(0);\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js",
    "content": "global.moment = require(\"moment-timezone\");\n\nconst ical = require(\"node-ical\");\nconst moment = require(\"moment-timezone\");\nconst CalendarFetcherUtils = require(\"../../../../../modules/default/calendar/calendarfetcherutils\");\n\ndescribe(\"Calendar fetcher utils test\", () => {\n\tconst defaultConfig = {\n\t\texcludedEvents: [],\n\t\tincludePastEvents: false,\n\t\tmaximumEntries: 10,\n\t\tmaximumNumberOfDays: 367\n\t};\n\n\tdescribe(\"filterEvents\", () => {\n\t\tit(\"should return only ongoing and upcoming non full day events\", () => {\n\t\t\tconst minusOneHour = moment().subtract(1, \"hours\").toDate();\n\t\t\tconst minusTwoHours = moment().subtract(2, \"hours\").toDate();\n\t\t\tconst plusOneHour = moment().add(1, \"hours\").toDate();\n\t\t\tconst plusTwoHours = moment().add(2, \"hours\").toDate();\n\n\t\t\tconst filteredEvents = CalendarFetcherUtils.filterEvents(\n\t\t\t\t{\n\t\t\t\t\tpastEvent: { type: \"VEVENT\", start: minusTwoHours, end: minusOneHour, summary: \"pastEvent\" },\n\t\t\t\t\tongoingEvent: { type: \"VEVENT\", start: minusOneHour, end: plusOneHour, summary: \"ongoingEvent\" },\n\t\t\t\t\tupcomingEvent: { type: \"VEVENT\", start: plusOneHour, end: plusTwoHours, summary: \"upcomingEvent\" }\n\t\t\t\t},\n\t\t\t\tdefaultConfig\n\t\t\t);\n\n\t\t\texpect(filteredEvents).toHaveLength(2);\n\t\t\texpect(filteredEvents[0].title).toBe(\"ongoingEvent\");\n\t\t\texpect(filteredEvents[1].title).toBe(\"upcomingEvent\");\n\t\t});\n\n\t\tit(\"should return only ongoing and upcoming full day events\", () => {\n\t\t\tconst yesterday = moment().subtract(1, \"days\").startOf(\"day\").toDate();\n\t\t\tconst today = moment().startOf(\"day\").toDate();\n\t\t\tconst tomorrow = moment().add(1, \"days\").startOf(\"day\").toDate();\n\n\t\t\tconst filteredEvents = CalendarFetcherUtils.filterEvents(\n\t\t\t\t{\n\t\t\t\t\tpastEvent: { type: \"VEVENT\", start: yesterday, end: yesterday, summary: \"pastEvent\" },\n\t\t\t\t\tongoingEvent: { type: \"VEVENT\", start: today, end: today, summary: \"ongoingEvent\" },\n\t\t\t\t\tupcomingEvent: { type: \"VEVENT\", start: tomorrow, end: tomorrow, summary: \"upcomingEvent\" }\n\t\t\t\t},\n\t\t\t\tdefaultConfig\n\t\t\t);\n\n\t\t\texpect(filteredEvents).toHaveLength(2);\n\t\t\texpect(filteredEvents[0].title).toBe(\"ongoingEvent\");\n\t\t\texpect(filteredEvents[1].title).toBe(\"upcomingEvent\");\n\t\t});\n\n\t\tit(\"should return the correct times when recurring events pass through daylight saving time\", () => {\n\t\t\tconst data = ical.parseICS(`BEGIN:VEVENT\nDTSTART;TZID=Europe/Amsterdam:20250311T090000\nDTEND;TZID=Europe/Amsterdam:20250311T091500\nRRULE:FREQ=WEEKLY;BYDAY=FR,MO,TH,TU,WE,SA,SU\nDTSTAMP:20250531T091103Z\nORGANIZER;CN=test:mailto:test@test.com\nUID:67e65a1d-b889-4451-8cab-5518cecb9c66\nCREATED:20230111T114612Z\nDESCRIPTION:Test\nLAST-MODIFIED:20250528T071312Z\nSEQUENCE:1\nSTATUS:CONFIRMED\nSUMMARY:Test\nTRANSP:OPAQUE\nEND:VEVENT`);\n\n\t\t\tconst filteredEvents = CalendarFetcherUtils.filterEvents(data, defaultConfig);\n\n\t\t\tconst januaryFirst = filteredEvents.filter((event) => moment(event.startDate, \"x\").format(\"MM-DD\") === \"01-01\");\n\t\t\tconst julyFirst = filteredEvents.filter((event) => moment(event.startDate, \"x\").format(\"MM-DD\") === \"07-01\");\n\n\t\t\tlet januaryMoment = moment(`${moment(januaryFirst[0].startDate, \"x\").format(\"YYYY\")}-01-01T09:00:00`)\n\t\t\t\t.tz(\"Europe/Amsterdam\", true) // Convert to Europe/Amsterdam timezone (see event ical) but keep 9 o'clock\n\t\t\t\t.tz(moment.tz.guess()); // Convert to guessed timezone as that is used in the filterEvents\n\n\t\t\tlet julyMoment = moment(`${moment(julyFirst[0].startDate, \"x\").format(\"YYYY\")}-07-01T09:00:00`)\n\t\t\t\t.tz(\"Europe/Amsterdam\", true) // Convert to Europe/Amsterdam timezone (see event ical) but keep 9 o'clock\n\t\t\t\t.tz(moment.tz.guess()); // Convert to guessed timezone as that is used in the filterEvents\n\n\t\t\texpect(januaryFirst[0].startDate).toEqual(januaryMoment.format(\"x\"));\n\t\t\texpect(julyFirst[0].startDate).toEqual(julyMoment.format(\"x\"));\n\t\t});\n\n\t\tit(\"should return the correct moments based on the timezone given\", () => {\n\t\t\tconst data = ical.parseICS(`BEGIN:VEVENT\nDTSTART;TZID=Europe/Amsterdam:20250311T090000\nDTEND;TZID=Europe/Amsterdam:20250311T091500\nRRULE:FREQ=WEEKLY;BYDAY=FR,MO,TH,TU,WE,SA,SU\nDTSTAMP:20250531T091103Z\nORGANIZER;CN=test:mailto:test@test.com\nUID:67e65a1d-b889-4451-8cab-5518cecb9c66\nCREATED:20230111T114612Z\nDESCRIPTION:Test\nLAST-MODIFIED:20250528T071312Z\nSEQUENCE:1\nSTATUS:CONFIRMED\nSUMMARY:Test\nTRANSP:OPAQUE\nEND:VEVENT`);\n\n\t\t\tconst moments = CalendarFetcherUtils.getMomentsFromRecurringEvent(data[\"67e65a1d-b889-4451-8cab-5518cecb9c66\"], moment(), moment().add(365, \"days\"));\n\n\t\t\tconst januaryFirst = moments.filter((m) => m.format(\"MM-DD\") === \"01-01\");\n\t\t\tconst julyFirst = moments.filter((m) => m.format(\"MM-DD\") === \"07-01\");\n\n\t\t\texpect(januaryFirst[0].toISOString(true)).toContain(\"09:00:00.000+01:00\");\n\t\t\texpect(julyFirst[0].toISOString(true)).toContain(\"09:00:00.000+02:00\");\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/unit/modules/default/calendar/calendar_utils_spec.js",
    "content": "global.moment = require(\"moment\");\n\nconst CalendarUtils = require(\"../../../../../modules/default/calendar/calendarutils\");\n\ndescribe(\"Calendar utils tests\", () => {\n\tdescribe(\"capFirst\", () => {\n\t\tconst words = {\n\t\t\trodrigo: \"Rodrigo\",\n\t\t\t\"123m\": \"123m\",\n\t\t\t\"magic mirror\": \"Magic mirror\",\n\t\t\t\",a\": \",a\",\n\t\t\tñandú: \"Ñandú\"\n\t\t};\n\n\t\tObject.keys(words).forEach((word) => {\n\t\t\tit(`for '${word}' should return '${words[word]}'`, () => {\n\t\t\t\texpect(CalendarUtils.capFirst(word)).toBe(words[word]);\n\t\t\t});\n\t\t});\n\n\t\tit(\"should not capitalize other letters\", () => {\n\t\t\texpect(CalendarUtils.capFirst(\"event\")).not.toBe(\"EVent\");\n\t\t});\n\t});\n\n\tdescribe(\"getLocaleSpecification\", () => {\n\t\tit(\"should return a valid moment.LocaleSpecification for a 12-hour format\", () => {\n\t\t\texpect(CalendarUtils.getLocaleSpecification(12)).toEqual({ longDateFormat: { LT: \"h:mm A\" } });\n\t\t});\n\n\t\tit(\"should return a valid moment.LocaleSpecification for a 24-hour format\", () => {\n\t\t\texpect(CalendarUtils.getLocaleSpecification(24)).toEqual({ longDateFormat: { LT: \"HH:mm\" } });\n\t\t});\n\n\t\tit(\"should return the current system locale when called without timeFormat number\", () => {\n\t\t\texpect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: moment.localeData().longDateFormat(\"LT\") } });\n\t\t});\n\n\t\tit(\"should return a 12-hour longDateFormat when using the 'en' locale\", () => {\n\t\t\tconst localeBackup = moment.locale();\n\t\t\tmoment.locale(\"en\");\n\t\t\texpect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: \"h:mm A\" } });\n\t\t\tmoment.locale(localeBackup);\n\t\t});\n\n\t\tit(\"should return a 12-hour longDateFormat when using the 'au' locale\", () => {\n\t\t\tconst localeBackup = moment.locale();\n\t\t\tmoment.locale(\"au\");\n\t\t\texpect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: \"h:mm A\" } });\n\t\t\tmoment.locale(localeBackup);\n\t\t});\n\n\t\tit(\"should return a 12-hour longDateFormat when using the 'eg' locale\", () => {\n\t\t\tconst localeBackup = moment.locale();\n\t\t\tmoment.locale(\"eg\");\n\t\t\texpect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: \"h:mm A\" } });\n\t\t\tmoment.locale(localeBackup);\n\t\t});\n\n\t\tit(\"should return a 24-hour longDateFormat when using the 'nl' locale\", () => {\n\t\t\tconst localeBackup = moment.locale();\n\t\t\tmoment.locale(\"nl\");\n\t\t\texpect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: \"HH:mm\" } });\n\t\t\tmoment.locale(localeBackup);\n\t\t});\n\n\t\tit(\"should return a 24-hour longDateFormat when using the 'fr' locale\", () => {\n\t\t\tconst localeBackup = moment.locale();\n\t\t\tmoment.locale(\"fr\");\n\t\t\texpect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: \"HH:mm\" } });\n\t\t\tmoment.locale(localeBackup);\n\t\t});\n\n\t\tit(\"should return a 24-hour longDateFormat when using the 'uk' locale\", () => {\n\t\t\tconst localeBackup = moment.locale();\n\t\t\tmoment.locale(\"uk\");\n\t\t\texpect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: \"HH:mm\" } });\n\t\t\tmoment.locale(localeBackup);\n\t\t});\n\t});\n\n\tdescribe(\"shorten\", () => {\n\t\tit(\"should not shorten if short enough\", () => {\n\t\t\texpect(CalendarUtils.shorten(\"Event 1\", 10, false, 1)).toBe(\"Event 1\");\n\t\t});\n\n\t\tit(\"should shorten into one line\", () => {\n\t\t\texpect(CalendarUtils.shorten(\"Example event at 12 o clock\", 10, true, 1)).toBe(\"Example …\");\n\t\t});\n\n\t\tit(\"should shorten into three lines\", () => {\n\t\t\texpect(CalendarUtils.shorten(\"Example event at 12 o clock\", 10, true, 3)).toBe(\"Example <br>event at 12 o <br>clock\");\n\t\t});\n\n\t\tit(\"should not shorten into three lines if wrap is false\", () => {\n\t\t\texpect(CalendarUtils.shorten(\"Example event at 12 o clock\", 10, false, 3)).toBe(\"Example ev…\");\n\t\t});\n\n\t\tconst strings = {\n\t\t\t\" String with whitespace at the beginning that needs trimming\": { length: 16, return: \"String with whit…\" },\n\t\t\t\"long string that needs shortening\": { length: 16, return: \"long string that…\" },\n\t\t\t\"short string\": { length: 16, return: \"short string\" },\n\t\t\t\"long string with no maxLength defined\": { return: \"long string with no maxLength defined\" }\n\t\t};\n\n\t\tObject.keys(strings).forEach((string) => {\n\t\t\tit(`for '${string}' should return '${strings[string].return}'`, () => {\n\t\t\t\texpect(CalendarUtils.shorten(string, strings[string].length)).toBe(strings[string].return);\n\t\t\t});\n\t\t});\n\n\t\tit(\"should return an empty string if shorten is called with a non-string\", () => {\n\t\t\texpect(CalendarUtils.shorten(100)).toBe(\"\");\n\t\t});\n\n\t\tit(\"should not shorten the string if shorten is called with a non-number maxLength\", () => {\n\t\t\texpect(CalendarUtils.shorten(\"This is a test string\", \"This is not a number\")).toBe(\"This is a test string\");\n\t\t});\n\n\t\tit(\"should wrap the string instead of shorten it if shorten is called with wrapEvents = true (with maxLength defined as 20)\", () => {\n\t\t\texpect(CalendarUtils.shorten(\"This is a wrapEvent test. Should wrap the string instead of shorten it if called with wrapEvent = true\", 20, true)).toBe(\n\t\t\t\t\"This is a <br>wrapEvent test. Should wrap <br>the string instead of <br>shorten it if called with <br>wrapEvent = true\"\n\t\t\t);\n\t\t});\n\n\t\tit(\"should wrap the string instead of shorten it if shorten is called with wrapEvents = true (without maxLength defined, default 25)\", () => {\n\t\t\texpect(CalendarUtils.shorten(\"This is a wrapEvent test. Should wrap the string instead of shorten it if called with wrapEvent = true\", undefined, true)).toBe(\n\t\t\t\t\"This is a wrapEvent <br>test. Should wrap the string <br>instead of shorten it if called <br>with wrapEvent = true\"\n\t\t\t);\n\t\t});\n\n\t\tit(\"should wrap and shorten the string in the second line if called with wrapEvents = true and maxTitleLines = 2\", () => {\n\t\t\texpect(CalendarUtils.shorten(\"This is a wrapEvent and maxTitleLines test. Should wrap and shorten the string in the second line if called with wrapEvents = true and maxTitleLines = 2\", undefined, true, 2)).toBe(\n\t\t\t\t\"This is a wrapEvent and <br>maxTitleLines test. Should wrap and …\"\n\t\t\t);\n\t\t});\n\t});\n\n\tdescribe(\"titleTransform and shorten combined\", () => {\n\t\tit(\"should replace the birthday and wrap nicely\", () => {\n\t\t\tconst transformedTitle = CalendarUtils.titleTransform(\"Michael Teeuw's birthday\", [{ search: \"'s birthday\", replace: \"\" }]);\n\t\t\texpect(CalendarUtils.shorten(transformedTitle, 10, true, 2)).toBe(\"Michael <br>Teeuw\");\n\t\t});\n\t});\n\n\tdescribe(\"titleTransform with yearmatchgroup\", () => {\n\t\tit(\"should replace the birthday and wrap nicely\", () => {\n\t\t\tconst transformedTitle = CalendarUtils.titleTransform(\"Luciella '2000\", [{ search: \"^([^']*) '(\\\\d{4})$\", replace: \"$1 ($2.)\", yearmatchgroup: 2 }]);\n\t\t\tconst expectedResult = `Luciella (${new Date().getFullYear() - 2000}.)`;\n\t\t\texpect(transformedTitle).toBe(expectedResult);\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/unit/modules/default/compliments/compliments_spec.js",
    "content": "describe(\"Compliments module\", () => {\n\tlet complimentsModule;\n\n\tbeforeEach(() => {\n\t\t// Mock global dependencies\n\t\tglobal.Module = {\n\t\t\tregister: vi.fn((name, moduleDefinition) => {\n\t\t\t\tcomplimentsModule = moduleDefinition;\n\t\t\t})\n\t\t};\n\t\tglobal.Log = {\n\t\t\tinfo: vi.fn(),\n\t\t\twarn: vi.fn(),\n\t\t\terror: vi.fn()\n\t\t};\n\t\tglobal.Cron = vi.fn();\n\n\t\t// Load the module\n\t\trequire(\"../../../../../modules/default/compliments/compliments\");\n\n\t\t// Setup module instance\n\t\tcomplimentsModule.config = { ...complimentsModule.defaults };\n\t\tcomplimentsModule.name = \"compliments\";\n\t\tcomplimentsModule.file = vi.fn((path) => `http://localhost:8080/modules/default/compliments/${path}`);\n\t});\n\n\tafterEach(() => {\n\t\tvi.restoreAllMocks();\n\t});\n\n\tdescribe(\"loadComplimentFile\", () => {\n\t\tdescribe(\"HTTP error handling\", () => {\n\t\t\tit(\"should return null and log error on HTTP 404\", async () => {\n\t\t\t\tcomplimentsModule.config.remoteFile = \"http://example.com/missing.json\";\n\n\t\t\t\tglobal.fetch = vi.fn(() => Promise.resolve({\n\t\t\t\t\tok: false,\n\t\t\t\t\tstatus: 404,\n\t\t\t\t\tstatusText: \"Not Found\"\n\t\t\t\t}));\n\n\t\t\t\tconst result = await complimentsModule.loadComplimentFile();\n\n\t\t\t\texpect(result).toBeNull();\n\t\t\t\texpect(Log.error).toHaveBeenCalledWith(\"[compliments] HTTP error: 404 Not Found\");\n\t\t\t});\n\n\t\t\tit(\"should return null and log error on HTTP 500\", async () => {\n\t\t\t\tcomplimentsModule.config.remoteFile = \"http://example.com/error.json\";\n\n\t\t\t\tglobal.fetch = vi.fn(() => Promise.resolve({\n\t\t\t\t\tok: false,\n\t\t\t\t\tstatus: 500,\n\t\t\t\t\tstatusText: \"Internal Server Error\"\n\t\t\t\t}));\n\n\t\t\t\tconst result = await complimentsModule.loadComplimentFile();\n\n\t\t\t\texpect(result).toBeNull();\n\t\t\t\texpect(Log.error).toHaveBeenCalledWith(\"[compliments] HTTP error: 500 Internal Server Error\");\n\t\t\t});\n\n\t\t\tit(\"should return content on successful HTTP 200\", async () => {\n\t\t\t\tcomplimentsModule.config.remoteFile = \"http://example.com/compliments.json\";\n\t\t\t\tconst expectedContent = \"{\\\"anytime\\\":[\\\"Test compliment\\\"]}\";\n\n\t\t\t\tglobal.fetch = vi.fn(() => Promise.resolve({\n\t\t\t\t\tok: true,\n\t\t\t\t\tstatus: 200,\n\t\t\t\t\ttext: () => Promise.resolve(expectedContent)\n\t\t\t\t}));\n\n\t\t\t\tconst result = await complimentsModule.loadComplimentFile();\n\n\t\t\t\texpect(result).toBe(expectedContent);\n\t\t\t\texpect(Log.error).not.toHaveBeenCalled();\n\t\t\t});\n\t\t});\n\n\t\tdescribe(\"Cache-busting with query parameters\", () => {\n\t\t\tbeforeEach(() => {\n\t\t\t\tvi.useFakeTimers();\n\t\t\t\tvi.setSystemTime(new Date(\"2025-01-01T12:00:00.000Z\"));\n\t\t\t});\n\n\t\t\tafterEach(() => {\n\t\t\t\tvi.useRealTimers();\n\t\t\t});\n\n\t\t\tit(\"should add cache-busting parameter to URL without query params\", async () => {\n\t\t\t\tcomplimentsModule.config.remoteFile = \"http://example.com/compliments.json\";\n\t\t\t\tcomplimentsModule.config.remoteFileRefreshInterval = 60000;\n\n\t\t\t\tglobal.fetch = vi.fn(() => Promise.resolve({\n\t\t\t\t\tok: true,\n\t\t\t\t\ttext: () => Promise.resolve(\"{}\")\n\t\t\t\t}));\n\n\t\t\t\tawait complimentsModule.loadComplimentFile();\n\n\t\t\t\texpect(fetch).toHaveBeenCalledWith(expect.stringContaining(\"?dummy=1735732800000\"));\n\t\t\t});\n\n\t\t\tit(\"should add cache-busting parameter to URL with existing query params\", async () => {\n\t\t\t\tcomplimentsModule.config.remoteFile = \"http://example.com/compliments.json?lang=en\";\n\t\t\t\tcomplimentsModule.config.remoteFileRefreshInterval = 60000;\n\n\t\t\t\tglobal.fetch = vi.fn(() => Promise.resolve({\n\t\t\t\t\tok: true,\n\t\t\t\t\ttext: () => Promise.resolve(\"{}\")\n\t\t\t\t}));\n\n\t\t\t\tawait complimentsModule.loadComplimentFile();\n\n\t\t\t\tconst calledUrl = fetch.mock.calls[0][0];\n\t\t\t\texpect(calledUrl).toContain(\"lang=en\");\n\t\t\t\texpect(calledUrl).toContain(\"&dummy=1735732800000\");\n\t\t\t\texpect(calledUrl).not.toContain(\"?dummy=\");\n\t\t\t});\n\n\t\t\tit(\"should not add cache-busting parameter when remoteFileRefreshInterval is 0\", async () => {\n\t\t\t\tcomplimentsModule.config.remoteFile = \"http://example.com/compliments.json\";\n\t\t\t\tcomplimentsModule.config.remoteFileRefreshInterval = 0;\n\n\t\t\t\tglobal.fetch = vi.fn(() => Promise.resolve({\n\t\t\t\t\tok: true,\n\t\t\t\t\ttext: () => Promise.resolve(\"{}\")\n\t\t\t\t}));\n\n\t\t\t\tawait complimentsModule.loadComplimentFile();\n\n\t\t\t\texpect(fetch).toHaveBeenCalledWith(\"http://example.com/compliments.json\");\n\t\t\t});\n\n\t\t\tit(\"should not add cache-busting parameter to local files\", async () => {\n\t\t\t\tcomplimentsModule.config.remoteFile = \"compliments.json\";\n\t\t\t\tcomplimentsModule.config.remoteFileRefreshInterval = 60000;\n\n\t\t\t\tglobal.fetch = vi.fn(() => Promise.resolve({\n\t\t\t\t\tok: true,\n\t\t\t\t\ttext: () => Promise.resolve(\"{}\")\n\t\t\t\t}));\n\n\t\t\t\tawait complimentsModule.loadComplimentFile();\n\n\t\t\t\tconst calledUrl = fetch.mock.calls[0][0];\n\t\t\t\texpect(calledUrl).toBe(\"http://localhost:8080/modules/default/compliments/compliments.json\");\n\t\t\t\texpect(calledUrl).not.toContain(\"dummy=\");\n\t\t\t});\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/unit/modules/default/utils_spec.js",
    "content": "global.moment = require(\"moment-timezone\");\nconst { performWebRequest, formatTime } = require(\"../../../../modules/default/utils\");\n\ndescribe(\"Default modules utils tests\", () => {\n\tdescribe(\"performWebRequest\", () => {\n\t\tconst locationHost = \"localhost:8080\";\n\t\tconst locationProtocol = \"http\";\n\n\t\tlet fetchResponse;\n\t\tlet fetchMock;\n\t\tlet urlToCall;\n\n\t\tbeforeEach(() => {\n\t\t\tfetchResponse = new Response();\n\t\t\tglobal.fetch = vi.fn(() => Promise.resolve(fetchResponse));\n\t\t\tfetchMock = global.fetch;\n\t\t});\n\n\t\tdescribe(\"When using cors proxy\", () => {\n\t\t\tObject.defineProperty(global, \"location\", {\n\t\t\t\tvalue: {\n\t\t\t\t\thost: locationHost,\n\t\t\t\t\tprotocol: locationProtocol\n\t\t\t\t}\n\t\t\t});\n\n\t\t\tit(\"Calls correct URL once\", async () => {\n\t\t\t\turlToCall = \"http://www.test.com/path?param1=value1\";\n\n\t\t\t\tawait performWebRequest(urlToCall, \"json\", true);\n\n\t\t\t\texpect(fetchMock.mock.calls).toHaveLength(1);\n\t\t\t\texpect(fetchMock.mock.calls[0][0]).toBe(`${locationProtocol}//${locationHost}/cors?url=${urlToCall}`);\n\t\t\t});\n\n\t\t\tit(\"Sends correct headers\", async () => {\n\t\t\t\turlToCall = \"http://www.test.com/path?param1=value1\";\n\n\t\t\t\tconst headers = [\n\t\t\t\t\t{ name: \"header1\", value: \"value1\" },\n\t\t\t\t\t{ name: \"header2\", value: \"value2\" }\n\t\t\t\t];\n\n\t\t\t\tawait performWebRequest(urlToCall, \"json\", true, headers);\n\n\t\t\t\texpect(fetchMock.mock.calls).toHaveLength(1);\n\t\t\t\texpect(fetchMock.mock.calls[0][0]).toBe(`${locationProtocol}//${locationHost}/cors?sendheaders=header1:value1,header2:value2&url=${urlToCall}`);\n\t\t\t});\n\t\t});\n\n\t\tdescribe(\"When not using cors proxy\", () => {\n\t\t\tit(\"Calls correct URL once\", async () => {\n\t\t\t\turlToCall = \"http://www.test.com/path?param1=value1\";\n\n\t\t\t\tawait performWebRequest(urlToCall);\n\n\t\t\t\texpect(fetchMock.mock.calls).toHaveLength(1);\n\t\t\t\texpect(fetchMock.mock.calls[0][0]).toBe(urlToCall);\n\t\t\t});\n\n\t\t\tit(\"Sends correct headers\", async () => {\n\t\t\t\turlToCall = \"http://www.test.com/path?param1=value1\";\n\t\t\t\tconst headers = [\n\t\t\t\t\t{ name: \"header1\", value: \"value1\" },\n\t\t\t\t\t{ name: \"header2\", value: \"value2\" }\n\t\t\t\t];\n\n\t\t\t\tawait performWebRequest(urlToCall, \"json\", false, headers);\n\n\t\t\t\tconst expectedHeaders = { headers: { header1: \"value1\", header2: \"value2\" } };\n\t\t\t\texpect(fetchMock.mock.calls).toHaveLength(1);\n\t\t\t\texpect(fetchMock.mock.calls[0][1]).toStrictEqual(expectedHeaders);\n\t\t\t});\n\t\t});\n\n\t\tdescribe(\"When receiving json format\", () => {\n\t\t\tit(\"Returns undefined when no data is received\", async () => {\n\t\t\t\turlToCall = \"www.test.com\";\n\n\t\t\t\tconst response = await performWebRequest(urlToCall);\n\n\t\t\t\texpect(response).toBeUndefined();\n\t\t\t});\n\n\t\t\tit(\"Returns object when data is received\", async () => {\n\t\t\t\turlToCall = \"www.test.com\";\n\t\t\t\tfetchResponse = new Response(\"{\\\"body\\\": \\\"some content\\\"}\");\n\n\t\t\t\tconst response = await performWebRequest(urlToCall);\n\n\t\t\t\texpect(response.body).toBe(\"some content\");\n\t\t\t});\n\n\t\t\tit(\"Returns expected headers when data is received\", async () => {\n\t\t\t\turlToCall = \"www.test.com\";\n\t\t\t\tfetchResponse = new Response(\"{\\\"body\\\": \\\"some content\\\"}\", { headers: { header1: \"value1\", header2: \"value2\" } });\n\n\t\t\t\tconst response = await performWebRequest(urlToCall, \"json\", false, undefined, [\"header1\"]);\n\n\t\t\t\texpect(response.headers).toHaveLength(1);\n\t\t\t\texpect(response.headers[0].name).toBe(\"header1\");\n\t\t\t\texpect(response.headers[0].value).toBe(\"value1\");\n\t\t\t});\n\t\t});\n\t});\n\n\tdescribe(\"formatTime\", () => {\n\t\tconst time = new Date();\n\n\t\tbeforeEach(async () => {\n\t\t\ttime.setHours(13, 13);\n\t\t});\n\n\t\tit(\"should convert correctly according to the config\", () => {\n\t\t\texpect(\n\t\t\t\tformatTime(\n\t\t\t\t\t{\n\t\t\t\t\t\ttimeFormat: 24\n\t\t\t\t\t},\n\t\t\t\t\ttime\n\t\t\t\t)\n\t\t\t).toBe(\"13:13\");\n\t\t\texpect(\n\t\t\t\tformatTime(\n\t\t\t\t\t{\n\t\t\t\t\t\tshowPeriod: true,\n\t\t\t\t\t\tshowPeriodUpper: true,\n\t\t\t\t\t\ttimeFormat: 12\n\t\t\t\t\t},\n\t\t\t\t\ttime\n\t\t\t\t)\n\t\t\t).toBe(\"1:13 PM\");\n\t\t\texpect(\n\t\t\t\tformatTime(\n\t\t\t\t\t{\n\t\t\t\t\t\tshowPeriod: true,\n\t\t\t\t\t\tshowPeriodUpper: false,\n\t\t\t\t\t\ttimeFormat: 12\n\t\t\t\t\t},\n\t\t\t\t\ttime\n\t\t\t\t)\n\t\t\t).toBe(\"1:13 pm\");\n\t\t\texpect(\n\t\t\t\tformatTime(\n\t\t\t\t\t{\n\t\t\t\t\t\tshowPeriod: false,\n\t\t\t\t\t\ttimeFormat: 12\n\t\t\t\t\t},\n\t\t\t\t\ttime\n\t\t\t\t)\n\t\t\t).toBe(\"1:13\");\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/unit/modules/default/weather/weather_object_spec.js",
    "content": "const WeatherObject = require(\"../../../../../modules/default/weather/weatherobject\");\n\nglobal.moment = require(\"moment-timezone\");\nglobal.SunCalc = require(\"suncalc\");\n\ndescribe(\"WeatherObject\", () => {\n\tlet originalTimeZone;\n\tlet weatherobject;\n\n\tbeforeAll(() => {\n\t\toriginalTimeZone = moment.tz.guess();\n\t\tmoment.tz.setDefault(\"Africa/Dar_es_Salaam\");\n\t\tweatherobject = new WeatherObject();\n\t});\n\n\tit(\"should return true for daytime at noon\", () => {\n\t\tweatherobject.date = moment(\"12:00\", \"HH:mm\");\n\t\tweatherobject.updateSunTime(-6.774877582342688, 37.63345667023327);\n\t\texpect(weatherobject.isDayTime()).toBe(true);\n\t});\n\n\tit(\"should return false for daytime at midnight\", () => {\n\t\tweatherobject.date = moment(\"00:00\", \"HH:mm\");\n\t\tweatherobject.updateSunTime(-6.774877582342688, 37.63345667023327);\n\t\texpect(weatherobject.isDayTime()).toBe(false);\n\t});\n\n\tit(\"should return sunrise as the next sunaction\", () => {\n\t\tweatherobject.updateSunTime(-6.774877582342688, 37.63345667023327);\n\t\tlet midnight = moment(\"00:00\", \"HH:mm\");\n\t\texpect(weatherobject.nextSunAction(midnight)).toBe(\"sunrise\");\n\t});\n\n\tit(\"should return sunset as the next sunaction\", () => {\n\t\tweatherobject.updateSunTime(-6.774877582342688, 37.63345667023327);\n\t\tlet noon = moment(weatherobject.sunrise).hour(14);\n\t\texpect(weatherobject.nextSunAction(noon)).toBe(\"sunset\");\n\t});\n\n\tit(\"should return an already defined feelsLike info\", () => {\n\t\tweatherobject.feelsLikeTemp = \"feelsLikeTempValue\";\n\t\texpect(weatherobject.feelsLike()).toBe(\"feelsLikeTempValue\");\n\t});\n\n\tafterAll(() => {\n\t\tmoment.tz.setDefault(originalTimeZone);\n\t});\n});\n"
  },
  {
    "path": "tests/unit/modules/default/weather/weather_utils_spec.js",
    "content": "const weather = require(\"../../../../../modules/default/weather/weatherutils\");\nconst WeatherUtils = require(\"../../../../../modules/default/weather/weatherutils\");\n\ndescribe(\"Weather utils tests\", () => {\n\tdescribe(\"temperature conversion to imperial\", () => {\n\t\tit(\"should convert temp correctly from Celsius to Celsius\", () => {\n\t\t\texpect(Math.round(WeatherUtils.convertTemp(10, \"metric\"))).toBe(10);\n\t\t});\n\n\t\tit(\"should convert temp correctly from Celsius to Fahrenheit\", () => {\n\t\t\texpect(Math.round(WeatherUtils.convertTemp(10, \"imperial\"))).toBe(50);\n\t\t});\n\n\t\tit(\"should convert temp correctly from Fahrenheit to Celsius\", () => {\n\t\t\texpect(Math.round(WeatherUtils.convertTempToMetric(10))).toBe(-12);\n\t\t});\n\t});\n\n\tdescribe(\"windspeed conversion to beaufort\", () => {\n\t\tit(\"should convert windspeed correctly from mps to beaufort\", () => {\n\t\t\texpect(Math.round(WeatherUtils.convertWind(5, \"beaufort\"))).toBe(3);\n\t\t\texpect(Math.round(WeatherUtils.convertWind(300, \"beaufort\"))).toBe(12);\n\t\t});\n\n\t\tit(\"should convert windspeed correctly from mps to mps\", () => {\n\t\t\texpect(WeatherUtils.convertWind(11.75, \"FOOBAR\")).toBe(11.75);\n\t\t});\n\n\t\tit(\"should convert windspeed correctly from mps to kmh\", () => {\n\t\t\texpect(Math.round(WeatherUtils.convertWind(11.75, \"kmh\"))).toBe(42);\n\t\t});\n\n\t\tit(\"should convert windspeed correctly from mps to knots\", () => {\n\t\t\texpect(Math.round(WeatherUtils.convertWind(10, \"knots\"))).toBe(19);\n\t\t});\n\n\t\tit(\"should convert windspeed correctly from mph to mps\", () => {\n\t\t\texpect(Math.round(WeatherUtils.convertWindToMetric(93.951324266285))).toBe(42);\n\t\t});\n\n\t\tit(\"should convert windspeed correctly from kmh to mps\", () => {\n\t\t\texpect(Math.round(WeatherUtils.convertWindToMs(151.2))).toBe(42);\n\t\t});\n\t});\n\n\tdescribe(\"wind direction conversion\", () => {\n\t\tit(\"should convert wind direction correctly from cardinal to value\", () => {\n\t\t\texpect(WeatherUtils.convertWindDirection(\"SSE\")).toBe(157);\n\t\t\texpect(WeatherUtils.convertWindDirection(\"XXX\")).toBeNull();\n\t\t});\n\t});\n\n\tdescribe(\"feelsLike calculation\", () => {\n\t\tit(\"should return a calculated feelsLike info (negative value)\", () => {\n\t\t\texpect(WeatherUtils.calculateFeelsLike(0, 20, 40)).toBe(-9.397005931555448);\n\t\t});\n\n\t\tit(\"should return a calculated feelsLike info (positive value)\", () => {\n\t\t\texpect(WeatherUtils.calculateFeelsLike(30, 0, 60)).toBe(32.832032277777756);\n\t\t});\n\t});\n\n\tdescribe(\"precipitationUnit conversion\", () => {\n\t\tit(\"should keep value and unit if outputUnit is undefined\", () => {\n\t\t\tconst values = [1, 2];\n\t\t\tconst units = [\"mm\", \"cm\"];\n\n\t\t\tfor (let i = 0; i < values.length; i++) {\n\t\t\t\tconst result = weather.convertPrecipitationUnit(values[i], units[i], undefined);\n\t\t\t\texpect(result).toBe(`${values[i].toFixed(2)} ${units[i]}`);\n\t\t\t}\n\t\t});\n\n\t\tit(\"should keep value and unit if outputUnit is metric\", () => {\n\t\t\tconst values = [1, 2];\n\t\t\tconst units = [\"mm\", \"cm\"];\n\n\t\t\tfor (let i = 0; i < values.length; i++) {\n\t\t\t\tconst result = weather.convertPrecipitationUnit(values[i], units[i], \"metric\");\n\t\t\t\texpect(result).toBe(`${values[i].toFixed(2)} ${units[i]}`);\n\t\t\t}\n\t\t});\n\n\t\tit(\"should use mm unit if input unit is undefined\", () => {\n\t\t\tconst values = [1, 2];\n\n\t\t\tfor (let i = 0; i < values.length; i++) {\n\t\t\t\tconst result = weather.convertPrecipitationUnit(values[i], undefined, \"metric\");\n\t\t\t\texpect(result).toBe(`${values[i].toFixed(2)} mm`);\n\t\t\t}\n\t\t});\n\n\t\tit(\"should convert value and unit if outputUnit is imperial\", () => {\n\t\t\tconst values = [1, 2];\n\t\t\tconst units = [\"mm\", \"cm\"];\n\t\t\tconst expectedValues = [0.04, 0.79];\n\n\t\t\tfor (let i = 0; i < values.length; i++) {\n\t\t\t\tconst result = weather.convertPrecipitationUnit(values[i], units[i], \"imperial\");\n\t\t\t\texpect(result).toBe(`${expectedValues[i]} in`);\n\t\t\t}\n\t\t});\n\n\t\tit(\"should round percentage values regardless of output units\", () => {\n\t\t\tconst values = [0.1, 2.22, 9.999];\n\t\t\tconst output = [undefined, \"imperial\", \"metric\"];\n\t\t\tconst result = [\"0 %\", \"2 %\", \"10 %\"];\n\n\t\t\tfor (let i = 0; i < values.length; i++) {\n\t\t\t\texpect(weather.convertPrecipitationUnit(values[i], \"%\", output[i])).toBe(result[i]);\n\t\t\t}\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "tests/utils/vitest-setup.js",
    "content": "/**\n * Vitest setup file for module aliasing and CI logging\n * This allows require(\"logger\") to work in unit tests\n */\n\nconst Module = require(\"node:module\");\nconst path = require(\"node:path\");\n\n// Set test mode flag for application code to detect test environment\nprocess.env.mmTestMode = \"true\";\n\n// Store the original require\nconst originalRequire = Module.prototype.require;\n\n// Track if we've already applied log level\nlet logLevelApplied = false;\n\n// Override require to handle our custom aliases\nModule.prototype.require = function (id) {\n\t// Handle \"logger\" alias\n\tif (id === \"logger\") {\n\t\tconst logger = originalRequire.call(this, path.resolve(__dirname, \"../../js/logger.js\"));\n\n\t\t// Suppress debug/info logs in CI to keep output clean\n\t\tif (!logLevelApplied && process.env.CI === \"true\") {\n\t\t\tlogger.setLogLevel(\"ERROR\");\n\t\t\tlogLevelApplied = true;\n\t\t}\n\n\t\treturn logger;\n\t}\n\n\t// Handle all other requires normally\n\treturn originalRequire.apply(this, arguments);\n};\n"
  },
  {
    "path": "tests/utils/weather_mocker.js",
    "content": "const fs = require(\"node:fs\");\nconst path = require(\"node:path\");\nconst exec = require(\"node:child_process\").execSync;\n\n/**\n * @param {string} type what data to read, can be \"current\" \"forecast\" or \"hourly\n * @param {object} extendedData extra data to add to the default mock data\n * @returns {string} mocked current weather data\n */\nconst readMockData = (type, extendedData = {}) => {\n\tlet fileName;\n\n\tswitch (type) {\n\t\tcase \"forecast\":\n\t\t\tfileName = \"weather_forecast.json\";\n\t\t\tbreak;\n\t\tcase \"hourly\":\n\t\t\tfileName = \"weather_hourly.json\";\n\t\t\tbreak;\n\t\tcase \"current\":\n\t\tdefault:\n\t\t\tfileName = \"weather_current.json\";\n\t\t\tbreak;\n\t}\n\n\tconst fileData = JSON.parse(fs.readFileSync(path.resolve(`${__dirname}/../mocks/${fileName}`)).toString());\n\tconst mergedData = JSON.stringify({ ...{}, ...fileData, ...extendedData });\n\treturn mergedData;\n};\n\nconst injectMockData = (configFileName, extendedData = {}) => {\n\tlet mockWeather;\n\tif (configFileName.includes(\"forecast\")) {\n\t\tmockWeather = readMockData(\"forecast\", extendedData);\n\t} else if (configFileName.includes(\"hourly\")) {\n\t\tmockWeather = readMockData(\"hourly\", extendedData);\n\t} else {\n\t\tmockWeather = readMockData(\"current\", extendedData);\n\t}\n\tlet content = fs.readFileSync(configFileName).toString();\n\tcontent = content.replace(\"#####WEATHERDATA#####\", mockWeather);\n\tconst tempFile = configFileName.replace(\".js\", \"_temp.js\");\n\tfs.writeFileSync(tempFile, content);\n\treturn tempFile;\n};\n\nconst cleanupMockData = () => {\n\tconst tempDir = path.resolve(`${__dirname}/../configs`).toString();\n\texec(`find ${tempDir} -type f -name *_temp.js -delete`);\n};\n\nmodule.exports = { injectMockData, cleanupMockData };\n"
  },
  {
    "path": "translations/af.json",
    "content": "{\n\t\"LOADING\": \"Besig om te laai …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"Eergister\",\n\t\"YESTERDAY\": \"Gister\",\n\t\"TODAY\": \"Vandag\",\n\t\"TOMORROW\": \"Môre\",\n\t\"DAYAFTERTOMORROW\": \"Oormôre\",\n\t\"RUNNING\": \"Eindig in\",\n\t\"EMPTY\": \"Geen komende gebeurtenisse.\",\n\t\"WEEK\": \"Week {weekNumber}\",\n\n\t\"N\": \"N\",\n\t\"NNE\": \"NNO\",\n\t\"NE\": \"NO\",\n\t\"ENE\": \"ONO\",\n\t\"E\": \"O\",\n\t\"ESE\": \"OSO\",\n\t\"SE\": \"SO\",\n\t\"SSE\": \"SSO\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SSW\",\n\t\"SW\": \"SW\",\n\t\"WSW\": \"WSW\",\n\t\"W\": \"W\",\n\t\"WNW\": \"WNW\",\n\t\"NW\": \"NW\",\n\t\"NNW\": \"NNW\",\n\n\t\"FEELS\": \"Voel soos {DEGREE}\",\n\t\"PRECIP_POP\": \"Neerslag waarskynlikheid\",\n\t\"PRECIP_AMOUNT\": \"Neerslag hoeveelheid\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Die konfigurasie opsies vir die {MODULE_NAME} module het verander.\\nGaan asseblief die dokumentasie na.\",\n\t\"MODULE_CONFIG_ERROR\": \"Fout in die {MODULE_NAME} module. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"Ongeldige URL.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Geen internetverbinding.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Owerheid het misluk.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Gaan die logs na vir meer besonderhede.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Geen nuus op die oomblik.\",\n\n\t\"UPDATE_NOTIFICATION\": \"MagicMirror² update beskikbaar.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Update beskikbaar vir {MODULE_NAME} module.\",\n\t\"UPDATE_INFO_SINGLE\": \"Die huidige installasie is {COMMIT_COUNT} commit agter op die {BRANCH_NAME} branch.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Die huidige installasie is {COMMIT_COUNT} commits agter op die {BRANCH_NAME} branch.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Update voltooi vir {MODULE_NAME} module.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Fout tydens opdatering van {MODULE_NAME} module.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"MagicMirror moet herbegin word.\"\n}\n"
  },
  {
    "path": "translations/ar.json",
    "content": "{\n\t\"LOADING\": \"جار التحميل …\",\n\n\t\"YESTERDAY\": \"أمس\",\n\t\"TODAY\": \"اليوم\",\n\t\"TOMORROW\": \"غدًا\",\n\t\"RUNNING\": \"ينتهي خلال\",\n\t\"EMPTY\": \"لا توجد أحداث قادمة.\",\n\t\"WEEK\": \"الأسبوع {weekNumber}\",\n\n\t\"N\": \"شمال\",\n\t\"NNE\": \"شمال شمال شرقي\",\n\t\"NE\": \"شمال شرقي\",\n\t\"ENE\": \"شرق شمال شرقي\",\n\t\"E\": \"شرق\",\n\t\"ESE\": \"شرق جنوب شرقي\",\n\t\"SE\": \"جنوب شرقي\",\n\t\"SSE\": \"جنوب جنوب شرقي\",\n\t\"S\": \"جنوب\",\n\t\"SSW\": \"جنوب جنوب غربي\",\n\t\"SW\": \"جنوب غربي\",\n\t\"WSW\": \"غرب جنوب غربي\",\n\t\"W\": \"غرب\",\n\t\"WNW\": \"غرب شمال غربي\",\n\t\"NW\": \"شمال غربي\",\n\t\"NNW\": \"شمال شمال غربي\",\n\n\t\"FEELS\": \"كأنها {DEGREE}\",\n\t\"PRECIP_POP\": \"احتمالية الهطول\",\n\t\"PRECIP_AMOUNT\": \"كمية الهطول\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"تم تغيير خيارات التهيئة لوحدة {MODULE_NAME}.\\nيرجى مراجعة الوثائق.\",\n\t\"MODULE_CONFIG_ERROR\": \"خطأ في وحدة {MODULE_NAME}. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"رابط غير صحيح.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"لا يوجد اتصال بالإنترنت.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"فشل التصريح.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"تحقق من السجلات لمزيد من التفاصيل.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"لا توجد أخبار في الوقت الحالي.\",\n\n\t\"UPDATE_NOTIFICATION\": \"تحديث MagicMirror² متاح.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"تحديث متاح لوحدة {MODULE_NAME}.\",\n\t\"UPDATE_INFO_SINGLE\": \"التثبيت الحالي متخلف عن تحديث واحد على فرع {BRANCH_NAME}.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"التثبيت الحالي متخلف عن {COMMIT_COUNT} تحديثات على فرع {BRANCH_NAME}.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"تم التحديث لوحدة {MODULE_NAME}\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"حدث خطأ أثناء تحديث وحدة {MODULE_NAME}\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"يتطلب إعادة تشغيل MagicMirror.\"\n}\n"
  },
  {
    "path": "translations/bg.json",
    "content": "{\n\t\"LOADING\": \"Зареждане на …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"Завчера\",\n\t\"YESTERDAY\": \"Вчера\",\n\t\"TODAY\": \"Днес\",\n\t\"TOMORROW\": \"Утре\",\n\t\"DAYAFTERTOMORROW\": \"Вдругиден\",\n\t\"RUNNING\": \"Свършва след\",\n\t\"EMPTY\": \"Няма предстоящи събития.\",\n\t\"WEEK\": \"Седмица {weekNumber}\",\n\n\t\"N\": \"С\",\n\t\"NNE\": \"ССИ\",\n\t\"NE\": \"СИ\",\n\t\"ENE\": \"ИСИ\",\n\t\"E\": \"И\",\n\t\"ESE\": \"ИЮИ\",\n\t\"SE\": \"ЮИ\",\n\t\"SSE\": \"ЮЮИ\",\n\t\"S\": \"Ю\",\n\t\"SSW\": \"ЮЮЗ\",\n\t\"SW\": \"ЮЗ\",\n\t\"WSW\": \"ЗЮЗ\",\n\t\"W\": \"З\",\n\t\"WNW\": \"ЗСЗ\",\n\t\"NW\": \"СЗ\",\n\t\"NNW\": \"ССЗ\",\n\n\t\"FEELS\": \"Усеща се като {DEGREE}\",\n\t\"PRECIP_POP\": \"Вероятност за валежи\",\n\t\"PRECIP_AMOUNT\": \"Количество валежи\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Променени са опциите за конфигурация на модула „{MODULE_NAME}“.\\nМоля, проверете документацията.\",\n\t\"MODULE_CONFIG_ERROR\": \"Грешка в модула „{MODULE_NAME}“. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"Неправилен URL адрес.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Няма интернет връзка.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Неуспешна авторизация.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Проверете логовете за повече подробности.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Няма новини в момента.\",\n\n\t\"UPDATE_NOTIFICATION\": \"Налична е актуализация за MagicMirror².\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Налична е актуализация за модула „{MODULE_NAME}“.\",\n\t\"UPDATE_INFO_SINGLE\": \"Инсталираната версия е с {COMMIT_COUNT} ревизия назад от клона „{BRANCH_NAME}“.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Инсталираната версия е с {COMMIT_COUNT} ревизии назад от клона „{BRANCH_NAME}“.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Актуализацията на модула „{MODULE_NAME}“ е завършена.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Грешка при актуализацията на модула „{MODULE_NAME}“\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"Необходимо е рестартиране на MagicMirror.\"\n}\n"
  },
  {
    "path": "translations/ca.json",
    "content": "{\n\t\"LOADING\": \"Carregant …\",\n\n\t\"YESTERDAY\": \"Ahir\",\n\t\"TODAY\": \"Avui\",\n\t\"TOMORROW\": \"Demà\",\n\t\"DAYAFTERTOMORROW\": \"Demà passat\",\n\t\"RUNNING\": \"Acaba en\",\n\t\"EMPTY\": \"No hi ha esdeveniments programats.\",\n\t\"WEEK\": \"Setmana\",\n\n\t\"N\": \"N\",\n\t\"NNE\": \"NNE\",\n\t\"NE\": \"NE\",\n\t\"ENE\": \"ENE\",\n\t\"E\": \"E\",\n\t\"ESE\": \"ESE\",\n\t\"SE\": \"SE\",\n\t\"SSE\": \"SSE\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SSO\",\n\t\"SW\": \"SO\",\n\t\"WSW\": \"OSO\",\n\t\"W\": \"O\",\n\t\"WNW\": \"ONO\",\n\t\"NW\": \"NO\",\n\t\"NNW\": \"NNO\",\n\n\t\"FEELS\": \"Sensació tèrmica {DEGREE}\",\n\t\"PRECIP_POP\": \"Probabilitat de precipitació\",\n\t\"PRECIP_AMOUNT\": \"Quantitat de precipitació\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"S'ha canviat l'opció de configuració del mòdul {MODULE_NAME}.\\nConsulta la documentació.\",\n\t\"MODULE_CONFIG_ERROR\": \"S'ha produït un error al mòdul {MODULE_NAME}. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"L'URL és mal format.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"No hi ha connexió a Internet.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"L'autorització ha fallat.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Consulta els registres per a més detalls.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"No hi ha notícies disponibles en aquest moment.\",\n\n\t\"UPDATE_NOTIFICATION\": \"MagicMirror² actualizació disponible.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Disponible una actualizació per al mòdul {MODULE_NAME}.\",\n\t\"UPDATE_INFO_SINGLE\": \"La teva instal·lació actual està {COMMIT_COUNT} commit canvis darrere de la branca {BRANCH_NAME}.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"La teva instal·lació actual està {COMMIT_COUNT} commits canvis darrere de la branca {BRANCH_NAME}.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"S'ha completat l'actualització del mòdul {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"S'ha produït un error durant l'actualització del mòdul {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"És necessari reiniciar MagicMirror.\"\n}\n"
  },
  {
    "path": "translations/cs.json",
    "content": "{\n\t\"LOADING\": \"Načítání …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"Předevčírem\",\n\t\"YESTERDAY\": \"Včera\",\n\t\"TODAY\": \"Dnes\",\n\t\"TOMORROW\": \"Zítra\",\n\t\"DAYAFTERTOMORROW\": \"Pozítří\",\n\t\"RUNNING\": \"Končí za\",\n\t\"EMPTY\": \"Žádné nadcházející události.\",\n\t\"WEEK\": \"{weekNumber}. týden\",\n\n\t\"N\": \"S\",\n\t\"NNE\": \"SSV\",\n\t\"NE\": \"SV\",\n\t\"ENE\": \"VSV\",\n\t\"E\": \"V\",\n\t\"ESE\": \"VJV\",\n\t\"SE\": \"JV\",\n\t\"SSE\": \"JJV\",\n\t\"S\": \"J\",\n\t\"SSW\": \"JJZ\",\n\t\"SW\": \"JZ\",\n\t\"WSW\": \"ZJZ\",\n\t\"W\": \"Z\",\n\t\"WNW\": \"ZSZ\",\n\t\"NW\": \"SZ\",\n\t\"NNW\": \"SSZ\",\n\n\t\"FEELS\": \"Pocitově {DEGREE}\",\n\t\"PRECIP_POP\": \"Pravděpodobnost deště\",\n\t\"PRECIP_AMOUNT\": \"Množství deště\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Konfigurační možnosti modulu {MODULE_NAME} byly změněny.\\nProsím, zkontrolujte dokumentaci.\",\n\t\"MODULE_CONFIG_ERROR\": \"Chyba v modulu {MODULE_NAME}. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"Nesprávná URL adresa.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Není připojení k internetu.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Autorizace selhala.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Zkontrolujte protokoly pro více informací.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Žádné zprávy.\",\n\n\t\"UPDATE_NOTIFICATION\": \"Dostupná aktualizace pro MagicMirror².\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Dostupná aktualizace pro modul {MODULE_NAME}.\",\n\t\"UPDATE_INFO_SINGLE\": \"Současná instalace je na větvi {BRANCH_NAME} pozadu o {COMMIT_COUNT} commit.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Současná instalace je na větvi {BRANCH_NAME} pozadu o {COMMIT_COUNT} commits.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Aktualizace dokončena pro modul {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Chyba aktualizace modulu {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"Je třeba restartovat MagicMirror.\"\n}\n"
  },
  {
    "path": "translations/cv.json",
    "content": "{\n\t\"LOADING\": \"Тиенет …\",\n\n\t\"YESTERDAY\": \"Знон\",\n\t\"TODAY\": \"Паян\",\n\t\"TOMORROW\": \"Ыран\",\n\t\"DAYAFTERTOMORROW\": \"Виҫмине\",\n\t\"RUNNING\": \"Хальхи\",\n\t\"EMPTY\": \"Пулас ӗҫ ҫук\",\n\t\"WEEK\": \"{weekNumber} эрне\",\n\n\t\"N\": \"Ҫ\",\n\t\"NNE\": \"ҪҪТ\",\n\t\"NE\": \"ҪТ\",\n\t\"ENE\": \"ТҪТ\",\n\t\"E\": \"Т\",\n\t\"ESE\": \"ТКТ\",\n\t\"SE\": \"КТ\",\n\t\"SSE\": \"ККТ\",\n\t\"S\": \"К\",\n\t\"SSW\": \"ККА\",\n\t\"SW\": \"КА\",\n\t\"WSW\": \"АКА\",\n\t\"W\": \"А\",\n\t\"WNW\": \"АҪА\",\n\t\"NW\": \"ҪА\",\n\t\"NNW\": \"ҪҪА\",\n\n\t\"FEELS\": \"Туйӑннӑ {DEGREE}\",\n\t\"PRECIP_POP\": \"Ҫумӑр ҫума пултарасси\",\n\t\"PRECIP_AMOUNT\": \"Ҫумӑр виҫи\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"{MODULE_NAME} модулӗн конфигураци опциялӗ пур ҫӗнтерӗ.\",\n\t\"MODULE_CONFIG_ERROR\": \"{MODULE_NAME} модулӗнде хата. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"Ҫӗҫ ҫӗнӗ URL хата.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Интернет-пулла хӗҫҫӗн.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Авторизация хата.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Тӗп лог ҫӗнтерӗ.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Пулас ҫӗнтер ҫук.\",\n\n\t\"UPDATE_NOTIFICATION\": \"MagicMirror² валли ҫӗнетӳ пур.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"{MODULE_NAME} модуль валли ҫӗнетӳ пур.\",\n\t\"UPDATE_INFO_SINGLE\": \"Ҫак инсталляци {BRANCH_NAME} commit турат {COMMIT_COUNT} коммитпа кая уйрӑлса тӑрать.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Ҫак инсталляци {BRANCH_NAME} commit турат {COMMIT_COUNT} коммитпа кая уйрӑлса тӑрать.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"{MODULE_NAME} модулӗнде валли ҫӗнетӳ пур.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"{MODULE_NAME} модулӗнде валли ҫӗнетӳ хата.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"MagicMirror перезагрузка тӗрӗҫҫӗн тӑрать.\"\n}\n"
  },
  {
    "path": "translations/cy.json",
    "content": "{\n\t\"LOADING\": \"Llwytho …\",\n\n\t\"YESTERDAY\": \"Ddoe\",\n\t\"TODAY\": \"Heddiw\",\n\t\"TOMORROW\": \"Yfory\",\n\t\"DAYAFTERTOMORROW\": \"Drennydd\",\n\t\"RUNNING\": \"Gorffen mewn\",\n\t\"EMPTY\": \"Dim digwyddiadau.\",\n\t\"WEEK\": \"Wythnos {weekNumber}\",\n\n\t\"N\": \"Go\",\n\t\"NNE\": \"GoGoDw\",\n\t\"NE\": \"GoDw\",\n\t\"ENE\": \"DwGoDw\",\n\t\"E\": \"Dw\",\n\t\"ESE\": \"DwDeDw\",\n\t\"SE\": \"DwDe\",\n\t\"SSE\": \"DeDeDw\",\n\t\"S\": \"De\",\n\t\"SSW\": \"DeDeGr\",\n\t\"SW\": \"DeGr\",\n\t\"WSW\": \"GrDeGr\",\n\t\"W\": \"Gr\",\n\t\"WNW\": \"GrGoGr\",\n\t\"NW\": \"GoGr\",\n\t\"NNW\": \"GoGoGe\",\n\n\t\"FEELS\": \"Teimlad {DEGREE}\",\n\t\"PRECIP_POP\": \"Tebygolrwydd glawiad\",\n\t\"PRECIP_AMOUNT\": \"Cyfanswm glawiad\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Mae'r dewisiadau ar gyfer y modiwl {MODULE_NAME} wedi newid.\\nGwiriwch y ddogfennaeth.\",\n\t\"MODULE_CONFIG_ERROR\": \"Gwall yn y modiwl {MODULE_NAME}. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"URL anghywir.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Dim cysylltiad rhyngrwyd.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Methiant awdurdodi.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Gwiriwch y logiau am ragor o fanylion.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Dim newyddion ar hyn o bryd.\",\n\n\t\"UPDATE_NOTIFICATION\": \"MagicMirror² mwy diweddar yn barod.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Mae diweddaraiad ar gyfer y modiwl {MODULE_NAME}.\",\n\t\"UPDATE_INFO_SINGLE\": \"Mae'r fersiwn bresenol {COMMIT_COUNT} commit tu ôl i'r gangen {BRANCH_NAME}.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Mae'r fersiwn bresenol {COMMIT_COUNT} commit tu ôl i'r gangen {BRANCH_NAME}.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Diweddariad wedi'i gwblhau ar gyfer y modiwl {MODULE_NAME}\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Gwall diweddariad ar gyfer y modiwl {MODULE_NAME}\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"Mae angen ailgychwyn MagicMirror.\"\n}\n"
  },
  {
    "path": "translations/da.json",
    "content": "{\n\t\"LOADING\": \"Indlæser …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"Forgårs\",\n\t\"YESTERDAY\": \"I går\",\n\t\"TODAY\": \"I dag\",\n\t\"TOMORROW\": \"I morgen\",\n\t\"DAYAFTERTOMORROW\": \"I overmorgen\",\n\t\"RUNNING\": \"Slutter om\",\n\t\"EMPTY\": \"Ingen kommende begivenheder.\",\n\t\"WEEK\": \"Uge {weekNumber}\",\n\n\t\"N\": \"N\",\n\t\"NNE\": \"NNØ\",\n\t\"NE\": \"NØ\",\n\t\"ENE\": \"ØNØ\",\n\t\"E\": \"Ø\",\n\t\"ESE\": \"ØSØ\",\n\t\"SE\": \"SØ\",\n\t\"SSE\": \"SSØ\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SSV\",\n\t\"SW\": \"SV\",\n\t\"WSW\": \"VSV\",\n\t\"W\": \"V\",\n\t\"WNW\": \"VNV\",\n\t\"NW\": \"NV\",\n\t\"NNW\": \"NNV\",\n\n\t\"FEELS\": \"Føles som {DEGREE}\",\n\t\"PRECIP_POP\": \"Sandsynlighed for nedbør\",\n\t\"PRECIP_AMOUNT\": \"Nedbørsmængde\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Konfigurationsmulighederne for {MODULE_NAME} modulet er ændret.\\nSe venligst dokumentationen.\",\n\t\"MODULE_CONFIG_ERROR\": \"Fejl i {MODULE_NAME} modulet. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"Forkert url.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Ingen internetforbindelse.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Godkendelse mislykkedes.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Tjek logfiler for flere detaljer.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Ingen nyheder i øjeblikket.\",\n\n\t\"UPDATE_NOTIFICATION\": \"MagicMirror² opdatering tilgængelig.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Opdatering tilgængelig for {MODULE_NAME} modulet.\",\n\t\"UPDATE_INFO_SINGLE\": \"Den nuværende installation er {COMMIT_COUNT} commit bagud på {BRANCH_NAME} branch'en.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Den nuværende installation er {COMMIT_COUNT} commits bagud på {BRANCH_NAME} branch'en.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Opdatering færdig for {MODULE_NAME} modulet\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Opdateringsfejl for {MODULE_NAME} modulet\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"Genstart af MagicMirror er påkrævet.\"\n}\n"
  },
  {
    "path": "translations/de.json",
    "content": "{\n\t\"LOADING\": \"Lade …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"Vorgestern\",\n\t\"YESTERDAY\": \"Gestern\",\n\t\"TODAY\": \"Heute\",\n\t\"TOMORROW\": \"Morgen\",\n\t\"DAYAFTERTOMORROW\": \"Übermorgen\",\n\t\"RUNNING\": \"noch\",\n\t\"EMPTY\": \"Keine Termine.\",\n\t\"WEEK\": \"{weekNumber}. Kalenderwoche\",\n\t\"WEEK_SHORT\": \"{weekNumber}KW\",\n\n\t\"N\": \"N\",\n\t\"NNE\": \"NNO\",\n\t\"NE\": \"NO\",\n\t\"ENE\": \"ONO\",\n\t\"E\": \"O\",\n\t\"ESE\": \"OSO\",\n\t\"SE\": \"SO\",\n\t\"SSE\": \"SSO\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SSW\",\n\t\"SW\": \"SW\",\n\t\"WSW\": \"WSW\",\n\t\"W\": \"W\",\n\t\"WNW\": \"WNW\",\n\t\"NW\": \"NW\",\n\t\"NNW\": \"NNW\",\n\n\t\"FEELS\": \"Gefühlt {DEGREE}\",\n\t\"PRECIP_POP\": \"Niederschlagswahrscheinlichkeit\",\n\t\"PRECIP_AMOUNT\": \"Niederschlagsmenge\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Die Konfigurationsoptionen für das Modul „{MODULE_NAME}“ haben sich geändert. \\nBitte überprüfen Sie die Dokumentation.\",\n\t\"MODULE_CONFIG_ERROR\": \"Fehler im Modul „{MODULE_NAME}“. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"Fehlerhafte URL.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Keine Internetverbindung.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Autorisierung fehlgeschlagen.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Prüfe die Logdateien für weitere Details.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Momentan keine Neuigkeiten.\",\n\n\t\"UPDATE_NOTIFICATION\": \"Aktualisierung für MagicMirror² verfügbar.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Aktualisierung für das Modul „{MODULE_NAME}“ verfügbar.\",\n\t\"UPDATE_INFO_SINGLE\": \"Die aktuelle Installation ist ein Commit hinter dem {BRANCH_NAME}-Branch.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Die aktuelle Installation ist {COMMIT_COUNT} Commits hinter dem {BRANCH_NAME}-Branch.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Aktualisierung für das Modul {MODULE_NAME} abgeschlossen.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Fehler bei der Aktualisierung für das Modul {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"MagicMirror muss neu gestartet werden.\"\n}\n"
  },
  {
    "path": "translations/el.json",
    "content": "{\n\t\"LOADING\": \"Φόρτωση ...\",\n\n\t\"DAYBEFOREYESTERDAY\": \"Προχθές\",\n\t\"YESTERDAY\": \"Χθες\",\n\t\"TODAY\": \"Σήμερα\",\n\t\"TOMORROW\": \"Αύριο\",\n\t\"DAYAFTERTOMORROW\": \"Μεθαύριο\",\n\t\"RUNNING\": \"Λήγει σε\",\n\t\"EMPTY\": \"Δεν υπάρχουν προσεχείς εκδηλώσεις.\",\n\t\"WEEK\": \"Εβδομάδα {weekNumber}\",\n\n\t\"N\": \"B\",\n\t\"NNE\": \"BBA\",\n\t\"NE\": \"BA\",\n\t\"ENE\": \"ABA\",\n\t\"E\": \"A\",\n\t\"ESE\": \"ANA\",\n\t\"SE\": \"NA\",\n\t\"SSE\": \"NNA\",\n\t\"S\": \"N\",\n\t\"SSW\": \"NNΔ\",\n\t\"SW\": \"NΔ\",\n\t\"WSW\": \"ΔNΔ\",\n\t\"W\": \"Δ\",\n\t\"WNW\": \"ΔΒΔ\",\n\t\"NW\": \"ΒΔ\",\n\t\"NNW\": \"ΒΒΔ\",\n\n\t\"FEELS\": \"Αίσθηση {DEGREE}\",\n\t\"PRECIP_POP\": \"Πιθ. υετού\",\n\t\"PRECIP_AMOUNT\": \"Ποσότητα υετού\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Οι επιλογές διαμόρφωσης για το module {MODULE_NAME} έχουν αλλάξει.\\nΕλέγξτε την τεκμηρίωση.\",\n\t\"MODULE_CONFIG_ERROR\": \"Σφάλμα στο module {MODULE_NAME}. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"Λανθασμένη μορφή url.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Δεν υπάρχει σύνδεση στο διαδίκτυο.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Η εξουσιοδότηση απέτυχε.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Ελέγξτε τα αρχεία καταγραφής για περισσότερες λεπτομέρειες.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Δεν υπάρχουν ειδήσεις αυτή τη στιγμή.\",\n\n\t\"UPDATE_NOTIFICATION\": \"Διατίθεται ενημέρωση MagicMirror².\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Διατίθεται ενημέρωση για το module {MODULE_NAME}.\",\n\t\"UPDATE_INFO_SINGLE\": \"Η τρέχουσα εγκατάσταση είναι {COMMIT_COUNT} commit πίσω στο branch {BRANCH_NAME}.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Η τρέχουσα εγκατάσταση είναι {COMMIT_COUNT} commit πίσω στο branch {BRANCH_NAME}\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Η ενημέρωση ολοκληρώθηκε για το module {MODULE_NAME}\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Η ενημέρωση απέτυχε για το module {MODULE_NAME}\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"Απαιτείται επανεκκίνηση του MagicMirror.\"\n}\n"
  },
  {
    "path": "translations/en.json",
    "content": "{\n\t\"LOADING\": \"Loading …\",\n\n\t\"YESTERDAY\": \"Yesterday\",\n\t\"TODAY\": \"Today\",\n\t\"TOMORROW\": \"Tomorrow\",\n\t\"RUNNING\": \"Ends in\",\n\t\"EMPTY\": \"No upcoming events.\",\n\t\"WEEK\": \"Week {weekNumber}\",\n\t\"WEEK_SHORT\": \"W{weekNumber}\",\n\n\t\"N\": \"N\",\n\t\"NNE\": \"NNE\",\n\t\"NE\": \"NE\",\n\t\"ENE\": \"ENE\",\n\t\"E\": \"E\",\n\t\"ESE\": \"ESE\",\n\t\"SE\": \"SE\",\n\t\"SSE\": \"SSE\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SSW\",\n\t\"SW\": \"SW\",\n\t\"WSW\": \"WSW\",\n\t\"W\": \"W\",\n\t\"WNW\": \"WNW\",\n\t\"NW\": \"NW\",\n\t\"NNW\": \"NNW\",\n\n\t\"FEELS\": \"Feels like {DEGREE}\",\n\t\"PRECIP_POP\": \"PoP\",\n\t\"PRECIP_AMOUNT\": \"Precipitation amount\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"The configuration options for the {MODULE_NAME} module have changed.\\nPlease check the documentation.\",\n\t\"MODULE_CONFIG_ERROR\": \"Error in the {MODULE_NAME} module. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"Malformed url.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"No internet connection.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Authorization failed.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Check logs for more details.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"No news at the moment.\",\n\n\t\"UPDATE_NOTIFICATION\": \"MagicMirror² update available.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Update available for {MODULE_NAME} module.\",\n\t\"UPDATE_INFO_SINGLE\": \"The current installation is {COMMIT_COUNT} commit behind on the {BRANCH_NAME} branch.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"The current installation is {COMMIT_COUNT} commits behind on the {BRANCH_NAME} branch.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Update done for {MODULE_NAME} module\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Update error for {MODULE_NAME} module\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"Restarting of MagicMirror is required.\"\n}\n"
  },
  {
    "path": "translations/eo.json",
    "content": "{\n\t\"LOADING\": \"Ŝarĝas …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"antaŭhieraŭ\",\n\t\"YESTERDAY\": \"hieraŭ\",\n\t\"TODAY\": \"hodiaŭ\",\n\t\"TOMORROW\": \"morgaŭ\",\n\t\"DAYAFTERTOMORROW\": \"postmorgaŭ\",\n\t\"RUNNING\": \"ankoraŭ\",\n\t\"EMPTY\": \"Neniu evento.\",\n\t\"WEEK\": \"{weekNumber}a kalendara semajno\",\n\t\"WEEK_SHORT\": \"{weekNumber}a KS\",\n\n\t\"N\": \"N\",\n\t\"NNE\": \"NNOr\",\n\t\"NE\": \"NOr\",\n\t\"ENE\": \"OrNOr\",\n\t\"E\": \"Or\",\n\t\"ESE\": \"OrSOr\",\n\t\"SE\": \"SOr\",\n\t\"SSE\": \"SSOr\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SSOk\",\n\t\"SW\": \"SOk\",\n\t\"WSW\": \"OkSOk\",\n\t\"W\": \"Ok\",\n\t\"WNW\": \"OkNOk\",\n\t\"NW\": \"NOk\",\n\t\"NNW\": \"NNOk\",\n\n\t\"FEELS\": \"Perceptite kiel {DEGREE}\",\n\t\"PRECIP_POP\": \"Ŝanco de pluvado\",\n\t\"PRECIP_AMOUNT\": \"Kvanto de pluvo\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"La agordaj opcioj por la modulo „{MODULE_NAME}“ ŝanĝiĝis. \\nBonvolu kontroli la dokumentadon.\",\n\t\"MODULE_CONFIG_ERROR\": \"Eraro en la modulo „{MODULE_NAME}“. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"Malĝusta URL.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Neniu interreta konekto.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Aŭtorigo malsukcesis.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Kontrolu la protokolajn dosierojn por pli da detaloj.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Momente neniu novaĵoj.\",\n\n\t\"UPDATE_NOTIFICATION\": \"Ĝisdatigo por MagicMirror² disponebla.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Ĝisdatigo por la modulo „{MODULE_NAME}“ disponebla.\",\n\t\"UPDATE_INFO_SINGLE\": \"La nuna instalado estas unu komito malantaŭ la {BRANCH_NAME}-branĉo.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"La nuna instalado estas {COMMIT_COUNT} komitoj malantaŭ la {BRANCH_NAME}-branĉo.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Ĝisdatigo por la modulo {MODULE_NAME} finita.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Eraro dum la ĝisdatigo de la modulo {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"MagicMirror devas esti restartigita.\"\n}\n"
  },
  {
    "path": "translations/es.json",
    "content": "{\n\t\"LOADING\": \"Cargando …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"Anteayer\",\n\t\"YESTERDAY\": \"Ayer\",\n\t\"TODAY\": \"Hoy\",\n\t\"TOMORROW\": \"Mañana\",\n\t\"DAYAFTERTOMORROW\": \"Pasado mañana\",\n\t\"RUNNING\": \"Termina en\",\n\t\"EMPTY\": \"No hay eventos programados.\",\n\t\"WEEK\": \"Semana {weekNumber}\",\n\t\"WEEK_SHORT\": \"S{weekNumber}\",\n\n\t\"N\": \"N\",\n\t\"NNE\": \"NNE\",\n\t\"NE\": \"NE\",\n\t\"ENE\": \"ENE\",\n\t\"E\": \"E\",\n\t\"ESE\": \"ESE\",\n\t\"SE\": \"SE\",\n\t\"SSE\": \"SSE\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SSO\",\n\t\"SW\": \"SO\",\n\t\"WSW\": \"OSO\",\n\t\"W\": \"O\",\n\t\"WNW\": \"ONO\",\n\t\"NW\": \"NO\",\n\t\"NNW\": \"NNO\",\n\n\t\"FEELS\": \"Sensación térmica de {DEGREE}\",\n\t\"PRECIP_POP\": \"Precipitación\",\n\t\"PRECIP_AMOUNT\": \"Cantidad de precipitación\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Las opciones de configuración para el módulo {MODULE_NAME} han cambiado. \\nVerifique la documentación.\",\n\t\"MODULE_CONFIG_ERROR\": \"Error en el módulo {MODULE_NAME}. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"URL mal formado.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"No hay conexión a Internet.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"No autorizado.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Por favor, consulte los registros para obtener más información.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"No hay noticias disponibles en este momento.\",\n\n\t\"UPDATE_NOTIFICATION\": \"MagicMirror² actualización disponible.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Disponible una actualización para el módulo {MODULE_NAME}.\",\n\t\"UPDATE_INFO_SINGLE\": \"Tu actual instalación está {COMMIT_COUNT} commit cambios detrás de la rama {BRANCH_NAME}.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Tu actual instalación está {COMMIT_COUNT} commits cambios detrás de la rama {BRANCH_NAME}.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"S'ha completat l'actualització del mòdul {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"S'ha produït un error durant l'actualització del mòdul {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"És necessari reiniciar MagicMirror.\"\n}\n"
  },
  {
    "path": "translations/et.json",
    "content": "{\n\t\"LOADING\": \"Laadimine…\",\n\n\t\"DAYBEFOREYESTERDAY\": \"Üleeile\",\n\t\"YESTERDAY\": \"Eile\",\n\t\"TODAY\": \"Täna\",\n\t\"TOMORROW\": \"Homme\",\n\t\"DAYAFTERTOMORROW\": \"Ülehomme\",\n\t\"RUNNING\": \"Lõpeb\",\n\t\"EMPTY\": \"Eelolevaid sündmusi pole.\",\n\t\"WEEK\": \"Nädal {weekNumber}\",\n\n\t\"N\": \"Põhi\",\n\t\"NNE\": \"Põhjakirre\",\n\t\"NE\": \"Kirre\",\n\t\"ENE\": \"Idakirre\",\n\t\"E\": \"Ida\",\n\t\"ESE\": \"Idakagu\",\n\t\"SE\": \"Kagu\",\n\t\"SSE\": \"Lõunakagu\",\n\t\"S\": \"Lõuna\",\n\t\"SSW\": \"Lõunaedel\",\n\t\"SW\": \"Edel\",\n\t\"WSW\": \"Lääneedel\",\n\t\"W\": \"Lääs\",\n\t\"WNW\": \"Lääneloe\",\n\t\"NW\": \"Loe\",\n\t\"NNW\": \"Põhjaloe\",\n\n\t\"FEELS\": \"Tajutav temperatuur {DEGREE}\",\n\t\"PRECIP_POP\": \"Sademete tõenäosus\",\n\t\"PRECIP_AMOUNT\": \"Sademete hulk\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Konfiguratiooni sätted {MODULE_NAME} moodulile on muutunud.\\nPalun vaadake dokumentatsiooni.\",\n\t\"MODULE_CONFIG_ERROR\": \"Error moodulis {MODULE_NAME}. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"Ebakorrektne url.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Interneti ühendus puudub.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Autoriseerimine ebaõnnestus.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Lisateabe saamiseks kontrollige logifaile.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Hetkel ei ole uudiseid.\",\n\n\t\"UPDATE_NOTIFICATION\": \"MagicMirror²'le on uuendus saadaval.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Uuendus saadaval {MODULE_NAME} moodulile.\",\n\t\"UPDATE_INFO_SINGLE\": \"Praegune paigaldus on {COMMIT_COUNT} muudatust tagapool {BRANCH_NAME} harus.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Praegune paigaldus on {COMMIT_COUNT} muudatust tagapool {BRANCH_NAME} harus.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"{MODULE_NAME} moodul uuendati edukalt\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"{MODULE_NAME} mooduli uuendamine ebaõnnestus\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"Palun taaskäivitage MagicMirror.\"\n}\n"
  },
  {
    "path": "translations/fi.json",
    "content": "{\n\t\"LOADING\": \"Lataa …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"Toissapäivänä\",\n\t\"YESTERDAY\": \"Eilen\",\n\t\"TODAY\": \"Tänään\",\n\t\"TOMORROW\": \"Huomenna\",\n\t\"DAYAFTERTOMORROW\": \"Ylihuomenna\",\n\t\"RUNNING\": \"Päättyy {timeUntilEnd} päästä\",\n\t\"EMPTY\": \"Ei tulevia tapahtumia.\",\n\t\"WEEK\": \"Viikko {weekNumber}\",\n\n\t\"N\": \"P\",\n\t\"NNE\": \"PPI\",\n\t\"NE\": \"PI\",\n\t\"ENE\": \"IPI\",\n\t\"E\": \"I\",\n\t\"ESE\": \"IEI\",\n\t\"SE\": \"EI\",\n\t\"SSE\": \"EEI\",\n\t\"S\": \"E\",\n\t\"SSW\": \"EEL\",\n\t\"SW\": \"EL\",\n\t\"WSW\": \"LEL\",\n\t\"W\": \"L\",\n\t\"WNW\": \"LPL\",\n\t\"NW\": \"PL\",\n\t\"NNW\": \"PPL\",\n\n\t\"FEELS\": \"Tuntuu kuin {DEGREE}\",\n\t\"PRECIP_POP\": \"Sateen todennäköisyys\",\n\t\"PRECIP_AMOUNT\": \"Sateen määrä\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Moduulin {MODULE_NAME} asetukset on muutettu.\\nOle hyvä ja tarkista dokumentaatio.\",\n\t\"MODULE_CONFIG_ERROR\": \"Virhe moduulissa {MODULE_NAME}. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"Virheellinen url.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Ei internet-yhteyttä.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Valtuutus epäonnistui.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Tarkista lokitiedostot saadaksesi lisätietoja.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Ei uutisia tällä hetkellä.\",\n\n\t\"UPDATE_NOTIFICATION\": \"MagicMirror² päivitys saatavilla.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Päivitys saatavilla moduulille {MODULE_NAME}.\",\n\t\"UPDATE_INFO_SINGLE\": \"Nykyasennus on {COMMIT_COUNT} muutoksen jäljessä {BRANCH_NAME} haaraan nähden.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Nykyasennus on {COMMIT_COUNT} muutosta jäljessä {BRANCH_NAME} haaraan nähden.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Päivitys moduulille {MODULE_NAME} valmis.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Virhe moduulin {MODULE_NAME} päivityksessä.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"MagicMirror tulee käynnistää uudelleen.\"\n}\n"
  },
  {
    "path": "translations/fr.json",
    "content": "{\n\t\"LOADING\": \"Chargement…\",\n\n\t\"DAYBEFOREYESTERDAY\": \"Avant-hier\",\n\t\"YESTERDAY\": \"Hier\",\n\t\"TODAY\": \"Aujourd'hui\",\n\t\"TOMORROW\": \"Demain\",\n\t\"DAYAFTERTOMORROW\": \"Après-demain\",\n\t\"RUNNING\": \"Se termine dans\",\n\t\"EMPTY\": \"Aucun RDV à venir.\",\n\t\"WEEK\": \"Semaine {weekNumber}\",\n\t\"WEEK_SHORT\": \"S{weekNumber}\",\n\n\t\"N\": \"N\",\n\t\"NNE\": \"NNE\",\n\t\"NE\": \"NE\",\n\t\"ENE\": \"ENE\",\n\t\"E\": \"E\",\n\t\"ESE\": \"ESE\",\n\t\"SE\": \"SE\",\n\t\"SSE\": \"SSE\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SSO\",\n\t\"SW\": \"SO\",\n\t\"WSW\": \"OSO\",\n\t\"W\": \"O\",\n\t\"WNW\": \"ONO\",\n\t\"NW\": \"NO\",\n\t\"NNW\": \"NNO\",\n\n\t\"FEELS\": \"Ressenti {DEGREE}\",\n\t\"PRECIP_POP\": \"Probabilité de précipitations\",\n\t\"PRECIP_AMOUNT\": \"Quantité des précipitations\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Les options de configuration du module {MODULE_NAME} ont changé.\\nVeuillez consulter la documentation.\",\n\t\"MODULE_CONFIG_ERROR\": \"Erreur dans le module {MODULE_NAME}. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"URL mal formée.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Pas de connexion Internet.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"L'autorisation à échouée.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Consultez les journaux pour plus de détails.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Aucune nouvelle pour le moment.\",\n\n\t\"UPDATE_NOTIFICATION\": \"Une mise à jour de MagicMirror² est disponible\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Une mise à jour est disponible pour le module {MODULE_NAME}.\",\n\t\"UPDATE_INFO_SINGLE\": \"L'installation actuelle est {COMMIT_COUNT} commit en retard sur la branche {BRANCH_NAME}.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"L'installation actuelle est {COMMIT_COUNT} commits en retard sur la branche {BRANCH_NAME}.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Mise à jour effectuée pour le module {MODULE_NAME}\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Erreur lors de la mise à jour du module {MODULE_NAME}\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"Le redémarrage de MagicMirror est nécessaire.\"\n}\n"
  },
  {
    "path": "translations/fy.json",
    "content": "{\n\t\"LOADING\": \"Bezich mei laden …\",\n\n\t\"YESTERDAY\": \"Juster\",\n\t\"TODAY\": \"Hjoed\",\n\t\"TOMORROW\": \"Moarn\",\n\t\"DAYAFTERTOMORROW\": \"Oaremoarn\",\n\t\"RUNNING\": \"Einigest oer\",\n\t\"EMPTY\": \"Gjin plande &ocirc;fspraken.\",\n\t\"WEEK\": \"Wike {weekNumber}\",\n\n\t\"N\": \"N\",\n\t\"NNE\": \"NNE\",\n\t\"NE\": \"NE\",\n\t\"ENE\": \"ENE\",\n\t\"E\": \"E\",\n\t\"ESE\": \"ESE\",\n\t\"SE\": \"SE\",\n\t\"SSE\": \"SSE\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SSW\",\n\t\"SW\": \"SW\",\n\t\"WSW\": \"WSW\",\n\t\"W\": \"W\",\n\t\"WNW\": \"WNW\",\n\t\"NW\": \"NW\",\n\t\"NNW\": \"NNW\",\n\n\t\"FEELS\": \"Voelt as {DEGREE}\",\n\t\"PRECIP_POP\": \"Kans op rein\",\n\t\"PRECIP_AMOUNT\": \"Hoeveelheid rein\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"De konfiguraasje fan it {MODULE_NAME} module is feroare.\\nSjoch de dokumintaasje foar mear ynformaasje.\",\n\t\"MODULE_CONFIG_ERROR\": \"Fout yn it {MODULE_NAME} module. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"De URL is net jildich.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Gjin ynternetferbining.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Autorisearje mislearre.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Sjoch de logs foar mear ynformaasje.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Op it stuit gjin nijsberjochten.\",\n\n\t\"UPDATE_NOTIFICATION\": \"Der is in update beskikber foar MagicMirror².\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Der is in update beskikber foar it {MODULE_NAME} module.\",\n\t\"UPDATE_INFO_SINGLE\": \"Dizze ynstallaasje is {BRANCH_NAME} commit efter op {COMMIT_COUNT} commits.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Dizze ynstallaasje is {BRANCH_NAME} commits achter op {COMMIT_COUNT} commits.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"It {MODULE_NAME} module is bywurke.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Fout by it bywurkje fan it {MODULE_NAME} module.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"It is nedich om MagicMirror te herstarten.\"\n}\n"
  },
  {
    "path": "translations/gl.json",
    "content": "{\n\t\"LOADING\": \"Cargando …\",\n\n\t\"YESTERDAY\": \"Onte\",\n\t\"TODAY\": \"Hoxe\",\n\t\"TOMORROW\": \"Mañá\",\n\t\"DAYAFTERTOMORROW\": \"Pasado mañá\",\n\t\"RUNNING\": \"Remata en\",\n\t\"EMPTY\": \"Non hai próximos eventos.\",\n\n\t\"WEEK\": \"Semana {weekNumber}\",\n\n\t\"N\": \"N\",\n\t\"NNE\": \"NNE\",\n\t\"NE\": \"NE\",\n\t\"ENE\": \"ENE\",\n\t\"E\": \"E\",\n\t\"ESE\": \"ESE\",\n\t\"SE\": \"SE\",\n\t\"SSE\": \"SSE\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SSW\",\n\t\"SW\": \"SW\",\n\t\"WSW\": \"WSW\",\n\t\"W\": \"W\",\n\t\"WNW\": \"WNW\",\n\t\"NW\": \"NW\",\n\t\"NNW\": \"NNW\",\n\n\t\"FEELS\": \"Semella como {DEGREE}\",\n\t\"PRECIP_POP\": \"Precipitacións\",\n\t\"PRECIP_AMOUNT\": \"Cantidade de precipitacións\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Cambiaron as opcións de configuración para o módulo {MODULE_NAME}.\\nPor favor, verifique a documentación.\",\n\t\"MODULE_CONFIG_ERROR\": \"Hai un erro no módulo {MODULE_NAME}. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"URL mal formado.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Non hai conexión a Internet.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"A autorización fallou.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Verifique os rexistros para obter máis información.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Non hai novas no momento.\",\n\n\t\"UPDATE_NOTIFICATION\": \"Actualización dispoñible para MagicMirror².\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Actualización dispoñible para o módulo {MODULE_NAME}.\",\n\t\"UPDATE_INFO_SINGLE\": \"A instalación actual está {COMMIT_COUNT} commits detrás da rama {BRANCH_NAME}.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"A instalación actual está {COMMIT_COUNT} commits detrás da rama {BRANCH_NAME}.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Actualización feita para o módulo {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Erro na actualización do módulo {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"É necesario reiniciar MagicMirror.\"\n}\n"
  },
  {
    "path": "translations/gu.json",
    "content": "{\n\t\"LOADING\": \"લોડ થઈ રહ્યું છે …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"પરમ ગઇકાલે\",\n\t\"YESTERDAY\": \"ગઇકાલે\",\n\t\"TODAY\": \"આજે\",\n\t\"TOMORROW\": \"આવતી કાલે\",\n\t\"DAYAFTERTOMORROW\": \"પરમ દિવસે\",\n\t\"RUNNING\": \"માં સમાપ્ત થાય છે\",\n\t\"EMPTY\": \"કોઈ આગામી કાર્યક્રમ નથી.\",\n\t\"WEEK\": \"સપ્તાહ {weekNumber}\",\n\n\t\"N\": \"ઉ\",\n\t\"NNE\": \"ઉઉપુ\",\n\t\"NE\": \"ઉપુ\",\n\t\"ENE\": \"પુઉપુ\",\n\t\"E\": \"પુ\",\n\t\"ESE\": \"પુદપુ\",\n\t\"SE\": \"દપુ\",\n\t\"SSE\": \"દદપુ\",\n\t\"S\": \"દ\",\n\t\"SSW\": \"દદપ\",\n\t\"SW\": \"દપ\",\n\t\"WSW\": \"પદપ\",\n\t\"W\": \"પ\",\n\t\"WNW\": \"પઉપ\",\n\t\"NW\": \"ઉપ\",\n\t\"NNW\": \"ઉઉપ\",\n\n\t\"FEELS\": \"{DEGREE} જેવું લાગશે\",\n\t\"PRECIP_POP\": \"વર્ષા સંભાવના\",\n\t\"PRECIP_AMOUNT\": \"વર્ષા માત્રા\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"{MODULE_NAME} મોડ્યુલ માટે ગોઠવણી વિકલ્પો બદલાયા છે. \\nકૃપા કરીને દસ્તાવેજોને તપાસો.\",\n\t\"MODULE_CONFIG_ERROR\": \"{MODULE_NAME} મોડ્યુલમાં ભૂલ છે. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"ખોટી URL.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"ઇન્ટરનેટ કનેક્શન નથી.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"અધિકૃત કરવું નિષ્ફળ.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"વધુ વિગતો માટે લોગ તપાસો.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"હાલમાં કોઈ સમાચાર નથી.\",\n\n\t\"UPDATE_NOTIFICATION\": \"MagicMirror² અપડેટ ઉપલબ્ધ છે.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"{MODULE_NAME} મોડ્યુલ માટે અપડેટ ઉપલબ્ધ છે.\",\n\t\"UPDATE_INFO_SINGLE\": \"વર્તમાન ઇન્સ્ટોલેશન  એ  {BRANCH_NAME} શાખા  ની {COMMIT_COUNT} કમીટ પાછળ છે. \",\n\t\"UPDATE_INFO_MULTIPLE\": \"વર્તમાન ઇન્સ્ટોલેશન  એ  {BRANCH_NAME} શાખા  ની {COMMIT_COUNT} કમીટ પાછળ છે. \",\n\t\"UPDATE_NOTIFICATION_DONE\": \"{MODULE_NAME} મોડ્યુલ માટે અપડેટ પૂર્ણ થયું.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"{MODULE_NAME} મોડ્યુલ માટે અપડેટમાં ભૂલ આવી.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"MagicMirror ને ફરી શરૂ કરવાની જરૂર છે.\"\n}\n"
  },
  {
    "path": "translations/he.json",
    "content": "{\n\t\"LOADING\": \"טוען...\",\n\n\t\"DAYBEFOREYESTERDAY\": \"שלשום\",\n\t\"YESTERDAY\": \"אתמול\",\n\t\"TODAY\": \"היום\",\n\t\"TOMORROW\": \"מחר\",\n\t\"DAYAFTERTOMORROW\": \"בעוד יומיים\",\n\t\"RUNNING\": \"מסתיים ב\",\n\t\"EMPTY\": \"אין ארועים\",\n\t\"WEEK\": \"{weekNumber} שבוע\",\n\n\t\"N\": \"צ\",\n\t\"NNE\": \"צ-צ-מז\",\n\t\"NE\": \"צ-מז\",\n\t\"ENE\": \"מז-צ-מז\",\n\t\"E\": \"מז\",\n\t\"ESE\": \"מז-ד-מז\",\n\t\"SE\": \"ד-מז\",\n\t\"SSE\": \"ד-ד-מז\",\n\t\"S\": \"ד\",\n\t\"SSW\": \"ד-ד-מע\",\n\t\"SW\": \"ד-מע\",\n\t\"WSW\": \"מע-ד-מע\",\n\t\"W\": \"מע\",\n\t\"WNW\": \"מע-ז-מע\",\n\t\"NW\": \"ז-מע\",\n\t\"NNW\": \"צ-צ-מע\",\n\n\t\"FEELS\": \"מרגיש כמו {DEGREE}\",\n\t\"PRECIP_POP\": \"משקעים\",\n\t\"PRECIP_AMOUNT\": \"כמות משקעים\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"אפשרויות התצורה עבור מודול {MODULE_NAME} השתנו.\\nאנא בדוק את התיעוד.\",\n\t\"MODULE_CONFIG_ERROR\": \"שגיאה במודול {MODULE_NAME}. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"כתובת אתר לא תקינה.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"אין חיבור לאינטרנט.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"הזדהות נכשלה.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"בדוק את היומנים לפרטים נוספים.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"אין חדשות כרגע.\",\n\n\t\"UPDATE_NOTIFICATION\": \"עדכון זמין ל-MagicMirror²\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"עדכון זמין ב-{MODULE_NAME} מודול\",\n\t\"UPDATE_INFO_SINGLE\": \"ההתקנה הנוכחית נמצאת מאחור הענף {BRANCH_NAME} ב-{COMMIT_COUNT} מופע\",\n\t\"UPDATE_INFO_MULTIPLE\": \"ההתקנה הנוכחית נמצאת מאחור הענף {BRANCH_NAME} ב-{COMMIT_COUNT} מופעים\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"העדכון הסתיים עבור מודול {MODULE_NAME}\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"שגיאת עדכון עבור מודול {MODULE_NAME}\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"יש צורך לאתחל את ה-MagicMirror\"\n}\n"
  },
  {
    "path": "translations/hi.json",
    "content": "{\n\t\"LOADING\": \"लोड हो रहा है …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"परसों\",\n\t\"YESTERDAY\": \"कल\",\n\t\"TODAY\": \"आज\",\n\t\"TOMORROW\": \"आने वाला कल\",\n\t\"DAYAFTERTOMORROW\": \"2 दिनों में\",\n\t\"RUNNING\": \"में समाप्त\",\n\t\"EMPTY\": \"कोई आगामी कार्यक्रम नहीं।\",\n\t\"WEEK\": \"सप्ताह {weekNumber}\",\n\n\t\"N\": \"उ\",\n\t\"NNE\": \"उउपु\",\n\t\"NE\": \"उपु\",\n\t\"ENE\": \"पुउपु\",\n\t\"E\": \"पु\",\n\t\"ESE\": \"पुदपु\",\n\t\"SE\": \"दपु\",\n\t\"SSE\": \"ददपु\",\n\t\"S\": \"द\",\n\t\"SSW\": \"ददप\",\n\t\"SW\": \"दप\",\n\t\"WSW\": \"पदप\",\n\t\"W\": \"प\",\n\t\"WNW\": \"पउप\",\n\t\"NW\": \"उप\",\n\t\"NNW\": \"उउप\",\n\n\t\"FEELS\": \"{DEGREE} की तरह लगना\",\n\t\"PRECIP_POP\": \"वृष्टि की संभावना\",\n\t\"PRECIP_AMOUNT\": \"वृष्टि मात्रा\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"{MODULE_NAME} मॉड्यूल के लिए कॉन्फ़िगरेशन विकल्प बदल गए हैं।  n कृपया दस्तावेज़ देखें।\",\n\t\"MODULE_CONFIG_ERROR\": \"{MODULE_NAME} मॉड्यूल में त्रुटि। {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"गलत URL।\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"कोई इंटरनेट कनेक्शन नहीं।\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"प्राधिकरण विफल।\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"अधिक जानकारी के लिए लॉग जांचें।\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"इस समय कोई समाचार नहीं।\",\n\n\t\"UPDATE_NOTIFICATION\": \"MagicMirror² अपडेट उपलब्ध।\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"{MODULE_NAME} मॉड्यूल के लिए उपलब्ध अद्यतन।\",\n\t\"UPDATE_INFO_SINGLE\": \"वर्तमान स्थापना {COMMIT_COUNT} {BRANCH_NAME} शाखा के पीछे है।\",\n\t\"UPDATE_INFO_MULTIPLE\": \"वर्तमान स्थापना {COMMIT_COUNT} पीछे {BRANCH_NAME} शाखा पर है।\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"{MODULE_NAME} मॉड्यूल के लिए अद्यतन पूरा।\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"{MODULE_NAME} मॉड्यूल के लिए अद्यतन त्रुटि।\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"MagicMirror को पुनः आरंभ करने की आवश्यकता है।\"\n}\n"
  },
  {
    "path": "translations/hr.json",
    "content": "{\n\t\"LOADING\": \"Učitavanje …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"Prekjučer\",\n\t\"YESTERDAY\": \"Jučer\",\n\t\"TODAY\": \"Danas\",\n\t\"TOMORROW\": \"Sutra\",\n\t\"DAYAFTERTOMORROW\": \"Prekosutra\",\n\t\"RUNNING\": \"Završava za\",\n\t\"EMPTY\": \"Nema nadolazećih događaja.\",\n\t\"WEEK\": \"Tjedan {weekNumber}\",\n\n\t\"N\": \"N\",\n\t\"NNE\": \"NNE\",\n\t\"NE\": \"NE\",\n\t\"ENE\": \"ENE\",\n\t\"E\": \"E\",\n\t\"ESE\": \"ESE\",\n\t\"SE\": \"SE\",\n\t\"SSE\": \"SSE\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SSW\",\n\t\"SW\": \"SW\",\n\t\"WSW\": \"WSW\",\n\t\"W\": \"W\",\n\t\"WNW\": \"WNW\",\n\t\"NW\": \"NW\",\n\t\"NNW\": \"NNW\",\n\n\t\"FEELS\": \"Osjećaj {DEGREE}\",\n\t\"PRECIP_POP\": \"Vjerojatnost padalina\",\n\t\"PRECIP_AMOUNT\": \"Količina padalina\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Opcije konfiguracije za modul {MODULE_NAME} su promijenjene.\\nMolimo provjerite dokumentaciju.\",\n\t\"MODULE_CONFIG_ERROR\": \"Greška u modulu {MODULE_NAME}. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"Neispravan URL.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Nema internetske veze.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Autorizacija nije uspjela.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Provjerite dnevnike za više informacija.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Trenutno nema vijesti.\",\n\n\t\"UPDATE_NOTIFICATION\": \"Dostupna je aktualizacija MagicMirror².\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Dostupna je aktualizacija modula {MODULE_NAME}.\",\n\t\"UPDATE_INFO_SINGLE\": \"Instalirana verzija {COMMIT_COUNT} commit kasni za branch-om {BRANCH_NAME}.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Instalirana verzija {COMMIT_COUNT} commit-ova kasni za branch-om {BRANCH_NAME}.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Ažuriranje je završeno za modul {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Greška pri ažuriranju modula {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"Potrebno je ponovno pokretanje MagicMirror-a.\"\n}\n"
  },
  {
    "path": "translations/hu.json",
    "content": "{\n\t\"LOADING\": \"Betöltés …\",\n\n\t\"YESTERDAY\": \"Tegnap\",\n\t\"TODAY\": \"Ma\",\n\t\"TOMORROW\": \"Holnap\",\n\t\"DAYAFTERTOMORROW\": \"Holnapután\",\n\t\"RUNNING\": \"Vége lesz\",\n\t\"EMPTY\": \"Nincs közelgő esemény.\",\n\t\"WEEK\": \"{weekNumber}. hét\",\n\n\t\"N\": \"É\",\n\t\"NNE\": \"ÉÉK\",\n\t\"NE\": \"ÉK\",\n\t\"ENE\": \"KÉK\",\n\t\"E\": \"K\",\n\t\"ESE\": \"KDK\",\n\t\"SE\": \"DK\",\n\t\"SSE\": \"DDK\",\n\t\"S\": \"D\",\n\t\"SSW\": \"DDNy\",\n\t\"SW\": \"DNy\",\n\t\"WSW\": \"NyDNy\",\n\t\"W\": \"Ny\",\n\t\"WNW\": \"NyÉNy\",\n\t\"NW\": \"ÉNy\",\n\t\"NNW\": \"ÉÉNy\",\n\n\t\"FEELS\": \"Érzet {DEGREE}\",\n\t\"PRECIP_POP\": \"Csapadék valószínűség\",\n\t\"PRECIP_AMOUNT\": \"Csapadék mennyisége\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"A(z) {MODULE_NAME} modul konfigurációs beállításai megváltoztak.\\nKérjük, ellenőrizze a dokumentációt.\",\n\t\"MODULE_CONFIG_ERROR\": \"Hiba a(z) {MODULE_NAME} modulban. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"Hibás URL.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Nincs internetkapcsolat.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Azonosítás sikertelen.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Ellenőrizze a naplókat további részletekért.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Jelenleg nincsenek hírek.\",\n\n\t\"UPDATE_NOTIFICATION\": \"MagicMirror²-hoz frissítés érhető el.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"A {MODULE_NAME} modulhoz frissítés érhető el.\",\n\t\"UPDATE_INFO_SINGLE\": \"A jelenlegi telepítés óta {COMMIT_COUNT} új commit jelent meg a {BRANCH_NAME} ágon.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"A jelenlegi telepítés óta {COMMIT_COUNT} új commit jelent meg a {BRANCH_NAME} ágon.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"A frissítés befejeződött a {MODULE_NAME} modulhoz.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Hiba történt a frissítés során a {MODULE_NAME} modulhoz.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"A MagicMirror újraindítása szükséges.\"\n}\n"
  },
  {
    "path": "translations/id.json",
    "content": "{\n\t\"LOADING\": \"Memuat …\",\n\n\t\"YESTERDAY\": \"Kemarin\",\n\t\"TODAY\": \"Hari ini\",\n\t\"TOMORROW\": \"Besok\",\n\t\"DAYAFTERTOMORROW\": \"Lusa\",\n\t\"RUNNING\": \"Berakhir dalam\",\n\t\"EMPTY\": \"Tidak ada agenda\",\n\t\"WEEK\": \"Pekan\",\n\n\t\"N\": \"U\",\n\t\"NNE\": \"UTL\",\n\t\"NE\": \"TL\",\n\t\"ENE\": \"TTL\",\n\t\"E\": \"T\",\n\t\"ESE\": \"TMg\",\n\t\"SE\": \"TG\",\n\t\"SSE\": \"SMg\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SBD\",\n\t\"SW\": \"BD\",\n\t\"WSW\": \"BBD\",\n\t\"W\": \"B\",\n\t\"WNW\": \"BBL\",\n\t\"NW\": \"BL\",\n\t\"NNW\": \"UBL\",\n\n\t\"FEELS\": \"Terasa {DEGREE}\",\n\t\"PRECIP_POP\": \"Kemungkinan curah hujan\",\n\t\"PRECIP_AMOUNT\": \"Jumlah curah hujan\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Opsi konfigurasi modul {MODULE_NAME} telah diubah.\\nSilakan periksa dokumentasi.\",\n\t\"MODULE_CONFIG_ERROR\": \"Terjadi kesalahan pada modul {MODULE_NAME}. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"URL tidak valid.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Tidak ada koneksi internet.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Gagal otentikasi.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Silakan periksa log untuk informasi lebih lanjut.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Saat ini tidak ada berita.\",\n\n\t\"UPDATE_NOTIFICATION\": \"Memperbarui MagicMirror² tersedia.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Memperbarui tersedia untuk modul {MODULE_NAME}.\",\n\t\"UPDATE_INFO_SINGLE\": \"Instalasi saat ini tertinggal {COMMIT_COUNT} commit pada cabang {BRANCH_NAME}.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Instalasi saat ini tertinggal {COMMIT_COUNT} commits pada cabang {BRANCH_NAME}.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Pembaruan modul {MODULE_NAME} selesai.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Terjadi kesalahan saat memperbarui modul {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"Diperlukan restart MagicMirror.\"\n}\n"
  },
  {
    "path": "translations/is.json",
    "content": "{\n\t\"LOADING\": \"Hleð upp …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"Í fyrradag\",\n\t\"YESTERDAY\": \"Í gær\",\n\t\"TODAY\": \"Í dag\",\n\t\"TOMORROW\": \"Á morgun\",\n\t\"DAYAFTERTOMORROW\": \"Ekki á morgun, heldur hinn\",\n\t\"RUNNING\": \"Endar eftir\",\n\t\"EMPTY\": \"Ekkert framundan.\",\n\t\"WEEK\": \"Vika {weekNumber}\",\n\n\t\"N\": \"N\",\n\t\"NNE\": \"NNA\",\n\t\"NE\": \"NA\",\n\t\"ENE\": \"ANA\",\n\t\"E\": \"A\",\n\t\"ESE\": \"ASA\",\n\t\"SE\": \"SA\",\n\t\"SSE\": \"SSA\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SSV\",\n\t\"SW\": \"SV\",\n\t\"WSW\": \"VSV\",\n\t\"W\": \"V\",\n\t\"WNW\": \"VNV\",\n\t\"NW\": \"NV\",\n\t\"NNW\": \"NNV\",\n\n\t\"FEELS\": \"Kælist á {DEGREE}\",\n\t\"PRECIP_POP\": \"Úrkoma\",\n\t\"PRECIP_AMOUNT\": \"Magn úrkomu\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Stillingar fyrir {MODULE_NAME} module hafa breyst.\\nVinsamlegast skoðaðu skjöl.\",\n\t\"MODULE_CONFIG_ERROR\": \"Villa í {MODULE_NAME} module. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"Villa í slóð.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Engin nettenging.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Auðkenning mistókst.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Vinsamlegast athugaðu skráningu fyrir frekari upplýsingar.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Engar fréttir í boði núna.\",\n\n\t\"UPDATE_NOTIFICATION\": \"MagicMirror² uppfærsla í boði.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Uppfærsla í boði fyrir {MODULE_NAME} module.\",\n\t\"UPDATE_INFO_SINGLE\": \"Núverandi kerfi er {COMMIT_COUNT} commit á eftir {BRANCH_NAME} branchinu.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Núverandi kerfi er {COMMIT_COUNT} commits á eftir {BRANCH_NAME} branchinu.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Uppfærsla lokið fyrir {MODULE_NAME} module\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Villa við uppfærslu fyrir {MODULE_NAME} module\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"Endurræsa MagicMirror er nauðsynlegt.\"\n}\n"
  },
  {
    "path": "translations/it.json",
    "content": "{\n\t\"LOADING\": \"Caricamento in corso …\",\n\n\t\"YESTERDAY\": \"Ieri\",\n\t\"TODAY\": \"Oggi\",\n\t\"TOMORROW\": \"Domani\",\n\t\"DAYAFTERTOMORROW\": \"Dopodomani\",\n\t\"RUNNING\": \"Termina entro\",\n\t\"EMPTY\": \"Nessun evento imminente.\",\n\t\"WEEK\": \"Settimana {weekNumber}\",\n\n\t\"N\": \"N\",\n\t\"NNE\": \"NNE\",\n\t\"NE\": \"NE\",\n\t\"ENE\": \"ENE\",\n\t\"E\": \"E\",\n\t\"ESE\": \"ESE\",\n\t\"SE\": \"SE\",\n\t\"SSE\": \"SSE\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SSO\",\n\t\"SW\": \"SO\",\n\t\"WSW\": \"OSO\",\n\t\"W\": \"O\",\n\t\"WNW\": \"ONO\",\n\t\"NW\": \"NO\",\n\t\"NNW\": \"NNO\",\n\n\t\"FEELS\": \"Percepiti {DEGREE}\",\n\t\"PRECIP_POP\": \"Probabilità di precipitazioni\",\n\t\"PRECIP_AMOUNT\": \"Quantità di precipitazioni\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Le opzioni di configurazione del modulo {MODULE_NAME} sono state modificate.\\nSi prega di consultare la documentazione.\",\n\t\"MODULE_CONFIG_ERROR\": \"Errore nel modulo {MODULE_NAME}. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"URL non valido.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Nessuna connessione a Internet.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Autenticazione non riuscita.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Si prega di controllare i log per ulteriori dettagli.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Al momento non ci sono notizie.\",\n\n\t\"UPDATE_NOTIFICATION\": \"E' disponibile un aggiornamento di MagicMirror².\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"E' disponibile un aggiornamento del modulo {MODULE_NAME}.\",\n\t\"UPDATE_INFO_SINGLE\": \"L'installazione è {COMMIT_COUNT} commit indietro rispetto all'attuale branch {BRANCH_NAME}.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"L'installazione è {COMMIT_COUNT} commits indietro rispetto all'attuale branch {BRANCH_NAME}.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"L'aggiornamento del modulo {MODULE_NAME} è stato completato.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Errore durante l'aggiornamento del modulo {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"E' necessario riavviare MagicMirror.\"\n}\n"
  },
  {
    "path": "translations/ja.json",
    "content": "{\n\t\"LOADING\": \"ローディング …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"おととい\",\n\t\"YESTERDAY\": \"昨日\",\n\t\"TODAY\": \"今日\",\n\t\"TOMORROW\": \"明日\",\n\t\"DAYAFTERTOMORROW\": \"明後日\",\n\t\"RUNNING\": \"で終わります\",\n\t\"EMPTY\": \"直近のイベントはありません\",\n\t\"WEEK\": \"第 {weekNumber} 週\",\n\n\t\"N\": \"北\",\n\t\"NNE\": \"北北東\",\n\t\"NE\": \"北東\",\n\t\"ENE\": \"東北東\",\n\t\"E\": \"東\",\n\t\"ESE\": \"東南東\",\n\t\"SE\": \"南東\",\n\t\"SSE\": \"南南東\",\n\t\"S\": \"南\",\n\t\"SSW\": \"南南西\",\n\t\"SW\": \"南西\",\n\t\"WSW\": \"西南西\",\n\t\"W\": \"西\",\n\t\"WNW\": \"西北西\",\n\t\"NW\": \"北西\",\n\t\"NNW\": \"北北西\",\n\n\t\"FEELS\": \"体感温度 {DEGREE}\",\n\t\"PRECIP_POP\": \"降水確率\",\n\t\"PRECIP_AMOUNT\": \"降水量\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"モジュール {MODULE_NAME} の設定オプションが変更されました。\\nドキュメントを確認してください。\",\n\t\"MODULE_CONFIG_ERROR\": \"モジュール {MODULE_NAME} でエラーが発生しました。{ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"不正なURLです。\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"インターネット接続がありません。\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"認証に失敗しました。\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"詳細はログを確認してください。\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"現在ニュースはありません。\",\n\n\t\"UPDATE_NOTIFICATION\": \"MagicMirror² のアップデートが利用可能です。\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"モジュール {MODULE_NAME} のアップデートが利用可能です。\",\n\t\"UPDATE_INFO_SINGLE\": \"現在のインストールは {BRANCH_NAME} ブランチから {COMMIT_COUNT} コミット遅れています。\",\n\t\"UPDATE_INFO_MULTIPLE\": \"現在のインストールは {BRANCH_NAME} ブランチから {COMMIT_COUNT} コミット遅れています。\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"モジュール {MODULE_NAME} のアップデートが完了しました。\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"モジュール {MODULE_NAME} のアップデート中にエラーが発生しました。\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"MagicMirror の再起動が必要です。\"\n}\n"
  },
  {
    "path": "translations/ko.json",
    "content": "{\n\t\"LOADING\": \"로드 중 …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"그저께\",\n\t\"YESTERDAY\": \"어제\",\n\t\"TODAY\": \"오늘\",\n\t\"TOMORROW\": \"내일\",\n\t\"DAYAFTERTOMORROW\": \"모레\",\n\t\"RUNNING\": \"종료 일\",\n\t\"EMPTY\": \"예정된 이벤트가 없습니다.\",\n\t\"WEEK\": \"{weekNumber}주차\",\n\n\t\"N\": \"북풍\",\n\t\"NNE\": \"북북동풍\",\n\t\"NE\": \"북동풍\",\n\t\"ENE\": \"동북동풍\",\n\t\"E\": \"동풍\",\n\t\"ESE\": \"동남동풍\",\n\t\"SE\": \"남동풍\",\n\t\"SSE\": \"남남동풍\",\n\t\"S\": \"남풍\",\n\t\"SSW\": \"남남서풍\",\n\t\"SW\": \"남서풍\",\n\t\"WSW\": \"서남서풍\",\n\t\"W\": \"서풍\",\n\t\"WNW\": \"서북서풍\",\n\t\"NW\": \"북서풍\",\n\t\"NNW\": \"북북서풍\",\n\n\t\"FEELS\": \"체감온도 {DEGREE}\",\n\t\"PRECIP_POP\": \"강수 확률\",\n\t\"PRECIP_AMOUNT\": \"강수량\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"모듈 {MODULE_NAME}의 설정값이 바뀌었습니다.\\n매뉴얼을 참고하세요.\",\n\t\"MODULE_CONFIG_ERROR\": \"에러 : {MODULE_NAME} - {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"잘못된 URL 형식입니다.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"인터넷이 연결되지 않았습니다.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"인증이 실패했습니다.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"상세 내용은 로그를 확인하세요.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"현재 뉴스가 없습니다.\",\n\n\t\"UPDATE_NOTIFICATION\": \"새로운 MagicMirror² 업데이트가 있습니다.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"{MODULE_NAME} 모듈에서 사용 가능한 업데이트 입니다.\",\n\t\"UPDATE_INFO_SINGLE\": \"설치할 {COMMIT_COUNT} commit 는 {BRANCH_NAME} 분기에 해당됩니다.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"설치할 {COMMIT_COUNT} commits 는 {BRANCH_NAME} 분기에 해당됩니다.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"모듈 {MODULE_NAME}의 업데이트가 완료되었습니다.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"모듈 {MODULE_NAME}의 업데이트 중 오류가 발생했습니다.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"MagicMirror를 재시작해야 합니다.\"\n}\n"
  },
  {
    "path": "translations/lt.json",
    "content": "{\n\t\"LOADING\": \"Kraunasi …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"Užvakar\",\n\t\"YESTERDAY\": \"Vakar\",\n\t\"TODAY\": \"Šiandien\",\n\t\"TOMORROW\": \"Rytoj\",\n\t\"DAYAFTERTOMORROW\": \"Už 2 dienų\",\n\t\"RUNNING\": \"Pasibaigs už\",\n\t\"EMPTY\": \"Nėra artimų įvykių.\",\n\t\"WEEK\": \"{weekNumber} savaitė\",\n\n\t\"N\": \"Š\",\n\t\"NNE\": \"ŠŠR\",\n\t\"NE\": \"ŠR\",\n\t\"ENE\": \"RŠR\",\n\t\"E\": \"R\",\n\t\"ESE\": \"RPR\",\n\t\"SE\": \"PR\",\n\t\"SSE\": \"PPR\",\n\t\"S\": \"P\",\n\t\"SSW\": \"PPV\",\n\t\"SW\": \"PV\",\n\t\"WSW\": \"VPV\",\n\t\"W\": \"V\",\n\t\"WNW\": \"VŠV\",\n\t\"NW\": \"ŠV\",\n\t\"NNW\": \"ŠŠV\",\n\n\t\"FEELS\": \"Jutiminė temp. {DEGREE}\",\n\t\"PRECIP_POP\": \"Krituliai\",\n\t\"PRECIP_AMOUNT\": \"Kritulių kiekis\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Modulio {MODULE_NAME} konfigūracijos parinktys pasikeitė.\\nPrašome patikrinti dokumentaciją.\",\n\t\"MODULE_CONFIG_ERROR\": \"Klaida modulyje {MODULE_NAME}. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"Netinkama URL nuoroda.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Nėra interneto ryšio.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Autorizacija nepavyko.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Patikrinkite žurnalus, kad gautumėte daugiau informacijos.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Šiuo metu naujienų nėra.\",\n\n\t\"UPDATE_NOTIFICATION\": \"Galimas MagicMirror² naujinimas.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Galimas {MODULE_NAME} naujinimas.\",\n\t\"UPDATE_INFO_SINGLE\": \"Šis įdiegimas atsilieka {COMMIT_COUNT} įsipareigojimu {BRANCH_NAME} šakoje.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Šis įdiegimas atsilieka {COMMIT_COUNT} įsipareigojimais {BRANCH_NAME} šakoje.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Naujinimas {MODULE_NAME} baigtas.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Klaida atnaujinant {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"Reikalingas MagicMirror perkrovimas.\"\n}\n"
  },
  {
    "path": "translations/ms-my.json",
    "content": "{\n\t\"LOADING\": \"Tunggu Sebentar …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"Kelmarin\",\n\t\"YESTERDAY\": \"Semalam\",\n\t\"TODAY\": \"Hari ini\",\n\t\"TOMORROW\": \"Esok\",\n\t\"DAYAFTERTOMORROW\": \"Lusa\",\n\t\"RUNNING\": \"Berakhir dalam\",\n\t\"EMPTY\": \"Tidak ada agenda\",\n\t\"WEEK\": \"Minggu ke-{weekNumber}\",\n\n\t\"N\": \"U\",\n\t\"NNE\": \"UUT\",\n\t\"NE\": \"TL\",\n\t\"ENE\": \"TTL\",\n\t\"E\": \"T\",\n\t\"ESE\": \"TT\",\n\t\"SE\": \"T\",\n\t\"SSE\": \"ST\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SBD\",\n\t\"SW\": \"BD\",\n\t\"WSW\": \"BBD\",\n\t\"W\": \"B\",\n\t\"WNW\": \"BBL\",\n\t\"NW\": \"BL\",\n\t\"NNW\": \"UBL\",\n\n\t\"FEELS\": \"Rasa seperti {DEGREE}\",\n\t\"PRECIP_POP\": \"Kemungkinan hujan\",\n\t\"PRECIP_AMOUNT\": \"Jumlah hujan\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Pilihan konfigurasi untuk modul {MODULE_NAME} telah berubah.\\nSila rujuk dokumentasi.\",\n\t\"MODULE_CONFIG_ERROR\": \"Ralat dalam modul {MODULE_NAME}. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"URL tidak sah.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Tiada sambungan internet.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Kebenaran gagal.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Sila semak log untuk maklumat lanjut.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Tiada berita buat masa ini.\",\n\n\t\"UPDATE_NOTIFICATION\": \"MagicMirror² mempunyai update terkini.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Modul {MODULE_NAME} mempunyai update terkini.\",\n\t\"UPDATE_INFO_SINGLE\": \"Pemasangan MagicMirror² ini mempunyai {COMMIT_COUNT} commit terkebelakang dari branch {BRANCH_NAME}.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Pemasangan MagicMirror² ini mempunyai {COMMIT_COUNT} commit terkebelakang dari branch {BRANCH_NAME}.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Update selesai untuk modul {MODULE_NAME}\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Ralat update untuk modul {MODULE_NAME}\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"Perlu restart MagicMirror.\"\n}\n"
  },
  {
    "path": "translations/nb.json",
    "content": "{\n\t\"LOADING\": \"Laster …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"I forgårs\",\n\t\"YESTERDAY\": \"I går\",\n\t\"TODAY\": \"I dag\",\n\t\"TOMORROW\": \"I morgen\",\n\t\"DAYAFTERTOMORROW\": \"I overmorgen\",\n\t\"RUNNING\": \"Slutter om\",\n\t\"EMPTY\": \"Ingen kommende arrangementer.\",\n\t\"WEEK\": \"Uke {weekNumber}\",\n\n\t\"N\": \"N\",\n\t\"NNE\": \"NNØ\",\n\t\"NE\": \"NØ\",\n\t\"ENE\": \"ØNØ\",\n\t\"E\": \"Ø\",\n\t\"ESE\": \"ØSØ\",\n\t\"SE\": \"SØ\",\n\t\"SSE\": \"SSØ\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SSV\",\n\t\"SW\": \"SV\",\n\t\"WSW\": \"VSV\",\n\t\"W\": \"V\",\n\t\"WNW\": \"VNV\",\n\t\"NW\": \"NV\",\n\t\"NNW\": \"NNV\",\n\n\t\"FEELS\": \"Føles som {DEGREE}\",\n\t\"PRECIP_POP\": \"Sannsynlighet for nedbør\",\n\t\"PRECIP_AMOUNT\": \"Nedbørsmengde\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Innstillingene for {MODULE_NAME}-modulen har endret seg.\\nVennligst sjekk dokumentasjonen.\",\n\t\"MODULE_CONFIG_ERROR\": \"Feil i {MODULE_NAME}-modulen. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"Ugyldig URL.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Ingen internettforbindelse.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Autentisering mislyktes.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Vennligst sjekk loggene for mer informasjon.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Ingen nyheter tilgjengelig for øyeblikket.\",\n\n\t\"UPDATE_NOTIFICATION\": \"MagicMirror²-oppdatering er tilgjengelig.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Oppdatering tilgjengelig for modulen {MODULE_NAME}.\",\n\t\"UPDATE_INFO_SINGLE\": \"Nåværende installasjon er {COMMIT_COUNT} commit bak {BRANCH_NAME} grenen.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Nåværende installasjon er {COMMIT_COUNT} commits bak {BRANCH_NAME} grenen.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Oppdateringen av modulen {MODULE_NAME} er fullført.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Det oppstod en feil under oppdateringen av modulen {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"Det er nødvendig med en omstart av MagicMirror.\"\n}\n"
  },
  {
    "path": "translations/nl.json",
    "content": "{\n\t\"LOADING\": \"Bezig met laden …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"Eergisteren\",\n\t\"YESTERDAY\": \"Gisteren\",\n\t\"TODAY\": \"Vandaag\",\n\t\"TOMORROW\": \"Morgen\",\n\t\"DAYAFTERTOMORROW\": \"Overmorgen\",\n\t\"RUNNING\": \"Eindigt over\",\n\t\"EMPTY\": \"Geen geplande afspraken.\",\n\t\"WEEK\": \"Week {weekNumber}\",\n\n\t\"N\": \"N\",\n\t\"NNE\": \"NNO\",\n\t\"NE\": \"NO\",\n\t\"ENE\": \"ONO\",\n\t\"E\": \"O\",\n\t\"ESE\": \"OZO\",\n\t\"SE\": \"ZO\",\n\t\"SSE\": \"ZZO\",\n\t\"S\": \"Z\",\n\t\"SSW\": \"ZZW\",\n\t\"SW\": \"ZW\",\n\t\"WSW\": \"WZW\",\n\t\"W\": \"W\",\n\t\"WNW\": \"WNW\",\n\t\"NW\": \"NW\",\n\t\"NNW\": \"NNW\",\n\n\t\"FEELS\": \"Voelt als {DEGREE}\",\n\t\"PRECIP_POP\": \"Neerslagkans\",\n\t\"PRECIP_AMOUNT\": \"Neerslaghoeveelheid\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"De configuratie opties voor de module {MODULE_NAME} zijn gewijzigd.\\nControleer de documentatie.\",\n\t\"MODULE_CONFIG_ERROR\": \"Fout in de {MODULE_NAME} module. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"Ongeldige url.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Geen internet verbinding.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Authenticatie mislukt.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Bekijk de logs voor meer informatie.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Geen nieuws op dit moment.\",\n\n\t\"UPDATE_NOTIFICATION\": \"MagicMirror² update beschikbaar.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Update beschikbaar voor {MODULE_NAME} module.\",\n\t\"UPDATE_INFO_SINGLE\": \"De huidige installatie loopt {COMMIT_COUNT} commit achter op de {BRANCH_NAME} branch.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"De huidige installatie loopt {COMMIT_COUNT} commits achter op de {BRANCH_NAME} branch.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Update voltooid voor {MODULE_NAME} module.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Fout tijdens het updaten van {MODULE_NAME} module.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"MagicMirror moet opnieuw worden opgestart.\"\n}\n"
  },
  {
    "path": "translations/nn.json",
    "content": "{\n\t\"LOADING\": \"Lastar …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"I forgårs\",\n\t\"YESTERDAY\": \"I går\",\n\t\"TODAY\": \"I dag\",\n\t\"TOMORROW\": \"I morgon\",\n\t\"DAYAFTERTOMORROW\": \"I overmorgon\",\n\t\"RUNNING\": \"Sluttar om\",\n\t\"EMPTY\": \"Ingen komande hendingar.\",\n\t\"WEEK\": \"Veke {weekNumber}\",\n\n\t\"N\": \"N\",\n\t\"NNE\": \"NNA\",\n\t\"NE\": \"NA\",\n\t\"ENE\": \"ANA\",\n\t\"E\": \"A\",\n\t\"ESE\": \"ASA\",\n\t\"SE\": \"SA\",\n\t\"SSE\": \"SSA\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SSV\",\n\t\"SW\": \"SV\",\n\t\"WSW\": \"VSV\",\n\t\"W\": \"V\",\n\t\"WNW\": \"VNV\",\n\t\"NW\": \"NV\",\n\t\"NNW\": \"NNV\",\n\n\t\"FEELS\": \"Kjenst som {DEGREE}\",\n\t\"PRECIP_POP\": \"Sannsyn for nedbør\",\n\t\"PRECIP_AMOUNT\": \"Nedbørsmengde\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Innstillingane for {MODULE_NAME} modulen har endra seg.\\nVennligst sjekk dokumentasjonen.\",\n\t\"MODULE_CONFIG_ERROR\": \"Feil i {MODULE_NAME} modulen. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"Ugyldig URL.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Ingen internettforbindelse.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Autentisering mislyktes.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Vennligst sjekk loggfilene for meir informasjon.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Ingen nyhende tilgjengeleg no.\",\n\n\t\"UPDATE_NOTIFICATION\": \"MagicMirror² oppdatering er tilgjengeleg.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Oppdatering tilgjengeleg for modulen {MODULE_NAME}.\",\n\t\"UPDATE_INFO_SINGLE\": \"noverande installasjon er {COMMIT_COUNT} commit bak {BRANCH_NAME} greinen.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"noverande installasjon er {COMMIT_COUNT} commits bak {BRANCH_NAME} greinen.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Oppdateringa av modulen {MODULE_NAME} er fullført.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Det oppstod ein feil under oppdateringa av modulen {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"Det er nødvendig med ein omstart av MagicMirror.\"\n}\n"
  },
  {
    "path": "translations/pl.json",
    "content": "{\n\t\"LOADING\": \"Ładowanie …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"Wczoraj\",\n\t\"YESTERDAY\": \"Przedwczoraj\",\n\t\"TODAY\": \"Dziś\",\n\t\"TOMORROW\": \"Jutro\",\n\t\"DAYAFTERTOMORROW\": \"Pojutrze\",\n\t\"RUNNING\": \"Koniec za\",\n\t\"EMPTY\": \"Brak wydarzeń.\",\n\t\"WEEK\": \"Tydzień {weekNumber}\",\n\n\t\"N\": \"N\",\n\t\"NNE\": \"NNE\",\n\t\"NE\": \"NE\",\n\t\"ENE\": \"ENE\",\n\t\"E\": \"E\",\n\t\"ESE\": \"ESE\",\n\t\"SE\": \"SE\",\n\t\"SSE\": \"SSE\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SSW\",\n\t\"SW\": \"SW\",\n\t\"WSW\": \"WSW\",\n\t\"W\": \"W\",\n\t\"WNW\": \"WNW\",\n\t\"NW\": \"NW\",\n\t\"NNW\": \"NNW\",\n\n\t\"FEELS\": \"Odczuwalna {DEGREE}\",\n\t\"PRECIP_POP\": \"Szansa opadów\",\n\t\"PRECIP_AMOUNT\": \"Ilość opadów\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Opcje konfiguracji modułu {MODULE_NAME} zostały zmienione.\\nProszę sprawdzić dokumentację.\",\n\t\"MODULE_CONFIG_ERROR\": \"Błąd w module {MODULE_NAME}. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"Nieprawidłowy adres URL.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Brak połączenia z internetem.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Autoryzacja nie powiodła się.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Sprawdź logi, aby uzyskać więcej informacji.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Brak wiadomości w tej chwili.\",\n\n\t\"UPDATE_NOTIFICATION\": \"Dostępna jest aktualizacja MagicMirror².\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Dostępna jest aktualizacja modułu {MODULE_NAME}.\",\n\t\"UPDATE_INFO_SINGLE\": \"Zainstalowana wersja odbiega o {COMMIT_COUNT} commit od gałęzi {BRANCH_NAME}.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Zainstalowana wersja odbiega o {COMMIT_COUNT} commitów od gałęzi {BRANCH_NAME}.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Aktualizacja modułu {MODULE_NAME} zakończona.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Błąd aktualizacji modułu {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"Wymagany jest restart MagicMirror.\"\n}\n"
  },
  {
    "path": "translations/ps.json",
    "content": "{\n\t\"LOADING\": \"پیلېدل\",\n\n\t\"DAYBEFOREYESTERDAY\": \"پرون ورځ\",\n\t\"YESTERDAY\": \"پرون\",\n\t\"TODAY\": \"نن\",\n\t\"TOMORROW\": \"سبا\",\n\t\"DAYAFTERTOMORROW\": \"بل سبا\",\n\t\"RUNNING\": \"روان\",\n\t\"EMPTY\": \"تش\",\n\t\"WEEK\": \"{weekNumber}. اونۍ\",\n\n\t\"N\": \"N\",\n\t\"NNE\": \"NNO\",\n\t\"NE\": \"NO\",\n\t\"ENE\": \"ONO\",\n\t\"E\": \"O\",\n\t\"ESE\": \"OSO\",\n\t\"SE\": \"SO\",\n\t\"SSE\": \"SSO\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SSW\",\n\t\"SW\": \"SW\",\n\t\"WSW\": \"WSW\",\n\t\"W\": \"W\",\n\t\"WNW\": \"WNW\",\n\t\"NW\": \"NW\",\n\t\"NNW\": \"NNW\",\n\n\t\"FEELS\": \"حس کېږی {DEGREE}\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"د {MODULE_NAME} بڼی تغیر کړی دی. \\n هیله دی چی اسناد و ګوری!\",\n\n\t\"UPDATE_NOTIFICATION\": \"د MagicMirror² نوې نسخه سته \",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"د {MODULE_NAME} نوی نسخه سته\",\n\t\"UPDATE_INFO_SINGLE\": \"اوسنی برخه {COMMIT_COUNT} د {BRANCH_NAME} څخه وروسته پاته ده\",\n\t\"UPDATE_INFO_MULTIPLE\": \"اوسنی برخه {COMMIT_COUNT} د {BRANCH_NAME} څخه وروسته پاته ده\"\n}\n"
  },
  {
    "path": "translations/pt-br.json",
    "content": "{\n\t\"LOADING\": \"Carregando …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"Anteontem\",\n\t\"YESTERDAY\": \"Ontem\",\n\t\"TODAY\": \"Hoje\",\n\t\"TOMORROW\": \"Amanhã\",\n\t\"RUNNING\": \"Acaba em\",\n\t\"EMPTY\": \"Nenhum evento novo.\",\n\t\"WEEK\": \"Semana {weekNumber}\",\n\n\t\"N\": \"N\",\n\t\"NNE\": \"NNE\",\n\t\"NE\": \"NE\",\n\t\"ENE\": \"ENE\",\n\t\"E\": \"E\",\n\t\"ESE\": \"ESE\",\n\t\"SE\": \"SE\",\n\t\"SSE\": \"SSE\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SSO\",\n\t\"SW\": \"SO\",\n\t\"WSW\": \"OSO\",\n\t\"W\": \"O\",\n\t\"WNW\": \"ONO\",\n\t\"NW\": \"NO\",\n\t\"NNW\": \"NNO\",\n\n\t\"FEELS\": \"Percebida {DEGREE}\",\n\t\"PRECIP_POP\": \"Probabilidade de precipitação\",\n\t\"PRECIP_AMOUNT\": \"Quantidade de precipitação\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"As opções de configuração do módulo {MODULE_NAME} foram alteradas.\\nConsulte a documentação.\",\n\t\"MODULE_CONFIG_ERROR\": \"Erro no módulo {MODULE_NAME}. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"URL inválido.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Sem conexão com a Internet.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Falha na autenticação.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Verifique os logs para mais detalhes.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Atualmente não há notícias.\",\n\n\t\"UPDATE_NOTIFICATION\": \"Nova atualização para MagicMirror² disponível.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Atualização para o módulo {MODULE_NAME} disponível.\",\n\t\"UPDATE_INFO_SINGLE\": \"Sua versão atual é a {COMMIT_COUNT} commit dentro do seguinte branch {BRANCH_NAME}.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Sua versão atual é a {COMMIT_COUNT} commits dentro do seguinte branch {BRANCH_NAME}.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"A atualização do módulo {MODULE_NAME} foi concluída.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Ocorreu um erro ao atualizar o módulo {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"É necessário reiniciar o MagicMirror.\"\n}\n"
  },
  {
    "path": "translations/pt.json",
    "content": "{\n\t\"LOADING\": \"A carregar…\",\n\n\t\"DAYBEFOREYESTERDAY\": \"Anteontem\",\n\t\"YESTERDAY\": \"Ontem\",\n\t\"TODAY\": \"Hoje\",\n\t\"TOMORROW\": \"Amanhã\",\n\t\"DAYAFTERTOMORROW\": \"Depois de amanhã\",\n\t\"RUNNING\": \"Termina em\",\n\t\"EMPTY\": \"Sem eventos programados.\",\n\t\"WEEK\": \"Semana {weekNumber}\",\n\t\"WEEK_SHORT\": \"S{weekNumber}\",\n\n\t\"N\": \"N\",\n\t\"NNE\": \"NNE\",\n\t\"NE\": \"NE\",\n\t\"ENE\": \"ENE\",\n\t\"E\": \"E\",\n\t\"ESE\": \"ESE\",\n\t\"SE\": \"SE\",\n\t\"SSE\": \"SSE\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SSO\",\n\t\"SW\": \"SO\",\n\t\"WSW\": \"OSO\",\n\t\"W\": \"O\",\n\t\"WNW\": \"ONO\",\n\t\"NW\": \"NO\",\n\t\"NNW\": \"NNO\",\n\n\t\"FEELS\": \"Sensação térmica {DEGREE}\",\n\t\"PRECIP_POP\": \"Probabilidade de precipitação\",\n\t\"PRECIP_AMOUNT\": \"Quantidade de precipitação\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"As opções de configuração do módulo {MODULE_NAME} foram alteradas.\\nConsulta a documentação.\",\n\t\"MODULE_CONFIG_ERROR\": \"Erro no módulo {MODULE_NAME}. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"URL inválido.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Sem ligação à internet.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Falha na autorização.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Consulta os registos para mais detalhes.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Sem notícias de momento.\",\n\n\t\"UPDATE_NOTIFICATION\": \"Está disponível uma atualização do MagicMirror².\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Atualização disponível para o módulo {MODULE_NAME}.\",\n\t\"UPDATE_INFO_SINGLE\": \"A instalação atual está {COMMIT_COUNT} commit atrás na ramificação {BRANCH_NAME}.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"A instalação atual está {COMMIT_COUNT} commits atrás na ramificação {BRANCH_NAME}.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Atualização concluída do módulo {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Erro na atualização do módulo {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"É necessário reiniciar o MagicMirror.\",\n\n\t\"MODULE_ERROR_MALFORMED_URL\": \"URL Inválido.\",\n\n\t\"UPDATE_NOTIFICATION\": \"Está disponível uma atualização do MagicMirror².\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Atualização disponível para o módulo {MODULE_NAME}.\",\n\t\"UPDATE_INFO_SINGLE\": \"A instalação atual está {COMMIT_COUNT} commit atrás na ramificação {BRANCH_NAME}.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"A instalação atual está {COMMIT_COUNT} commits atrás na ramificação {BRANCH_NAME}.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Atualização concluída do módulo {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Erro na atualização do módulo {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"É necessário reiniciar o MagicMirror.\"\n}\n"
  },
  {
    "path": "translations/ro.json",
    "content": "{\n\t\"LOADING\": \"Se încarcă …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"Alaltaieri\",\n\t\"YESTERDAY\": \"Ieri\",\n\t\"TODAY\": \"Astăzi\",\n\t\"TOMORROW\": \"Mâine\",\n\t\"DAYAFTERTOMORROW\": \"Poimâine\",\n\t\"RUNNING\": \"Se termină în\",\n\t\"EMPTY\": \"Nici un eveniment.\",\n\t\"WEEK\": \"Săptămâna {weekNumber}\",\n\n\t\"N\": \"N\",\n\t\"NNE\": \"NNE\",\n\t\"NE\": \"NE\",\n\t\"ENE\": \"ENE\",\n\t\"E\": \"E\",\n\t\"ESE\": \"ESE\",\n\t\"SE\": \"SE\",\n\t\"SSE\": \"SSE\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SSW\",\n\t\"SW\": \"SW\",\n\t\"WSW\": \"WSW\",\n\t\"W\": \"W\",\n\t\"WNW\": \"WNW\",\n\t\"NW\": \"NW\",\n\t\"NNW\": \"NNW\",\n\n\t\"FEELS\": \"Se simte ca fiind {DEGREE}\",\n\t\"PRECIP_POP\": \"Probabilitate de precipitații\",\n\t\"PRECIP_AMOUNT\": \"Cantitate de precipitații\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Opțiunile de configurare pentru modulul {MODULE_NAME} s-au schimbat.\\nVă rugăm să verificați documentația.\",\n\t\"MODULE_CONFIG_ERROR\": \"Eroare în modulul {MODULE_NAME}. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"URL incorect.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Fără conexiune la internet.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Autorizarea a eșuat.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Verificați jurnalele pentru mai multe detalii.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Nu există știri în acest moment.\",\n\n\t\"UPDATE_NOTIFICATION\": \"Un update este disponibil pentru MagicMirror².\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Un update este disponibil pentru modulul {MODULE_NAME}.\",\n\t\"UPDATE_INFO_SINGLE\": \"Există {COMMIT_COUNT} commit-uri noi pe branch-ul {BRANCH_NAME}.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Există {COMMIT_COUNT} commit-uri noi pe branch-ul {BRANCH_NAME}.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Update-ul a fost finalizat pentru modulul {MODULE_NAME}\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Eroare la update-ul modulului {MODULE_NAME}\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"Este necesară repornirea MagicMirror.\"\n}\n"
  },
  {
    "path": "translations/ru.json",
    "content": "{\n\t\"LOADING\": \"Загрузка …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"Позавчера\",\n\t\"YESTERDAY\": \"Вчера\",\n\t\"TODAY\": \"Сегодня\",\n\t\"TOMORROW\": \"Завтра\",\n\t\"DAYAFTERTOMORROW\": \"Послезавтра\",\n\t\"RUNNING\": \"Заканчивается через\",\n\t\"EMPTY\": \"Нет предстоящих событий\",\n\t\"WEEK\": \"Неделя {weekNumber}\",\n\n\t\"N\": \"С\",\n\t\"NNE\": \"ССВ\",\n\t\"NE\": \"СВ\",\n\t\"ENE\": \"ВСВ\",\n\t\"E\": \"В\",\n\t\"ESE\": \"ВЮВ\",\n\t\"SE\": \"ЮВ\",\n\t\"SSE\": \"ЮЮВ\",\n\t\"S\": \"Ю\",\n\t\"SSW\": \"ЮЮЗ\",\n\t\"SW\": \"ЮЗ\",\n\t\"WSW\": \"ЗЮЗ\",\n\t\"W\": \"З\",\n\t\"WNW\": \"ЗСЗ\",\n\t\"NW\": \"СЗ\",\n\t\"NNW\": \"ССЗ\",\n\n\t\"FEELS\": \"По ощущению {DEGREE}\",\n\t\"PRECIP_POP\": \"Вероятность осадков\",\n\t\"PRECIP_AMOUNT\": \"Количество осадков\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Настройки модуля {MODULE_NAME} изменены.\\nПожалуйста, проверьте документацию.\",\n\t\"MODULE_CONFIG_ERROR\": \"Ошибка в модуле {MODULE_NAME}. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"Неверный URL.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Нет интернет-соединения.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Не удалось авторизоваться.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Пожалуйста, проверьте логи для получения дополнительной информации.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"В данный момент нет новостей.\",\n\n\t\"UPDATE_NOTIFICATION\": \"Есть обновление для MagicMirror².\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Есть обновление для {MODULE_NAME} модуля.\",\n\t\"UPDATE_INFO_SINGLE\": \"Данная инсталляция позади {BRANCH_NAME} commit ветки на {COMMIT_COUNT} коммитов.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Данная инсталляция позади {BRANCH_NAME} commits ветки на {COMMIT_COUNT} коммитов.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Обновление модуля {MODULE_NAME} завершено.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Ошибка обновления модуля {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"Требуется перезапуск MagicMirror.\"\n}\n"
  },
  {
    "path": "translations/sk.json",
    "content": "{\n\t\"LOADING\": \"Načítanie …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"Predvčerom\",\n\t\"YESTERDAY\": \"Včera\",\n\t\"TODAY\": \"Dnes\",\n\t\"TOMORROW\": \"Zajtra\",\n\t\"DAYAFTERTOMORROW\": \"Pozajtra\",\n\t\"RUNNING\": \"Končí o\",\n\t\"EMPTY\": \"Žiadne nadchádzajúce udalosti.\",\n\t\"WEEK\": \"{weekNumber}. týždeň\",\n\n\t\"N\": \"S\",\n\t\"NNE\": \"SSV\",\n\t\"NE\": \"SV\",\n\t\"ENE\": \"VSV\",\n\t\"E\": \"V\",\n\t\"ESE\": \"VJV\",\n\t\"SE\": \"JV\",\n\t\"SSE\": \"JJV\",\n\t\"S\": \"J\",\n\t\"SSW\": \"JJZ\",\n\t\"SW\": \"JZ\",\n\t\"WSW\": \"ZJZ\",\n\t\"W\": \"Z\",\n\t\"WNW\": \"ZSZ\",\n\t\"NW\": \"SZ\",\n\t\"NNW\": \"SSZ\",\n\n\t\"FEELS\": \"Pocit ako {DEGREE}\",\n\t\"PRECIP_POP\": \"Pravdepodobnosť zrážok\",\n\t\"PRECIP_AMOUNT\": \"Množstvo zrážok\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Konfiguračné možnosti modulu {MODULE_NAME} boli zmenené.\\nProsím, skontrolujte dokumentáciu.\",\n\t\"MODULE_CONFIG_ERROR\": \"Chyba v module {MODULE_NAME}. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"Nesprávna URL adresa.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Nie je pripojenie k internetu.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Autorizácia zlyhala.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Skontrolujte protokoly pre viac informácií.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Momentálne žiadne správy.\",\n\n\t\"UPDATE_NOTIFICATION\": \"Dostupná aktualizácia pre MagicMirror².\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Dostupná aktualizácia pre modul {MODULE_NAME}.\",\n\t\"UPDATE_INFO_SINGLE\": \"Súčasná inštalácia je na vetve {BRANCH_NAME} pozadu o {COMMIT_COUNT} commit.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Súčasná inštalácia je na vetve {BRANCH_NAME} pozadu o {COMMIT_COUNT} commitov.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Aktualizácia dokončená pre modul {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Chyba aktualizácie modulu {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"Je potrebné reštartovať MagicMirror.\"\n}\n"
  },
  {
    "path": "translations/sv.json",
    "content": "{\n\t\"LOADING\": \"Laddar …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"\",\n\t\"YESTERDAY\": \"I går\",\n\t\"TODAY\": \"I dag\",\n\t\"TOMORROW\": \"I morgon\",\n\t\"DAYAFTERTOMORROW\": \"I övermorgon\",\n\t\"RUNNING\": \"Slutar\",\n\t\"EMPTY\": \"Inga kommande händelser.\",\n\t\"WEEK\": \"Vecka {weekNumber}\",\n\n\t\"N\": \"N\",\n\t\"NNE\": \"NNO\",\n\t\"NE\": \"NO\",\n\t\"ENE\": \"ONO\",\n\t\"E\": \"Ö\",\n\t\"ESE\": \"OSO\",\n\t\"SE\": \"SO\",\n\t\"SSE\": \"SSO\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SSV\",\n\t\"SW\": \"SV\",\n\t\"WSW\": \"VSV\",\n\t\"W\": \"V\",\n\t\"WNW\": \"VNV\",\n\t\"NW\": \"NV\",\n\t\"NNW\": \"NNV\",\n\n\t\"FEELS\": \"Känns som {DEGREE}\",\n\t\"PRECIP_POP\": \"Nederbördsrisk\",\n\t\"PRECIP_AMOUNT\": \"Nederbördsmängd\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Konfigurationsalternativen för modulen {MODULE_NAME} har ändrats.\\nVänligen se dokumentationen.\",\n\t\"MODULE_CONFIG_ERROR\": \"Fel i modulen {MODULE_NAME}. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"Felaktig URL.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Ingen internetanslutning.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Autentisering misslyckades.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Vänligen kontrollera loggarna för mer information.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Inga nyheter för tillfället.\",\n\n\t\"UPDATE_NOTIFICATION\": \"MagicMirror² uppdatering finns tillgänglig.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Uppdatering finns tillgänglig av {MODULE_NAME} modulen.\",\n\t\"UPDATE_INFO_SINGLE\": \"Denna installation ligger {COMMIT_COUNT} commit steg bakom {BRANCH_NAME} grenen.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Denna installation ligger {COMMIT_COUNT} commits steg bakom {BRANCH_NAME} grenen.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Uppdateringen av modulen {MODULE_NAME} har slutförts.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Ett fel inträffade vid uppdateringen av modulen {MODULE_NAME}.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"Det krävs en omstart av MagicMirror.\"\n}\n"
  },
  {
    "path": "translations/th.json",
    "content": "{\n\t\"LOADING\": \"กำลังโหลด …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"เมื่อวานซืน\",\n\t\"YESTERDAY\": \"เมื่อวานนี้\",\n\t\"TODAY\": \"วันนี้\",\n\t\"TOMORROW\": \"พรุ่งนี้\",\n\t\"DAYAFTERTOMORROW\": \"มะรืนนี้\",\n\t\"RUNNING\": \"สิ้นสุดใน\",\n\t\"EMPTY\": \"ไม่มีกิจกรรมที่กำลังจะมาถึง\",\n\t\"WEEK\": \"สัปดาห์ที่ {weekNumber}\",\n\n\t\"N\": \"น\",\n\t\"NNE\": \"น.ต.อ.น.\",\n\t\"NE\": \"ต.อ.น.\",\n\t\"ENE\": \"ต.อ.ต.อ.น.\",\n\t\"E\": \"ต.อ.\",\n\t\"ESE\": \"ต.อ.ต.อ.ต.\",\n\t\"SE\": \"ต.อ.ต.\",\n\t\"SSE\": \"ต.ต.อ.ต.\",\n\t\"S\": \"ต.\",\n\t\"SSW\": \"ต.ต.ต.ต.\",\n\t\"SW\": \"ต.ต.ต.\",\n\t\"WSW\": \"ต.ต.ต.ต.ต.\",\n\t\"W\": \"ต.ต.\",\n\t\"WNW\": \"ต.ต.ต.ต.น.\",\n\t\"NW\": \"ต.ต.น.\",\n\t\"NNW\": \"น.ต.ต.น.\",\n\n\t\"FEELS\": \"รู้สึกเหมือน {DEGREE}\",\n\t\"PRECIP_POP\": \"ความแม่นยำ\",\n\t\"PRECIP_AMOUNT\": \"ปริมาณน้ำฝน\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"ตัวเลือกการกำหนดค่าสำหรับโมดูล {MODULE_NAME} มีการเปลี่ยนแปลง\\nโปรดตรวจสอบเอกสารประกอบ\",\n\t\"MODULE_CONFIG_ERROR\": \"เกิดข้อผิดพลาดในโมดูล {MODULE_NAME} {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"URL ผิดรูปแบบ\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"ไม่มีการเชื่อมต่ออินเทอร์เน็ต.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"การอนุญาตล้มเหลว\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"ตรวจสอบบันทึกสำหรับรายละเอียดเพิ่มเติม\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"ไม่มีข่าวในขณะนี้\",\n\n\t\"UPDATE_NOTIFICATION\": \"MagicMirror² มีการอัปเดต\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"มีการอัปเดตสำหรับโมดูล {MODULE_NAME}\",\n\t\"UPDATE_INFO_SINGLE\": \"การติดตั้งปัจจุบันถูกคอมมิท {COMMIT_COUNT} รายการในสาขา {BRANCH_NAME}\",\n\t\"UPDATE_INFO_MULTIPLE\": \"การติดตั้งปัจจุบันคือ {COMMIT_COUNT} เป็นคอมมิทที่อยู่เบื้องหลังในสาขา {BRANCH_NAME}\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"การอัปเดตเสร็จสิ้นสำหรับโมดูล {MODULE_NAME}\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"เกิดข้อผิดพลาดในการอัปเดตโมดูล {MODULE_NAME}\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"ต้องรีสตาร์ท MagicMirror\"\n}\n"
  },
  {
    "path": "translations/tlh.json",
    "content": "{\n\t\"LOADING\": \"loS …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"cha'Hu'\",\n\t\"YESTERDAY\": \"wa'Hu'\",\n\t\"TODAY\": \"DaHjaj\",\n\t\"TOMORROW\": \"wa'leS\",\n\t\"DAYAFTERTOMORROW\": \"cha'leS\",\n\t\"RUNNING\": \"Dor\",\n\t\"EMPTY\": \"Sumbe' wanI'.\",\n\t\"WEEK\": \"Hogh (terran) {weekNumber}\",\n\n\t\"N\": \"N\",\n\t\"NNE\": \"NNE\",\n\t\"NE\": \"NE\",\n\t\"ENE\": \"ENE\",\n\t\"E\": \"chan\",\n\t\"ESE\": \"ESE\",\n\t\"SE\": \"SE\",\n\t\"SSE\": \"SSE\",\n\t\"S\": \"S\",\n\t\"SSW\": \"SSW\",\n\t\"SW\": \"tIng\",\n\t\"WSW\": \"WSW\",\n\t\"W\": \"W\",\n\t\"WNW\": \"WNW\",\n\t\"NW\": \"'ev\",\n\t\"NNW\": \"NNW\",\n\n\t\"FEELS\": \"jem {DEGREE}\",\n\t\"PRECIP_POP\": \"nIHmey\",\n\t\"PRECIP_AMOUNT\": \"nIHmey Daq\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"mIghtaH {MODULE_NAME} mIghtaHmey lIjwI'pu'.\\nDoHbe' yImev.\",\n\t\"MODULE_CONFIG_ERROR\": \"{MODULE_NAME} mIghtaHmeyDaq ghobe' yImev. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"URL ghobe' yImev.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Internet ghobe' yImev.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"ghobe' yImev.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"logmeyDaq yImev.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"DaHghachmey ghobe' yImev.\",\n\n\t\"UPDATE_NOTIFICATION\": \" De'chu' MagicMirror² lI'laH.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"bobcho' {MODULE_NAME} lI'laH De'chu.\",\n\t\"UPDATE_INFO_SINGLE\": \"{BRANCH_NAME} ghoghDaq {COMMIT_COUNT} commit lIjwI'pu'.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"{BRANCH_NAME} ghoghDaq {COMMIT_COUNT} commit lIjwI'pu'.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"{MODULE_NAME} mIghtaHmeyDaq bobcho' lIjwI'pu'.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"{MODULE_NAME} mIghtaHmeyDaq bobcho' ghobe' yImev.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"MagicMirrorDaq yImev.\"\n}\n"
  },
  {
    "path": "translations/tr.json",
    "content": "{\n\t\"LOADING\": \"Yükleniyor …\",\n\n\t\"YESTERDAY\": \"Dün\",\n\t\"TODAY\": \"Bugün\",\n\t\"TOMORROW\": \"Yarın\",\n\t\"DAYAFTERTOMORROW\": \"İki gün içinde\",\n\t\"RUNNING\": \"Biten\",\n\t\"EMPTY\": \"Yakında etkinlik yok.\",\n\t\"WEEK\": \"Hafta {weekNumber}\",\n\n\t\"N\": \"K\",\n\t\"NNE\": \"KKD\",\n\t\"NE\": \"KD\",\n\t\"ENE\": \"DKD\",\n\t\"E\": \"D\",\n\t\"ESE\": \"DGD\",\n\t\"SE\": \"GD\",\n\t\"SSE\": \"GGD\",\n\t\"S\": \"G\",\n\t\"SSW\": \"GGB\",\n\t\"SW\": \"GB\",\n\t\"WSW\": \"BGB\",\n\t\"W\": \"B\",\n\t\"WNW\": \"BKB\",\n\t\"NW\": \"KB\",\n\t\"NNW\": \"KKB\",\n\n\t\"FEELS\": \"Hissedilen {DEGREE}\",\n\t\"PRECIP_POP\": \"Yağış\",\n\t\"PRECIP_AMOUNT\": \"Yağış miktarı\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"{MODULE_NAME} modülü için yapılandırma seçenekleri değiştirildi.\\nLütfen belgeleri kontrol edin.\",\n\t\"MODULE_CONFIG_ERROR\": \"{MODULE_NAME} modülünde hata. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"Hatalı URL.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"İnternet bağlantısı yok.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Yetkilendirme başarısız.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Daha fazla ayrıntı için günlükleri kontrol edin.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Şu anda haber yok.\",\n\n\t\"UPDATE_NOTIFICATION\": \"MagicMirror² güncellemesi mevcut.\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"{MODULE_NAME} modulü için güncelleme mevcut.\",\n\t\"UPDATE_INFO_SINGLE\": \"Sahip olduğunuz kurulum {BRANCH_NAME} branchinden {COMMIT_COUNT} commit geridedir.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Sahip olduğunuz kurulum {BRANCH_NAME} branchinden {COMMIT_COUNT} commit geridedir.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Güncelleme tamamlandı.\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Güncelleme sırasında hata oluştu.\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"MagicMirror'ün yeniden başlatılması gerekiyor.\"\n}\n"
  },
  {
    "path": "translations/translations.js",
    "content": "let translations = {\n\ten: \"translations/en.json\", // English\n\taf: \"translations/af.json\", // Afrikaans\n\tbg: \"translations/bg.json\", // Bulgarian\n\tca: \"translations/ca.json\", // Catalan\n\tcs: \"translations/cs.json\", // Czech\n\tcv: \"translations/cv.json\", // Chuvash\n\tcy: \"translations/cy.json\", // Welsh (Cymraeg)\n\tda: \"translations/da.json\", // Danish\n\tde: \"translations/de.json\", // German\n\tel: \"translations/el.json\", // Greek\n\teo: \"translations/eo.json\", // Esperanto\n\tes: \"translations/es.json\", // Spanish\n\tet: \"translations/et.json\", // Estonian\n\tfi: \"translations/fi.json\", // Suomi\n\tfr: \"translations/fr.json\", // French\n\tfy: \"translations/fy.json\", // Frysk\n\tgl: \"translations/gl.json\", // Galego\n\tgu: \"translations/gu.json\", // Gujarati\n\the: \"translations/he.json\", // Hebrew\n\thi: \"translations/hi.json\", // Hindi\n\thr: \"translations/hr.json\", // Croatian\n\thu: \"translations/hu.json\", // Hungarian\n\tid: \"translations/id.json\", // Indonesian\n\tis: \"translations/is.json\", // Icelandic\n\tit: \"translations/it.json\", // Italian\n\tja: \"translations/ja.json\", // Japanese\n\tko: \"translations/ko.json\", // Korean\n\tlt: \"translations/lt.json\", // Lithuanian\n\t\"ms-my\": \"translations/ms-my.json\", // Malay\n\tnb: \"translations/nb.json\", // Norsk bokmål\n\tnl: \"translations/nl.json\", // Dutch\n\tnn: \"translations/nn.json\", // Norsk nynorsk\n\tpl: \"translations/pl.json\", // Polish\n\tpt: \"translations/pt.json\", // Português\n\t\"pt-br\": \"translations/pt-br.json\", // Português Brasileiro\n\tro: \"translations/ro.json\", // Romanian\n\tru: \"translations/ru.json\", // Russian\n\tsk: \"translations/sk.json\", // Slovak\n\tsv: \"translations/sv.json\", // Svenska\n\tth: \"translations/th.json\", // Thai\n\ttlh: \"translations/tlh.json\", // Klingon\n\ttr: \"translations/tr.json\", // Turkish\n\tuk: \"translations/uk.json\", // Ukrainian\n\t\"zh-cn\": \"translations/zh-cn.json\", // Simplified Chinese\n\t\"zh-tw\": \"translations/zh-tw.json\" // Traditional Chinese\n};\n\nif (typeof module !== \"undefined\") {\n\tmodule.exports = translations;\n}\n"
  },
  {
    "path": "translations/uk.json",
    "content": "{\n\t\"LOADING\": \"Завантаження …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"Позавчора\",\n\t\"YESTERDAY\": \"вчора\",\n\t\"TODAY\": \"Сьогодні\",\n\t\"TOMORROW\": \"Завтра\",\n\t\"DAYAFTERTOMORROW\": \"Післязавтра\",\n\t\"RUNNING\": \"Закінчується через\",\n\t\"EMPTY\": \"Немає найближчих подій\",\n\t\"WEEK\": \"Тиждень {weekNumber}\",\n\n\t\"N\": \"Пн\",\n\t\"NNE\": \"ПнПнСх\",\n\t\"NE\": \"ПнСх\",\n\t\"ENE\": \"СхПнСх\",\n\t\"E\": \"Сх\",\n\t\"ESE\": \"СхПдСх\",\n\t\"SE\": \"СхПд\",\n\t\"SSE\": \"СхСхПд\",\n\t\"S\": \"Пд\",\n\t\"SSW\": \"ПдПдЗх\",\n\t\"SW\": \"ПдЗх\",\n\t\"WSW\": \"ЗхПдЗх\",\n\t\"W\": \"Зх\",\n\t\"WNW\": \"ЗхПнЗх\",\n\t\"NW\": \"ПнЗх\",\n\t\"NNW\": \"ПнПнЗх\",\n\n\t\"FEELS\": \"Відчувається як {DEGREE}\",\n\t\"PRECIP_POP\": \"Опади\",\n\t\"PRECIP_AMOUNT\": \"Кількість опадів\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"Змінились параметри конфігурації модуля {MODULE_NAME}.\\nБудь ласка, перевірте документацію.\",\n\t\"MODULE_CONFIG_ERROR\": \"Помилка в модулі {MODULE_NAME}. {ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"Неправильний URL.\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"Немає підключення до Інтернету.\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"Авторизація не вдалася.\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"Перевірте журнали для отримання додаткової інформації.\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"Немає новин на даний момент.\",\n\n\t\"UPDATE_NOTIFICATION\": \"Є оновлення для MagicMirror².\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"Є оновлення для модуля {MODULE_NAME}.\",\n\t\"UPDATE_INFO_SINGLE\": \"Поточна версія на {COMMIT_COUNT} комміт позаду від гілки {BRANCH_NAME}.\",\n\t\"UPDATE_INFO_MULTIPLE\": \"Поточна інсталяція на {COMMIT_COUNT} комітів позаду від гілки {BRANCH_NAME}.\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"Оновлення завершено для модуля {MODULE_NAME}\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"Помилка оновлення модуля {MODULE_NAME}\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"Потрібен перезапуск MagicMirror.\"\n}\n"
  },
  {
    "path": "translations/zh-cn.json",
    "content": "{\n\t\"LOADING\": \"正在加载 …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"前天\",\n\t\"YESTERDAY\": \"昨天\",\n\t\"TODAY\": \"今天\",\n\t\"TOMORROW\": \"明天\",\n\t\"DAYAFTERTOMORROW\": \"后天\",\n\t\"RUNNING\": \"结束日期\",\n\t\"EMPTY\": \"无日程安排。\",\n\t\"WEEK\": \"第{weekNumber}周\",\n\n\t\"N\": \"北风\",\n\t\"NNE\": \"北偏东风\",\n\t\"NE\": \"东北风\",\n\t\"ENE\": \"东偏北风\",\n\t\"E\": \"东风\",\n\t\"ESE\": \"东偏南风\",\n\t\"SE\": \"东南风\",\n\t\"SSE\": \"南偏东风\",\n\t\"S\": \"南风\",\n\t\"SSW\": \"南偏西风\",\n\t\"SW\": \"西南风\",\n\t\"WSW\": \"西偏南风\",\n\t\"W\": \"西风\",\n\t\"WNW\": \"西偏北风\",\n\t\"NW\": \"西北风\",\n\t\"NNW\": \"北偏西风\",\n\n\t\"FEELS\": \"体感 {DEGREE}\",\n\t\"PRECIP_POP\": \"降水概率\",\n\t\"PRECIP_AMOUNT\": \"降水量\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"模块{MODULE_NAME}的配置选项已更改。\\n请查看文档。\",\n\t\"MODULE_CONFIG_ERROR\": \"模块{MODULE_NAME}发生错误。{ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"URL格式错误。\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"无网络连接。\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"授权失败。\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"请查看日志以获取更多详细信息。\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"目前没有新闻。\",\n\n\t\"UPDATE_NOTIFICATION\": \"MagicMirror²有新的版本。\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"{MODULE_NAME}模块可更新。\",\n\t\"UPDATE_INFO_SINGLE\": \"当前已安装版本比{BRANCH_NAME}分支落后{COMMIT_COUNT}次代码更新。\",\n\t\"UPDATE_INFO_MULTIPLE\": \"当前已安装版本比{BRANCH_NAME}分支落后{COMMIT_COUNT}次代码更新。\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"{MODULE_NAME}模块更新完成。\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"{MODULE_NAME}模块更新错误。\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"需要重启MagicMirror。\"\n}\n"
  },
  {
    "path": "translations/zh-tw.json",
    "content": "{\n\t\"LOADING\": \"正在載入 …\",\n\n\t\"DAYBEFOREYESTERDAY\": \"前天\",\n\t\"YESTERDAY\": \"昨天\",\n\t\"TODAY\": \"今天\",\n\t\"TOMORROW\": \"明天\",\n\t\"DAYAFTERTOMORROW\": \"後天\",\n\t\"RUNNING\": \"結束日期\",\n\t\"EMPTY\": \"沒有更多的活動。\",\n\t\"WEEK\": \"第 {weekNumber} 週\",\n\n\t\"N\": \"北風\",\n\t\"NNE\": \"北偏東風\",\n\t\"NE\": \"東北風\",\n\t\"ENE\": \"東偏北風\",\n\t\"E\": \"東風\",\n\t\"ESE\": \"東偏南風\",\n\t\"SE\": \"東南風\",\n\t\"SSE\": \"南偏東風\",\n\t\"S\": \"南風\",\n\t\"SSW\": \"南偏西風\",\n\t\"SW\": \"西南風\",\n\t\"WSW\": \"西偏南風\",\n\t\"W\": \"西風\",\n\t\"WNW\": \"西偏北風\",\n\t\"NW\": \"西北風\",\n\t\"NNW\": \"北偏西風\",\n\n\t\"FEELS\": \"體感溫度 {DEGREE}\",\n\t\"PRECIP_POP\": \"降雨機率\",\n\t\"PRECIP_AMOUNT\": \"降雨量\",\n\n\t\"MODULE_CONFIG_CHANGED\": \"模組 {MODULE_NAME} 的設定檔選項已更改。\\n請參見說明文件。\",\n\t\"MODULE_CONFIG_ERROR\": \"{MODULE_NAME} 模組發生錯誤。{ERROR}\",\n\t\"MODULE_ERROR_MALFORMED_URL\": \"網址格式錯誤。\",\n\t\"MODULE_ERROR_NO_CONNECTION\": \"無網路連線。\",\n\t\"MODULE_ERROR_UNAUTHORIZED\": \"授權失敗。\",\n\t\"MODULE_ERROR_UNSPECIFIED\": \"查看日誌以了解詳情。\",\n\n\t\"NEWSFEED_NO_ITEMS\": \"目前沒有新聞。\",\n\n\t\"UPDATE_NOTIFICATION\": \"MagicMirror² 有可用更新。\",\n\t\"UPDATE_NOTIFICATION_MODULE\": \"{MODULE_NAME} 模組有可用更新。\",\n\t\"UPDATE_INFO_SINGLE\": \"目前版本在 {BRANCH_NAME} 分支上已落後了 {COMMIT_COUNT} 次 commit。\",\n\t\"UPDATE_INFO_MULTIPLE\": \"目前版本在 {BRANCH_NAME} 分支上已落後了 {COMMIT_COUNT} 次 commit。\",\n\t\"UPDATE_NOTIFICATION_DONE\": \"{MODULE_NAME} 模組更新完成。\",\n\t\"UPDATE_NOTIFICATION_ERROR\": \"{MODULE_NAME} 模組更新錯誤。\",\n\t\"UPDATE_NOTIFICATION_NEED-RESTART\": \"需要重新啟動 MagicMirror。\"\n}\n"
  },
  {
    "path": "vitest.config.mjs",
    "content": "import {defineConfig} from \"vitest/config\";\n\n/*\n * Sequential execution keeps our shared test server stable:\n * - All suites bind to port 8080\n * - Fixtures and temp paths are reused between tests\n * - Debugging becomes predictable\n *\n * Parallel execution would require dynamic ports and isolated fixtures,\n * so we intentionally cap Vitest at a single worker for now.\n *\n * Projects separate unit, e2e (Playwright), and electron tests with\n * appropriate timeouts for each test type.\n */\n\nexport default defineConfig({\n\ttest: {\n\t\t// Shared settings for all test types\n\t\tglobals: true,\n\t\tenvironment: \"node\",\n\t\tsetupFiles: [\"./tests/utils/vitest-setup.js\"],\n\t\t// Stop test execution on first failure\n\t\tbail: 3,\n\n\t\t// Shared exclude patterns\n\t\texclude: [\n\t\t\t\"**/node_modules/**\",\n\t\t\t\"**/dist/**\",\n\t\t\t\"tests/unit/mocks/**\",\n\t\t\t\"tests/unit/helpers/**\",\n\t\t\t\"tests/electron/helpers/**\",\n\t\t\t\"tests/e2e/helpers/**\",\n\t\t\t\"tests/e2e/mocks/**\",\n\t\t\t\"tests/configs/**\",\n\t\t\t\"tests/utils/**\"\n\t\t],\n\n\t\t// Projects with specific configurations per test type\n\t\tprojects: [\n\t\t\t{\n\t\t\t\ttest: {\n\t\t\t\t\tname: \"unit\",\n\t\t\t\t\tglobals: true,\n\t\t\t\t\tenvironment: \"node\",\n\t\t\t\t\tsetupFiles: [\"./tests/utils/vitest-setup.js\"],\n\t\t\t\t\tinclude: [\n\t\t\t\t\t\t\"tests/unit/**/*_spec.js\",\n\t\t\t\t\t\t\"tests/unit/modules/default/calendar/calendar_fetcher_utils_bad_rrule.js\"\n\t\t\t\t\t],\n\t\t\t\t\ttestTimeout: 20000,\n\t\t\t\t\thookTimeout: 10000\n\t\t\t\t}\n\t\t\t},\n\t\t\t{\n\t\t\t\ttest: {\n\t\t\t\t\tname: \"e2e\",\n\t\t\t\t\tglobals: true,\n\t\t\t\t\tenvironment: \"node\",\n\t\t\t\t\tsetupFiles: [\"./tests/utils/vitest-setup.js\"],\n\t\t\t\t\tinclude: [\"tests/e2e/**/*_spec.js\"],\n\t\t\t\t\ttestTimeout: 60000,\n\t\t\t\t\thookTimeout: 30000\n\t\t\t\t}\n\t\t\t},\n\t\t\t{\n\t\t\t\ttest: {\n\t\t\t\t\tname: \"electron\",\n\t\t\t\t\tglobals: true,\n\t\t\t\t\tenvironment: \"node\",\n\t\t\t\t\tsetupFiles: [\"./tests/utils/vitest-setup.js\"],\n\t\t\t\t\tinclude: [\"tests/electron/**/*_spec.js\"],\n\t\t\t\t\ttestTimeout: 120000,\n\t\t\t\t\thookTimeout: 30000\n\t\t\t\t}\n\t\t\t}\n\t\t],\n\n\t\t// Coverage configuration\n\t\tcoverage: {\n\t\t\tprovider: \"v8\",\n\t\t\treporter: [\"lcov\", \"text\"],\n\t\t\tinclude: [\n\t\t\t\t\"clientonly/**/*.js\",\n\t\t\t\t\"js/**/*.js\",\n\t\t\t\t\"modules/default/**/*.js\",\n\t\t\t\t\"serveronly/**/*.js\"\n\t\t\t],\n\t\t\texclude: [\n\t\t\t\t\"**/node_modules/**\",\n\t\t\t\t\"**/tests/**\",\n\t\t\t\t\"**/dist/**\"\n\t\t\t]\n\t\t},\n\n\t\t/*\n\t\t * Pool settings for isolated test execution. Keep maxWorkers at 1 so\n\t\t * port 8080 and shared fixtures remain safe across the full suite.\n\t\t */\n\t\tpool: \"forks\",\n\t\tmaxWorkers: 1,\n\t\tisolate: true\n\t}\n});\n"
  }
]