mirror of
https://github.com/MagicMirrorOrg/MagicMirror.git
synced 2026-03-14 18:51:39 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b742e839be | ||
|
|
b0c5924019 | ||
|
|
62b0f7f26e | ||
|
|
8e0b8468d3 | ||
|
|
39a614e0de | ||
|
|
9c9a5359dd | ||
|
|
c24de64d77 |
137
.github/CODE_OF_CONDUCT.md
vendored
Normal file
137
.github/CODE_OF_CONDUCT.md
vendored
Normal file
@@ -0,0 +1,137 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, caste, color, religion, or sexual
|
||||
identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the overall
|
||||
community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official email address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement:
|
||||
Contact [Rejas](https://forum.magicmirror.builders/user/rejas),
|
||||
[Karsten](https://forum.magicmirror.builders/user/karsten13),
|
||||
[Sam](https://forum.magicmirror.builders/user/sdetweil) or
|
||||
[Kristjan](https://forum.magicmirror.builders/user/kristjanesperanto)
|
||||
via private message in the forum.
|
||||
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of
|
||||
actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or permanent
|
||||
ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the
|
||||
community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||
|
||||
Community Impact Guidelines were inspired by
|
||||
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
|
||||
[https://www.contributor-covenant.org/translations][translations].
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||
[FAQ]: https://www.contributor-covenant.org/faq
|
||||
[translations]: https://www.contributor-covenant.org/translations
|
||||
62
.github/CONTRIBUTING.md
vendored
62
.github/CONTRIBUTING.md
vendored
@@ -6,55 +6,43 @@ We hold our code to standard, and these standards are documented below.
|
||||
|
||||
## Linters
|
||||
|
||||
We use prettier for automatic linting of all our files: `npm run lint:prettier`.
|
||||
We use [prettier](https://prettier.io/) for automatic formatting a lot all our files. The configuration is in our `prettier.config.mjs` file.
|
||||
|
||||
To run prettier, use `node --run lint:prettier`.
|
||||
|
||||
### JavaScript: Run ESLint
|
||||
|
||||
We use [ESLint](https://eslint.org) on our JavaScript files.
|
||||
We use [ESLint](https://eslint.org) to lint our JavaScript files. The configuration is in our `eslint.config.mjs` file.
|
||||
|
||||
The ESLint configuration is in our `eslint.config.mjs` file.
|
||||
|
||||
To run ESLint, use `npm run lint:js`.
|
||||
To run ESLint, use `node --run lint:js`.
|
||||
|
||||
### CSS: Run StyleLint
|
||||
|
||||
We use [StyleLint](https://stylelint.io) to lint our CSS. Our configuration is in our `.stylelintrc` file.
|
||||
We use [StyleLint](https://stylelint.io) to lint our CSS. The configuration is in our `stylelint.config.mjs` file.
|
||||
|
||||
To run StyleLint, use `npm run lint:css`.
|
||||
To run StyleLint, use `node --run lint:css`.
|
||||
|
||||
### Markdown: Run markdownlint
|
||||
|
||||
We use [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2) to lint our markdown files. The configuration is in our `.markdownlint.json` file.
|
||||
|
||||
To run markdownlint, use `node --run lint:markdown`.
|
||||
|
||||
## Testing
|
||||
|
||||
We use [Jest](https://jestjs.io) for JavaScript testing.
|
||||
We use [Vitest](https://vitest.dev) for JavaScript testing.
|
||||
|
||||
To run all tests, use `npm run test`.
|
||||
To run all tests, use `node --run test`.
|
||||
|
||||
The specific test commands are defined in `package.json`.
|
||||
So you can also run the specific tests with other commands, e.g. `npm run test:unit` or `npx jest tests/e2e/env_spec.js`.
|
||||
The `package.json` scripts expose finer-grained test commands:
|
||||
|
||||
## Submitting Issues
|
||||
- `test:unit` – run unit tests only
|
||||
- `test:e2e` – execute browser-driven end-to-end tests
|
||||
- `test:electron` – launch the Electron-based regression suite
|
||||
- `test:coverage` – collect coverage while running every suite
|
||||
- `test:watch` – keep Vitest in watch mode for fast local feedback
|
||||
- `test:ui` – open the Vitest UI dashboard (needs OS file-watch support enabled)
|
||||
- `test:calendar` – run the legacy calendar debug helper
|
||||
- `test:css`, `test:markdown`, `test:prettier`, `test:spelling`, `test:js` – lint-only scripts that enforce formatting, spelling, markdown style, and ESLint.
|
||||
|
||||
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: [https://forum.magicmirror.builders/category/15/bug-hunt](https://forum.magicmirror.builders/category/15/bug-hunt)
|
||||
|
||||
Problems installing or configuring your MagicMirror? Check out: [https://forum.magicmirror.builders/category/10/troubleshooting](https://forum.magicmirror.builders/category/10/troubleshooting)
|
||||
|
||||
When submitting a new issue, please supply the following information:
|
||||
|
||||
**Platform**: Place your platform here... give us your web browser/Electron version _and_ your hardware (Raspberry Pi 2/3/4, Windows, Mac, Linux, System V UNIX).
|
||||
|
||||
**Node Version**: Make sure it's version 18 or later (recommended is 20).
|
||||
|
||||
**MagicMirror² Version**: Please let us know which version of MagicMirror² you are running. It can be found in the `package.json` file.
|
||||
|
||||
**Description**: Provide a detailed description about the issue and include specific details to help us understand the problem. Adding screenshots will help describing the problem.
|
||||
|
||||
**Steps to Reproduce**: List the step by step process to reproduce the issue.
|
||||
|
||||
**Expected Results**: Describe what you expected to see.
|
||||
|
||||
**Actual Results**: Describe what you actually saw.
|
||||
|
||||
**Configuration**: What does the used config.js file look like? Don't forget to remove any sensitive information!
|
||||
|
||||
**Additional Notes**: Provide any other relevant notes not previously mentioned. This is optional.
|
||||
You 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`.
|
||||
|
||||
52
.github/ISSUE_TEMPLATE.md
vendored
52
.github/ISSUE_TEMPLATE.md
vendored
@@ -1,52 +0,0 @@
|
||||
Hello and thank you for opening an issue.
|
||||
|
||||
**⚠️ Please make sure that you have read the following lines before submitting your Issue:**
|
||||
|
||||
## I'm not sure if this is a bug
|
||||
|
||||
If you're not sure if it's a real bug or if it's just you, please open a topic on the forum: [https://forum.magicmirror.builders/category/15/bug-hunt](https://forum.magicmirror.builders/category/15/bug-hunt)
|
||||
|
||||
## I'm having troubles installing or configuring MagicMirror
|
||||
|
||||
Problems installing or configuring your MagicMirror? Check out: [https://forum.magicmirror.builders/category/10/troubleshooting](https://forum.magicmirror.builders/category/10/troubleshooting)
|
||||
|
||||
A common problem is that your config file could be invalid. Please run in your MagicMirror² directory: `npm run config:check` and see if it reports an error.
|
||||
|
||||
## I found a bug in the MagicMirror² installer
|
||||
|
||||
If you are facing an issue or found a bug while trying to install MagicMirror² via the installer please report it in the respective GitHub repository:
|
||||
[https://github.com/sdetweil/MagicMirror_scripts](https://github.com/sdetweil/MagicMirror_scripts)
|
||||
|
||||
## I found a bug in the MagicMirror² Docker image
|
||||
|
||||
If you are facing an issue or found a bug while running MagicMirror² inside a Docker container please create an issue in the corresponding repository:
|
||||
[https://gitlab.com/khassel/magicmirror](https://gitlab.com/khassel/magicmirror)
|
||||
|
||||
## I'm having troubles installing or configuring foreign modules
|
||||
|
||||
Please open an issue in the module repository or ask for help in the [forum](https://forum.magicmirror.builders/)
|
||||
|
||||
---
|
||||
|
||||
## I found a bug in MagicMirror
|
||||
|
||||
Please make sure to only submit reproducible issues. You can safely remove everything above the dividing line.
|
||||
When submitting a new issue, please supply the following information:
|
||||
|
||||
**Platform**: Place your platform here... give us your web browser/Electron version _and_ your hardware (Raspberry Pi 2/3/4, Windows, Mac, Linux, System V UNIX).
|
||||
|
||||
**Node Version**: Make sure it's version 18 or later (recommended is 20).
|
||||
|
||||
**MagicMirror² Version**: Please let us know which version of MagicMirror² you are running. It can be found in the `package.json` file.
|
||||
|
||||
**Description**: Provide a detailed description about the issue and include specific details to help us understand the problem. Adding screenshots will help describing the problem.
|
||||
|
||||
**Steps to Reproduce**: List the step by step process to reproduce the issue.
|
||||
|
||||
**Expected Results**: Describe what you expected to see.
|
||||
|
||||
**Actual Results**: Describe what you actually saw.
|
||||
|
||||
**Configuration**: What does the used config.js file look like? Don't forget to remove any sensitive information!
|
||||
|
||||
**Additional Notes**: Provide any other relevant notes not previously mentioned. This is optional.
|
||||
154
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
154
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,154 @@
|
||||
name: 🐛 Report a problem
|
||||
description: Report an issue with MagicMirror² 🚨
|
||||
title: "[Bug] {{ brief description }}"
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting a bug! Please fill in the following template to help us reproduce the issue.
|
||||
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.
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: |
|
||||
Please tell us about how your MagicMirror² is set up.
|
||||
|
||||
Optimal would be the systeminformation from the logs, which looks like this:
|
||||
```bash
|
||||
[2025-01-14 20:05:03.529] [INFO] System information:
|
||||
### SYSTEM: manufacturer: Raspberry Pi Foundation; model: Raspberry Pi 4 Model B Rev 1.5; virtual: false
|
||||
### OS: platform: linux; distro: Debian GNU/Linux; release: 12; arch: arm64; kernel: 6.1.21-v8+
|
||||
### VERSIONS: electron: 31.2.1; used node: 20.15.0; installed node: 22.4.1; npm: 10.8.1; pm2:
|
||||
### OTHER: timeZone: Europe/Berlin; ELECTRON_ENABLE_GPU: undefined
|
||||
```
|
||||
|
||||
If you can't provide this information, please provide the following:
|
||||
- MagicMirror² version: Can be found in the `package.json` file. Please use the latest version before reporting a bug.
|
||||
- Node version: Run `node -v` to find out. Make sure it's version 20 or later (recommended is 22).
|
||||
- npm version: Run `npm -v` to find out.
|
||||
- Platform: Are you using a Raspberry Pi (2/3/4/5), Windows, Mac, Linux, Docker, or something else?
|
||||
value: |
|
||||
MagicMirror² version:
|
||||
Node version:
|
||||
npm version:
|
||||
Platform:
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: start-option
|
||||
attributes:
|
||||
label: Which start option are you using?
|
||||
description: |
|
||||
Please keep in mind that some problems are specific to certain start options.
|
||||
options:
|
||||
- "node --run start"
|
||||
- "node --run start:wayland"
|
||||
- "node --run start:windows"
|
||||
- "node --run start:x11"
|
||||
- "node --run server"
|
||||
- "node clientonly --address ... --port ..."
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: pm2
|
||||
attributes:
|
||||
label: Are you using PM2?
|
||||
options:
|
||||
- "No"
|
||||
- "Yes"
|
||||
- "I don't know"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: module
|
||||
attributes:
|
||||
label: Module
|
||||
description: |
|
||||
If the issue is related to a specific module, please provide the name of the module.
|
||||
Note: Please don't report issues with 3rd party modules here. Report them on the module's repository.
|
||||
options:
|
||||
- "alert"
|
||||
- "calendar"
|
||||
- "clock"
|
||||
- "compliments"
|
||||
- "helloworld"
|
||||
- "newsfeed"
|
||||
- "updatenotification"
|
||||
- "weather"
|
||||
- type: checkboxes
|
||||
id: module-disabled
|
||||
attributes:
|
||||
label: Have you tried disabling other modules?
|
||||
options:
|
||||
- label: "Yes"
|
||||
- label: "No"
|
||||
- type: checkboxes
|
||||
id: search
|
||||
attributes:
|
||||
label: Have you searched if someone else has already reported the issue on the forum or in the issues?
|
||||
options:
|
||||
- label: "Yes"
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What did you do?
|
||||
description: |
|
||||
Please include a *minimal* reproduction case. List the step by step process to reproduce the issue.
|
||||
You can use Markdown in this field.
|
||||
value: |
|
||||
<details>
|
||||
<summary>Configuration</summary>
|
||||
|
||||
```
|
||||
<!-- Paste your configuration here. Don't forget to remove any sensitive information! -->
|
||||
```
|
||||
</details>
|
||||
|
||||
```js
|
||||
<!-- Paste relevant code here -->
|
||||
```
|
||||
|
||||
Steps to reproduce the issue:
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expectation
|
||||
attributes:
|
||||
label: What did you expect to happen?
|
||||
description: |
|
||||
You can use Markdown in this field.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: lint-output
|
||||
attributes:
|
||||
label: What actually happened?
|
||||
description: |
|
||||
Please copy-paste relevant log output or error messages.
|
||||
You can use Markdown in this field.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: comments
|
||||
attributes:
|
||||
label: Additional comments
|
||||
description: |
|
||||
Is there anything else that's important for the team to know?
|
||||
Fill out all fields and provide as much information as possible.
|
||||
Adding screenshots might help us understand your problem better.
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Participation
|
||||
options:
|
||||
- label: "I am willing to submit a pull request for this change."
|
||||
required: false
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Please **do not** open a pull request until this issue has been accepted by the team.
|
||||
41
.github/ISSUE_TEMPLATE/change_request.yml
vendored
Normal file
41
.github/ISSUE_TEMPLATE/change_request.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: 🔀 Request a change
|
||||
description: Request a change that is not a bug fix, a feature request or a support request.
|
||||
title: "[Change Request] {{ brief description }}"
|
||||
labels:
|
||||
- enhancement
|
||||
- core
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Thanks for requesting a change! Please fill in the following template to help us understand your request.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: What problem do you want to solve with this change?
|
||||
description: |
|
||||
Please explain your use case in as much detail as possible.
|
||||
placeholder: |
|
||||
Currently...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: What do you think is the correct solution?
|
||||
description: |
|
||||
Please explain how you'd like to change MagicMirror² to address the problem.
|
||||
placeholder: |
|
||||
I'd like MagicMirror² to...
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Participation
|
||||
options:
|
||||
- label: I am willing to submit a pull request for this change.
|
||||
required: false
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Please **do not** open a pull request until this issue has been accepted by the team.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional comments
|
||||
description: Is there anything else that's important for the team to know?
|
||||
14
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
14
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 📚 Documentation
|
||||
url: https://github.com/MagicMirrorOrg/MagicMirror-Documentation/issues
|
||||
about: This issue tracker is not for documentation issues. Please file documentation issues on the docs repo.
|
||||
- name: 🤔 Support Question
|
||||
url: https://forum.magicmirror.builders/
|
||||
about: Problems installing or configuring your MagicMirror? Please post your question on the MagicMirror² Forum.
|
||||
- name: 💬 Exchange of ideas
|
||||
url: https://discord.gg/AmGBBwPph5
|
||||
about: This issue tracker is not for general discussion. Please use the Discord channel.
|
||||
- name: 📦 Issues with a 3rd-party module
|
||||
url: https://kristjanesperanto.github.io/MagicMirror-3rd-Party-Modules/
|
||||
about: This issue tracker is not for 3rd-party module issues. Please file 3rd-party module issues on the module's repo.
|
||||
67
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
67
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
name: 🚀 Feature Request
|
||||
description: Suggest a new feature for MagicMirror² 💡
|
||||
title: "[Feature Request] {{ brief description }}"
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: prerequisites
|
||||
attributes:
|
||||
label: Prerequisites
|
||||
description: Please ensure you have completed all of the following.
|
||||
options:
|
||||
- label: I am running the latest version of MagicMirror², and know that this feature is not available now.
|
||||
required: true
|
||||
- label: I know my issue is not related to a third-party module.
|
||||
required: true
|
||||
- label: I have searched for [existing issues](https://github.com/MagicMirrorOrg/MagicMirror/issues) that already include this feature request, without success.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Describe the Feature Request
|
||||
description: A clear and concise description of what the feature does.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: use-case
|
||||
attributes:
|
||||
label: Describe the Use Case
|
||||
description: A clear and concise use case for what problem this feature would solve.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposed-solution
|
||||
attributes:
|
||||
label: Describe Preferred Solution
|
||||
description: A clear and concise description of how you want this feature to be added to MagicMirror².
|
||||
|
||||
- type: textarea
|
||||
id: alternatives-considered
|
||||
attributes:
|
||||
label: Describe Alternatives
|
||||
description: A clear and concise description of any alternative solutions or features you have considered.
|
||||
|
||||
- type: textarea
|
||||
id: related-code
|
||||
attributes:
|
||||
label: Related Code
|
||||
description: If you are able to illustrate the feature request with an example, please provide a sample here.
|
||||
|
||||
- type: textarea
|
||||
id: additional-information
|
||||
attributes:
|
||||
label: Additional Information
|
||||
description: List any other information that is relevant to your issue. Related issues, suggestions on how to implement, Stack Overflow links, forum links, etc.
|
||||
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Participation
|
||||
options:
|
||||
- label: I am willing to submit a pull request for this change.
|
||||
required: false
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Please **do not** open a pull request until this issue has been accepted by the team.
|
||||
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,6 +1,6 @@
|
||||
Hello and thank you for wanting to contribute to the MagicMirror² project
|
||||
Hello and thank you for wanting to contribute to the MagicMirror² project!
|
||||
|
||||
**Please make sure that you have followed these 4 rules before submitting your Pull Request:**
|
||||
**Please make sure that you have followed these 3 rules before submitting your Pull Request:**
|
||||
|
||||
> 1. Base your pull requests against the `develop` branch.
|
||||
> 2. Include these infos in the description:
|
||||
@@ -10,10 +10,8 @@ Hello and thank you for wanting to contribute to the MagicMirror² project
|
||||
> - What does the pull request accomplish? Use a list if needed.
|
||||
> - If it includes major visual changes please add screenshots.
|
||||
>
|
||||
> 3. Please run `npm run lint:prettier` before submitting so that
|
||||
> 3. Please run `node --run lint:prettier` before submitting so that
|
||||
> style issues are fixed.
|
||||
> 4. Don't forget to add an entry about your changes to
|
||||
> the CHANGELOG.md file.
|
||||
|
||||
**Note**: Sometimes the development moves very fast. It is highly
|
||||
recommended that you update your branch of `develop` before creating a
|
||||
|
||||
22
.github/dependabot.yaml
vendored
22
.github/dependabot.yaml
vendored
@@ -6,7 +6,6 @@ updates:
|
||||
interval: "weekly"
|
||||
target-branch: "develop"
|
||||
labels:
|
||||
- "Skip Changelog"
|
||||
- "dependencies"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
@@ -15,26 +14,5 @@ updates:
|
||||
interval: "monthly"
|
||||
target-branch: "develop"
|
||||
labels:
|
||||
- "Skip Changelog"
|
||||
- "dependencies"
|
||||
- "javascript"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/vendor"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
target-branch: "develop"
|
||||
labels:
|
||||
- "Skip Changelog"
|
||||
- "dependencies"
|
||||
- "javascript"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/fonts"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
target-branch: "develop"
|
||||
labels:
|
||||
- "Skip Changelog"
|
||||
- "dependencies"
|
||||
- "javascript"
|
||||
|
||||
19
.github/stale.yaml
vendored
19
.github/stale.yaml
vendored
@@ -1,19 +0,0 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 60
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- pinned
|
||||
- security
|
||||
- under investigation
|
||||
- pr welcome
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: wontfix
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
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.
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
60
.github/workflows/automated-tests.yaml
vendored
60
.github/workflows/automated-tests.yaml
vendored
@@ -12,31 +12,65 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
code-style-check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v6
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "npm"
|
||||
- name: "Install dependencies"
|
||||
run: |
|
||||
node --run install-mm:dev
|
||||
- name: "Run linter tests"
|
||||
run: |
|
||||
node --run test:prettier
|
||||
node --run test:js
|
||||
node --run test:css
|
||||
node --run test:markdown
|
||||
test:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.9.0, 20.x, 22.x]
|
||||
node-version: [22.21.1, 22.x, 24.x]
|
||||
steps:
|
||||
- name: Install electron dependencies and labwc
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libnss3 libasound2t64 labwc
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: "Use Node.js ${{ matrix.node-version }}"
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
check-latest: true
|
||||
cache: "npm"
|
||||
- name: "Install dependencies"
|
||||
- name: "Install MagicMirror²"
|
||||
run: |
|
||||
npm run install-mm:dev
|
||||
node --run install-mm:dev
|
||||
- name: "Install Playwright browsers"
|
||||
run: |
|
||||
npx playwright install --with-deps chromium
|
||||
- name: "Prepare environment for tests"
|
||||
run: |
|
||||
# Fix chrome-sandbox permissions:
|
||||
sudo chown root:root ./node_modules/electron/dist/chrome-sandbox
|
||||
sudo chmod 4755 ./node_modules/electron/dist/chrome-sandbox
|
||||
# Start labwc
|
||||
WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 WLR_RENDERER=pixman labwc &
|
||||
touch css/custom.css
|
||||
- name: "Run tests"
|
||||
run: |
|
||||
Xvfb :99 -screen 0 1024x768x16 &
|
||||
export DISPLAY=:99
|
||||
touch css/custom.css
|
||||
npm run test:prettier
|
||||
npm run test:js
|
||||
npm run test:css
|
||||
npm run test
|
||||
export WAYLAND_DISPLAY=wayland-0
|
||||
node --run test
|
||||
|
||||
4
.github/workflows/dep-review.yaml
vendored
4
.github/workflows/dep-review.yaml
vendored
@@ -13,8 +13,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: "Dependency Review"
|
||||
uses: actions/dependency-review-action@v4
|
||||
with:
|
||||
allow-ghsas: GHSA-8hc4-vh64-cxmj
|
||||
|
||||
14
.github/workflows/electron-rebuild.yaml
vendored
14
.github/workflows/electron-rebuild.yaml
vendored
@@ -8,23 +8,21 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20.9.0, 20.x, 22.x]
|
||||
node-version: [22.21.1, 22.x, 24.x]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: "Use Node.js ${{ matrix.node-version }}"
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
check-latest: true
|
||||
- name: Install MagicMirror
|
||||
run: npm run install-mm
|
||||
run: node --run install-mm
|
||||
- name: Install @electron/rebuild
|
||||
run: npm install @electron/rebuild
|
||||
- name: Install node-libgpiod deps
|
||||
run: sudo apt-get install gpiod libgpiod2 libgpiod-dev
|
||||
- name: Install some test library to be rebuilded
|
||||
run: npm install node-libgpiod node-pty drivelist
|
||||
- name: Install test library (serialport) to be rebuilt
|
||||
run: npm install serialport
|
||||
- name: Run electron-rebuild
|
||||
run: npx electron-rebuild
|
||||
continue-on-error: false
|
||||
|
||||
28
.github/workflows/enforce-pullrequest-rules.yaml
vendored
28
.github/workflows/enforce-pullrequest-rules.yaml
vendored
@@ -1,28 +1,26 @@
|
||||
# This workflow enforces on every pull request:
|
||||
# - the update of our CHANGELOG.md file, see: https://github.com/dangoslen/changelog-enforcer
|
||||
# - that the PR is not based against master, taken from https://github.com/oppia/oppia-android/pull/2832/files
|
||||
# This workflow enforces on every pull request that the PR is not based against master,
|
||||
# taken from https://github.com/oppia/oppia-android/blob/develop/.github/workflows/static_checks.yml
|
||||
|
||||
name: "Enforce Pull-Request Rules"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
|
||||
pull_request:
|
||||
push:
|
||||
branches-ignore:
|
||||
- develop
|
||||
- master
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: "Enforce changelog"
|
||||
uses: dangoslen/changelog-enforcer@v3
|
||||
with:
|
||||
changeLogPath: "CHANGELOG.md"
|
||||
skipLabels: "Skip Changelog"
|
||||
- name: "Enforce develop branch"
|
||||
if: ${{ github.event.pull_request.base.ref == 'master' && !contains(github.event.pull_request.labels.*.name, 'mastermerge') }}
|
||||
- name: "Branch is not based on develop"
|
||||
if: ${{ github.base_ref != 'develop' && !contains(github.event.pull_request.labels.*.name, 'mastermerge') }}
|
||||
run: |
|
||||
echo "This PR is based against the master branch and not a release or hotfix."
|
||||
echo "Please don't do this. Switch the branch to 'develop'."
|
||||
echo "Current base branch: $BASE_BRANCH"
|
||||
echo "Note: PRs should only ever be merged into develop so please rebase your branch on develop and try again."
|
||||
exit 1
|
||||
env:
|
||||
BASE_BRANCH: ${{ github.event.pull_request.base.ref }}
|
||||
BASE_BRANCH: ${{ github.base_ref }}
|
||||
|
||||
33
.github/workflows/release-notes.yaml
vendored
Normal file
33
.github/workflows/release-notes.yaml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
# This workflow writes a draft release on GitHub named `unreleased` after every push on develop
|
||||
|
||||
name: "Create Release Notes"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
release-notes:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: "0"
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "npm"
|
||||
- name: "Create Markdown content"
|
||||
run: |
|
||||
export GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}
|
||||
node js/releasenotes.js
|
||||
31
.github/workflows/spellcheck.yaml
vendored
Normal file
31
.github/workflows/spellcheck.yaml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# This workflow will run a spellcheck on the codebase.
|
||||
# It runs a few days before each release. At 00:00 on day-of-month 27 in March, June, September, and December.
|
||||
|
||||
name: Run Spellcheck
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 27 3,6,9,12 *"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
spellcheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: develop
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: lts/*
|
||||
check-latest: true
|
||||
cache: "npm"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
node --run install-mm:dev
|
||||
- name: Run Spellcheck
|
||||
run: node --run test:spelling
|
||||
22
.github/workflows/stale.yaml
vendored
Normal file
22
.github/workflows/stale.yaml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: "Close stale issues and PRs"
|
||||
|
||||
on:
|
||||
workflow_dispatch: # needed for manually running this workflow
|
||||
schedule:
|
||||
- cron: "30 1 * * 6" # every Saturday at 1:30
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
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."
|
||||
days-before-issue-stale: 60
|
||||
days-before-issue-close: 7
|
||||
operations-per-run: 100
|
||||
stale-issue-label: "wontfix"
|
||||
exempt-issue-labels: "pinned,security,under investigation,pr welcome,ready (coming with next release)"
|
||||
22
.gitignore
vendored
22
.gitignore
vendored
@@ -9,10 +9,7 @@ lib-cov
|
||||
coverage
|
||||
.lock-wscript
|
||||
build/Release
|
||||
/node_modules/**/*
|
||||
fonts/node_modules/**/*
|
||||
vendor/node_modules/**/*
|
||||
!/tests/node_modules/**/*
|
||||
node_modules
|
||||
jspm_modules
|
||||
.npm
|
||||
.node_repl_history
|
||||
@@ -58,19 +55,19 @@ Temporary Items
|
||||
.Trash-*
|
||||
|
||||
# Ignore all modules except the default modules.
|
||||
/modules/**
|
||||
/modules/*
|
||||
!/modules/default
|
||||
!/modules/default/**
|
||||
!/modules/README.md**
|
||||
|
||||
# Ignore changes to the custom css files but keep the sample and main.
|
||||
/css/*
|
||||
!/css/custom.css.sample
|
||||
!/css/font-awesome.css
|
||||
!/css/main.css
|
||||
!/css/roboto.css
|
||||
|
||||
# Ignore users config file but keep the sample.
|
||||
/config/*
|
||||
!/config/config.js.sample
|
||||
config
|
||||
!config/config.js.sample
|
||||
|
||||
# Vim
|
||||
## swap
|
||||
@@ -84,3 +81,10 @@ Temporary Items
|
||||
|
||||
# Ignore positions file (#3518)
|
||||
js/positions.js
|
||||
|
||||
# Ignore lock files other than package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
|
||||
# Vitest temporary test files
|
||||
tests/**/.tmp/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/bin/sh
|
||||
|
||||
if command -v npm &> /dev/null; then
|
||||
npm run lint:staged
|
||||
if command -v npx &> /dev/null; then
|
||||
npx lint-staged
|
||||
fi
|
||||
|
||||
6
.markdownlint.json
Normal file
6
.markdownlint.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"line_length": false,
|
||||
"no-duplicate-heading": false,
|
||||
"no-inline-html": false,
|
||||
"no-trailing-punctuation": false
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"trailingComma": "none"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"extends": ["stylelint-config-standard"],
|
||||
"plugins": ["stylelint-prettier"],
|
||||
"rules": {
|
||||
"prettier/prettier": true
|
||||
}
|
||||
}
|
||||
305
CHANGELOG.md
305
CHANGELOG.md
@@ -1,10 +1,231 @@
|
||||
# MagicMirror² Change Log
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
This project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
❤️ **Donate:** Enjoying MagicMirror²? [Please consider a donation!](https://magicmirror.builders/#donate) With your help we can continue to improve the MagicMirror².
|
||||
|
||||
## Obsolete
|
||||
|
||||
This file is no longer being updated. Release notes are now automatically generated via a GitHub action.
|
||||
|
||||
## [2.33.0] - 2025-10-01
|
||||
|
||||
Thanks to: @Crazylegstoo, @dathbe, @m-idler, @plebcity, @khassel, @KristjanESPERANTO, @rejas and @sdetweil!
|
||||
|
||||
> ⚠️ This release needs nodejs version `v22.18.0 or higher`
|
||||
|
||||
### Added
|
||||
|
||||
- Add configuration option for `User-Agent`, used by calendar & news module (#3255)
|
||||
- [linter] Add prettier plugin for nunjuck templates (#3887)
|
||||
- [core] Add clear log for occupied port at startup (#3890)
|
||||
|
||||
### Changed
|
||||
|
||||
- [clock] Add CSS to prevent line breaking of sunset/sunrise time display (#3816)
|
||||
- [core] Enhance system information logging format and include additional env and RAM details (#3839, #3843)
|
||||
- [refactor] Add new file `js/module_functions.js` to move code used in several modules to one place (#3837)
|
||||
- [refactor] Use global.root_path where possible and add tests for config:check (#3883, #3885, #3886, #3889)
|
||||
- [tests] refactor: simplify jest config file (#3844)
|
||||
- [tests] refactor: extract constants for weather electron tests (#3845)
|
||||
- [tests] refactor: add `setupDOMEnvironment` helper function to eliminate repetitive JSDOM setup code (#3860)
|
||||
- [tests] replace `console` with `Log` in calendar `debug.js` to avoid exception in eslint config (#3846)
|
||||
- [tests] speed up e2e tests, cleanup and stabilize weather e2e tests, update snapshot url (#3847, #3848, #3861)
|
||||
- [tests] refactor translation tests (#3866)
|
||||
- Remove `sinon` dependency in favor of Jest native mocking
|
||||
- Unify test helper functions across translation test suites
|
||||
- Rename `setupDOMEnvironment` to `createTranslationTestEnvironment` for consistency
|
||||
- Simplify DOM setup by removing unnecessary Promise/async patterns
|
||||
- Avoid potential port conflicts by using port 3001 for translator unit tests
|
||||
- Improve test reliability and maintainability
|
||||
- [tests] add alert module tests for different welcome_message configurations (#3867)
|
||||
- [lint-staged] use `prettier --write --ignore-unknown` in `lint-staged` to avoid errors on unsupported files (#3888)
|
||||
|
||||
### Updated
|
||||
|
||||
- [calendar] Update defaultSymbol name and also the link to the icon search site (#3879)
|
||||
- [core] Update dependencies including electron to v38 as well as github actions (#3831, #3849, #3857, #3858, #3872, #3876, #3882, #3891, #3896)
|
||||
- [weather] Update feels_like temperature calculation formula (#3869)
|
||||
- [weather] Update null value handling for weather type (#3892)
|
||||
- [layout] Update styles for weather and calendar (#3894)
|
||||
|
||||
### Fixed
|
||||
|
||||
- [calendar] Fixed broken unittest that only broke on the 1st of July and 1st of january (#3830)
|
||||
- [clock] Fixed missing icons when no other modules with icons is loaded (#3834)
|
||||
- [weather] Fixed handling of empty values in weathergov providers handling of precipitationAmount (#3859)
|
||||
- [calendar] Fix regression handling of limit days (#3840)
|
||||
- [calendar] Fixed regression of calendarfetcherutils.shouldEventBeExcluded (#3841)
|
||||
- [core] Fixed socket.io timeout when server is slow to send notification, notification lost at client (#3380)
|
||||
- [tests] refactor AnimateCSS tests after jsdom 27 upgrade (#3891)
|
||||
- [weather] Use `apparent_temperature` data from openmeteo's hourly weather for current feelsLikeTemp (#3868).
|
||||
- [weather] Updated envcanada Provider to use new database/URL schema for accessing weather data (#3878).
|
||||
|
||||
## [2.32.0] - 2025-07-01
|
||||
|
||||
Thanks to: @bughaver, @bugsounet, @khassel, @KristjanESPERANTO, @plebcity, @rejas, @sdetweil.
|
||||
|
||||
> ⚠️ This release needs nodejs version `v22.14.0 or higher`
|
||||
|
||||
### Added
|
||||
|
||||
- [config] Allow to change module order for final renderer (or dynamically with CSS): Feature `order` in config (#3762)
|
||||
- [clock] Added option 'disableNextEvent' to hide next sun event (#3769)
|
||||
- [clock] Implement short syntax for clock week (#3775)
|
||||
|
||||
### Changed
|
||||
|
||||
- [refactor] Simplify module loading process (#3766)
|
||||
- Use `node --run` instead of `npm run` (#3764) and adapt `start:dev` script (#3773)
|
||||
- [workflow] Run linter and spellcheck with LTS node version (#3767)
|
||||
- [workflow] Split "Run test" step into two steps for more clarity (#3767)
|
||||
- [linter] Review linter setup (#3783)
|
||||
- Fix command to lint markdown in `CONTRIBUTING.md`
|
||||
- Re-activate JSDoc linting and fix linting issues
|
||||
- Refactor ESLint config to use `defineConfig` and `globalIgnores`
|
||||
- Replace `eslint-plugin-import` with `eslint-plugin-import-x`
|
||||
- Switch Stylelint config to flat format and simplify Stylelint scripts
|
||||
- [workflow] Replace Node.js version v23 with v24 (#3770)
|
||||
- [refactor] Replace deprecated constants `fs.F_OK` and `fs.R_OK` (#3789)
|
||||
- [refactor] Replace `ansis` with built-in function `util.styleText` (#3793)
|
||||
- [core] Integrate stuff from `vendor` and `fonts` folders into main `package.json`, simplifies install and maintaining dependencies (#3795, #3805)
|
||||
- [l10n] Complete translations (with the help of translation tools) (#3794)
|
||||
- [refactor] Refactored `calendarfetcherutils` in Calendar module to handle timezones better (#3806)
|
||||
- Removed as many of the date conversions as possible
|
||||
- Use `moment-timezone` when calculating recurring events, this will fix problems from the past with offsets and DST not being handled properly
|
||||
- Added some tests to test the behavior of the refactored methods to make sure the correct event dates are returned
|
||||
- [linter] Enable ESLint rule `no-console` and replace `console` with `Log` in some files (#3810)
|
||||
- [tests] Review and refactor translation tests (#3792)
|
||||
|
||||
### Fixed
|
||||
|
||||
- [fix] Handle spellcheck issues (#3783)
|
||||
- [calendar] fix fullday event rrule until with timezone offset (#3781)
|
||||
- [feat] Add rule `no-undef` in config file validation to fix #3785 (#3786)
|
||||
- [fonts] Fix `roboto.css` to avoid error message `Unknown descriptor 'var(' in @font-face rule.` in firefox console (#3787)
|
||||
- [tests] Fix and refactor e2e test `Same keys` in `translations_spec.js` (#3809)
|
||||
- [tests] Fix e2e tests newsfeed and calendar to exit without open handles (#3817)
|
||||
|
||||
### Updated
|
||||
|
||||
- [core] Update dependencies including electron to v36 (#3774, #3788, #3811, #3804, #3815, #3823)
|
||||
- [core] Update package type to `commonjs`
|
||||
- [logger] Review factory code part: use `switch/case` instead of `if/else if` (#3812)
|
||||
|
||||
## [2.31.0] - 2025-04-01
|
||||
|
||||
Thanks to: @Developer-Incoming, @eltociear, @geraki, @khassel, @KristjanESPERANTO, @MagMar94, @mixasgr, @n8many, @OWL4C, @rejas, @savvadam, @sdetweil.
|
||||
|
||||
> ⚠️ This release needs nodejs version `v22.14.0 or higher`
|
||||
|
||||
### Added
|
||||
|
||||
- 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`.
|
||||
- Add Arabic (#3719) and Esperanto translation (#3740)
|
||||
- Mark option `secondsColor` as deprecated in clock module.
|
||||
- Add Greek translation to Alerts module.
|
||||
- [newsfeed] Add specific ignoreOlderThan value (override) per feed (#3360)
|
||||
- [weather] Added option Humidity to hourly View
|
||||
- [weather] Added option to hide hourly entries that are Zero, hiding the entire column if empty.
|
||||
- [updatenotification] Added option to iterate over modules directory instead using modules defined in `config.js` (#3739)
|
||||
|
||||
### Changed
|
||||
|
||||
- [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)
|
||||
- [core] Optimize systeminformation calls and output (#3689)
|
||||
- [core] Add issue templates for feature requests and bug reports (#3695)
|
||||
- [core] Adapt `start:x11:dev` script
|
||||
- [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.
|
||||
- [weather/weatherflow] Fixed icons and added hourly support as well as UV, precipitation, and location name support.
|
||||
- [workflow] Run `sudo apt-get update` before installing packages to avoid install errors
|
||||
- [workflow] Exclude issues with label `ready (coming with next release)` from stale job
|
||||
|
||||
### Removed
|
||||
|
||||
### Updated
|
||||
|
||||
- [core] Update requirements and dependencies including electron to v35 and formatting (#3593, #3693, #3717)
|
||||
- [core] Update prettier, ESLint and simplify config
|
||||
- Update Greek translation
|
||||
|
||||
### Fixed
|
||||
|
||||
- [calendar] Fix clipping events being broadcast (#3678)
|
||||
- [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)
|
||||
- [calendar] Fix arrayed symbols, #3267, again, add testcase, add testcase for #3678
|
||||
- [weather] Fix wrong weatherCondition name in openmeteo provider which lead to n/a icon (#3691)
|
||||
- [core] Fix wrong port in log message when starting server only (#3696)
|
||||
- [calendar] Fix NewYork event processed on system in Central timezone shows wrong time #3701
|
||||
- [weather/yr] The Yr weather provider is now able to recover from bad API responses instead of freezing (#3296)
|
||||
- [compliments] Fix evening events being shown during the day (#3727)
|
||||
- [weather] Fixed minor spacing issues when using UV Index in Hourly
|
||||
- [workflow] Fix command to run spellcheck
|
||||
|
||||
## [2.30.0] - 2025-01-01
|
||||
|
||||
Thanks to: @xsorifc28, @HeikoGr, @bugsounet, @khassel, @KristjanESPERANTO, @rejas, @sdetweil.
|
||||
|
||||
> ⚠️ This release needs nodejs version `v20` or `v22 or higher`, minimum version is `v20.18.1`
|
||||
|
||||
### Added
|
||||
|
||||
- [core] Add Wayland and Windows start options to `package.json` (#3594)
|
||||
- [docs] Add step for npm publishing in release process (#3595)
|
||||
- [core] Add GitHub workflow to run spellcheck a few days before each release (#3623)
|
||||
- [core] Add test flag to `index.html` to pass to module js for test mode detection (needed by #3630)
|
||||
- [core] Add export on animation names (#3644)
|
||||
- [compliments] Add support for refreshing remote compliments file, and test cases (#3630)
|
||||
- [linter] Re-add `eslint-plugin-import`now that it supports ESLint v9 (#3586)
|
||||
- [linter] Re-activate `eslint-plugin-package-json` to lint `package.json` (#3643)
|
||||
- [linter] Add linting for markdown files (#3646)
|
||||
- [linter] Add some handy ESLint rules (#3665)
|
||||
- [calendar] Add ability to display end date for full date events, where end is not same day (showEnd=true) (#3650)
|
||||
- [core] Add text to the config.js.sample file about the locale variable (#3654, #3655)
|
||||
- [core] Add fetch timeout for all node_helpers (thru undici, forces node 20.18.1 minimum) to help on slower systems. (#3660) (3661)
|
||||
|
||||
### Changed
|
||||
|
||||
- [core] Run code style checks in workflow only once (#3648)
|
||||
- [core] Fix animations export #3644 only on server side (#3649)
|
||||
- [core] Use project URL in fallback config (#3656)
|
||||
- [core] Fix Access Denied crash writing js/positions.js (on synology nas) #3651. new message, MM starts, but no modules showing (#3652)
|
||||
- [linter] Switch to 'npx' for lint-staged in pre-commit hook (#3658)
|
||||
|
||||
### Removed
|
||||
|
||||
- [tests] Remove `node-pty` and `drivelist` from rebuilded test (#3575)
|
||||
- [deps] Remove `@eslint/js` dependency. Already installed with `eslint` in deep (#3636)
|
||||
|
||||
### Updated
|
||||
|
||||
- [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)
|
||||
- [core] Update electron dependency to v32 (test electron rebuild) and all other dependencies too (#3657)
|
||||
- [tests] All test configs have been updated to allow full external access, allowing for easier debugging (especially when running as a container)
|
||||
- [core] Run and test with node 23 (#3588)
|
||||
- [workflow] delete exception `allow-ghsas: GHSA-8hc4-vh64-cxmj` in `dep-review.yaml` (#3659)
|
||||
|
||||
### Fixed
|
||||
|
||||
- [updatenotification] Fix pm2 using detection when pm2 script is inside or outside MagicMirror root folder (#3576) (#3605) (#3626) (#3628)
|
||||
- [core] Fix loading node_helper of modules: avoid black screen, display errors and continue loading with next module (#3578)
|
||||
- [weather] Change default value for weatherEndpoint of provider openweathermap to "/onecall" (#3574)
|
||||
- [tests] Fix electron tests with mock dates, the mock on server side was missing (#3597)
|
||||
- [tests] Fix testcases with hard coded Date.now (#3597)
|
||||
- [core] Fix missing `basePath` where `location.host` is used (#3613)
|
||||
- [compliments] croner library changed filenames used in latest version (#3624)
|
||||
- [linter] Fix ESLint ignore pattern which caused that default modules not to be linted (#3632)
|
||||
- [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)
|
||||
- [calendar] Update to resolve issues #3098 #3144 #3351 #3422 #3443 #3467 #3537 related to timezone changes
|
||||
- [calendar] Fix #3267 (styles array), also fixes event with both exdate AND recurrence(and testcase)
|
||||
- [calendar] Fix showEndsOnlyWithDuration not working, #3598, applies ONLY to full day events
|
||||
- [calendar] Fix showEnd for Full Day events (#3602)
|
||||
- [tests] Suppress "module is not defined" in e2e tests (#3647)
|
||||
- [calendar] Fix #3267 (styles array, really this time!)
|
||||
- [core] Fix #3662 js/positions.js created incorrectly
|
||||
|
||||
## [2.29.0] - 2024-10-01
|
||||
|
||||
Thanks to: @bugsounet, @dkallen78, @jargordon, @khassel, @KristjanESPERANTO, @MarcLandis, @rejas, @ryan-d-williams, @sdetweil, @skpanagiotis.
|
||||
@@ -13,7 +234,7 @@ Thanks to: @bugsounet, @dkallen78, @jargordon, @khassel, @KristjanESPERANTO, @Ma
|
||||
|
||||
### Added
|
||||
|
||||
- [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)
|
||||
- [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)
|
||||
- [core] Check config at every start of MagicMirror² (#3450)
|
||||
- [core] Add spelling check (cspell): `npm run test:spelling` and handle spelling issues (#3544)
|
||||
- [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)
|
||||
@@ -39,13 +260,13 @@ Thanks to: @bugsounet, @dkallen78, @jargordon, @khassel, @KristjanESPERANTO, @Ma
|
||||
- [core] Detail optimizations in `config_check.js`
|
||||
- [core] Updated minimal needed node version in `package.json` (currently v20.9.0) (#3559) and except for v21 (no security updates) (#3561)
|
||||
- [linter] Switch to ESLint v9 and flat config and replace `eslint-plugin-unicorn` by `@eslint/js`
|
||||
- [core] fix discovering module positions twice after #3450
|
||||
- [core] Fix discovering module positions twice after #3450
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `checks` badge in README.md
|
||||
- [docs] Fixed `checks` badge in README.md
|
||||
- [weather] Fixed issue with the UK Met Office provider following a change in their API paths and header info.
|
||||
- [core] add check for node_helper loading for multiple instances of same module (#3502)
|
||||
- [core] Add check for node_helper loading for multiple instances of same module (#3502)
|
||||
- [weather] Fixed issue for respecting unit config on broadcasted notifications
|
||||
- [tests] Fixes calendar test by moving it from e2e to electron with fixed date (#3532)
|
||||
- [calendar] fixed sliceMultiDayEvents getting wrong count and displaying incorrect entries, Europe/Berlin (#3542)
|
||||
@@ -96,14 +317,14 @@ For more info, please read the following post: [A New Chapter for MagicMirror: T
|
||||
### Added
|
||||
|
||||
- Output of system information to the console for troubleshooting (#3328 and #3337), ignore errors under aarch64 (#3349)
|
||||
- [core] Add `eslint-plugin-package-json` to lint the `package.json` files (#3368)
|
||||
- [linter] Add `eslint-plugin-package-json` to lint the `package.json` files (#3368)
|
||||
- [weather] `showHumidity` config is now a string describing where to show this element. Supported values: "wind", "temp", "feelslike", "below", "none". (#3330)
|
||||
- electron-rebuild test suite for electron and 3rd party modules compatibility (#3392)
|
||||
- Create MM² icon and attach it to electron process (#3407)
|
||||
|
||||
### Updated
|
||||
|
||||
- Update updatenotification (update_helper.js): Recode with pm2 library (#3332)
|
||||
- [updatenotification] Recode update_helper.js with pm2 library (#3332)
|
||||
- Removing lodash dependency by replacing merge by spread operator (#3339)
|
||||
- Use node prefix for build-in modules (#3340)
|
||||
- Rework logging colors (#3350)
|
||||
@@ -113,12 +334,12 @@ For more info, please read the following post: [A New Chapter for MagicMirror: T
|
||||
- Update translations for estonian (#3371)
|
||||
- Update electron to v29 and update other dependencies
|
||||
- [calendar] fullDay events over several days now show the left days from the first day on and 'today' on the last day
|
||||
- Update layout of current weather indoor values
|
||||
- [weather] Update layout of current weather indoor values
|
||||
|
||||
### Fixed
|
||||
|
||||
- Correct apiBase of weathergov weatherProvider to match documentation (#2926)
|
||||
- Worked around several issues in the RRULE library that were causing deleted calender events to still show, some
|
||||
- [weather] Correct apiBase of weathergov weatherProvider to match documentation (#2926)
|
||||
- Worked around several issues in the RRULE library that were causing deleted calendar events to still show, some
|
||||
initial and recurring events to not show, and some event times to be off an hour. (#3291)
|
||||
- Skip changelog requirement when running tests for dependency updates (#3320)
|
||||
- Display precipitation probability when it is 0% instead of blank/empty (#3345)
|
||||
@@ -258,7 +479,7 @@ Special thanks to @khassel, @rejas and @sdetweil for taking over most (if not al
|
||||
- Added UV Index to hourly and current Weather, with support for Openmeteo
|
||||
- Added tests for serveronly
|
||||
- Set Timezone `Europe/Berlin` in unit tests (needed for new formatTime tests)
|
||||
- Added no-param-reassign eslint rule and fix warnings
|
||||
- [linter] Added no-param-reassign eslint rule and fix warnings
|
||||
- [updatenotification] Added `sendUpdatesNotifications` feature. Broadcast update with `UPDATES` notification to other modules
|
||||
- [updatenotification] Allow force scanning with `SCAN_UPDATES` notification from other modules
|
||||
- Added per-calendar fetchInterval
|
||||
@@ -269,7 +490,7 @@ Special thanks to @khassel, @rejas and @sdetweil for taking over most (if not al
|
||||
|
||||
### Updated
|
||||
|
||||
- Added support for precipitation probability with openmeteo weather-provider
|
||||
- [weather] Added support for precipitation probability with openmeteo weather-provider
|
||||
- Update electron to v25.2 and other dependencies
|
||||
- Use node v20 in github workflow (replacing v14)
|
||||
- Refactor formatTime into common util function for default modules
|
||||
@@ -442,7 +663,7 @@ Special thanks to the following contributors: @eouia, @khassel, @kolbyjack, @Kri
|
||||
|
||||
### Added
|
||||
|
||||
- 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).
|
||||
- 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).
|
||||
- Show endDate for calendar events when dateHeader is enabled and showEnd is set to true (#2192).
|
||||
- Added the notification emitting from the weather module on information updated.
|
||||
- Use recommended file extension for YAML files (#2864).
|
||||
@@ -523,7 +744,7 @@ Special thanks to the following contributors: @AmpioRosso, @eouia, @fewieden, @j
|
||||
### Fixed
|
||||
|
||||
- Fixed wrong file `kr.json` to `ko.json`. Use language code 'ko' instead of 'kr' for Korean language.
|
||||
- Fixed `feels_like` data from openweathermap's current weather being ignored (#2678).
|
||||
- [weather] Fixed `feels_like` data from openweathermap's current weather being ignored (#2678).
|
||||
- Fixed chaotic newsfeed display after network connection loss thanks to @jalibu (#2638).
|
||||
- Fixed incorrect time zone correction of recurring full day events (#2632 and #2634).
|
||||
- Fixed e2e tests by increasing testTimeout.
|
||||
@@ -561,7 +782,7 @@ Special thanks to the following contributors: @apiontek, @eouia, @jupadin, @khas
|
||||
- Actually test all js and css files when lint script is run.
|
||||
- Updated jsdocs and print warnings during testing too.
|
||||
- Updated weathergov provider to try fetching not just current, but also forecast, when API URLs available.
|
||||
- Refactored clock layout.
|
||||
- [clock] Refactored clock layout.
|
||||
- Refactored methods from weather-providers into weatherobject (isDaytime, updateSunTime).
|
||||
- Use of `logger.js` in jest tests.
|
||||
- Run prettier over all relevant files.
|
||||
@@ -1357,7 +1578,7 @@ A huge, huge, huge thanks to user @fewieden for all his hard work on the new `we
|
||||
### Fixed
|
||||
|
||||
- Fix instruction in README for using automatically installer script.
|
||||
- Bug of duplicated compliments as described in [here](https://forum.magicmirror.builders/topic/2381/compliments-module-stops-cycling-compliments).
|
||||
- Bug of [duplicated compliments](https://forum.magicmirror.builders/topic/2381/compliments-module-stops-cycling-compliments).
|
||||
- Fix double message about port when server is starting
|
||||
- Corrected Swedish translations for TODAY/TOMORROW/DAYAFTERTOMORROW.
|
||||
- Removed unused import from js/electron.js
|
||||
@@ -1606,3 +1827,53 @@ It includes (but is not limited to) the following features:
|
||||
### Initial release of MagicMirror
|
||||
|
||||
This 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)
|
||||
|
||||
[2.33.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.32.0...v2.33.0
|
||||
[2.32.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.31.0...v2.32.0
|
||||
[2.31.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.30.0...v2.31.0
|
||||
[2.30.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.29.0...v2.30.0
|
||||
[2.29.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.28.0...v2.29.0
|
||||
[2.28.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.27.0...v2.28.0
|
||||
[2.27.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.26.0...v2.27.0
|
||||
[2.26.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.25.0...v2.26.0
|
||||
[2.25.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.24.0...v2.25.0
|
||||
[2.24.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.23.0...v2.24.0
|
||||
[2.23.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.22.0...v2.23.0
|
||||
[2.22.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.21.0...v2.22.0
|
||||
[2.21.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.20.0...v2.21.0
|
||||
[2.20.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.19.0...v2.20.0
|
||||
[2.19.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.18.0...v2.19.0
|
||||
[2.18.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.17.1...v2.18.0
|
||||
[2.17.1]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.17.0...v2.17.1
|
||||
[2.17.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.16.0...v2.17.0
|
||||
[2.16.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.15.0...v2.16.0
|
||||
[2.15.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.14.0...v2.15.0
|
||||
[2.14.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.13.0...v2.14.0
|
||||
[2.13.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.12.0...v2.13.0
|
||||
[2.12.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.11.0...v2.12.0
|
||||
[2.11.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.10.1...v2.11.0
|
||||
[2.10.1]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.10.0...v2.10.1
|
||||
[2.10.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.9.0...v2.10.0
|
||||
[2.9.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.8.0...v2.9.0
|
||||
[2.8.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.7.1...v2.8.0
|
||||
[2.7.1]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.7.0...v2.7.1
|
||||
[2.7.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.6.0...v2.7.0
|
||||
[2.6.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.5.0...v2.6.0
|
||||
[2.5.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.4.1...v2.5.0
|
||||
[2.4.1]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.4.0...v2.4.1
|
||||
[2.4.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.3.1...v2.4.0
|
||||
[2.3.1]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.3.0...v2.3.1
|
||||
[2.3.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.2.2...v2.3.0
|
||||
[2.2.2]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.2.1...v2.2.2
|
||||
[2.2.1]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.2.0...v2.2.1
|
||||
[2.2.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.1.3...v2.2.0
|
||||
[2.1.3]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.1.2...v2.1.3
|
||||
[2.1.2]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.1.1...v2.1.2
|
||||
[2.1.1]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.1.0...v2.1.1
|
||||
[2.1.0]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.0.5...v2.1.0
|
||||
[2.0.5]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.0.4...v2.0.5
|
||||
[2.0.4]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.0.3...v2.0.4
|
||||
[2.0.3]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.0.2...v2.0.3
|
||||
[2.0.2]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.0.1...v2.0.2
|
||||
[2.0.1]: https://github.com/MagicMirrorOrg/MagicMirror/compare/v2.0.0...v2.0.1
|
||||
[2.0.0]: https://github.com/MagicMirrorOrg/MagicMirror/releases/tag/v2.0.0
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# Collaboration
|
||||
|
||||
This document describes how collaborators of this repository should work together.
|
||||
|
||||
## Pull Requests
|
||||
@@ -20,6 +22,7 @@ Are done by
|
||||
- [ ] @rejas
|
||||
- [ ] @sdetweil
|
||||
- [ ] @khassel
|
||||
- [ ] @KristjanESPERANTO
|
||||
|
||||
### Pre-Deployment steps
|
||||
|
||||
@@ -28,31 +31,41 @@ Are done by
|
||||
### Deployment steps
|
||||
|
||||
- [ ] pull latest `develop` branch
|
||||
- [ ] update `package.json` and `package-lock.json` to reflect correct version number `2.xx.0`
|
||||
- [ ] test `develop` branch
|
||||
- [ ] update `CHANGELOG.md`
|
||||
- [ ] add all contributor names: `...`
|
||||
- [ ] add min. node version: > ⚠️ This release needs nodejs version `v20` or `v22`, minimum version is `v20.9.0`
|
||||
- [ ] commit and push all changes
|
||||
- [ ] create `prep-release` branch from `develop`
|
||||
- [ ] update `package.json` and `package-lock.json` to reflect correct version number `2.xx.0`
|
||||
- [ ] test `prep-release` branch
|
||||
- [ ] commit and push all changes
|
||||
- [ ] create pull request from `prep-release` to `develop` branch with title `Prepare Release 2.xx.0`
|
||||
- [ ] after successful test run via github actions: merge pull request to `develop`
|
||||
- [ ] review the content of the automatically generated draft release named `unreleased`
|
||||
- [ ] check contributor names
|
||||
- [ ] check auto generated min. node version and adjust it for better readability if necessary
|
||||
- [ ] check if all elements are assigned to the correct category
|
||||
- [ ] change release name to `v2.xx.0`
|
||||
- [ ] after successful test run via github actions: create pull request from `develop` to `master` branch
|
||||
- [ ] add label `mastermerge`
|
||||
- [ ] title of the PR is `Release 2.xx.0`
|
||||
- [ ] description of the PR is the section of the `CHANGELOG.md`
|
||||
- [ ] description of the PR is the body of the draft release with name `v2.xx.0`
|
||||
- [ ] after PR tests run without issues, merge PR
|
||||
- [ ] create new release with
|
||||
- [ ] corresponding version tag `v2.xx.0`
|
||||
- [ ] a release name: `...`
|
||||
- [ ] description of the PR is the section of the `CHANGELOG.md`
|
||||
- [ ] edit draft release with name `v2.xx.0`
|
||||
- [ ] set corresponding version tag `v2.xx.0` (with `Select tag` and then `Create new tag`)
|
||||
- [ ] update release link in `Compare to previous Release` by replacing `develop` with new tag `v2.xx.0`
|
||||
- [ ] publish the release (button at the bottom)
|
||||
|
||||
### Draft new development release
|
||||
|
||||
- [ ] checkout `develop` branch
|
||||
- [ ] update `package.json` and `package-lock.json` to reflect correct version number `2.xx.0-develop`
|
||||
- [ ] draft new section in `CHANGELOG.md`
|
||||
- [ ] commit and publish `develop` branch
|
||||
- [ ] commit and push `develop` branch
|
||||
- [ ] if new release will be in January, update the year in LICENSE.md
|
||||
|
||||
### After release
|
||||
|
||||
- [ ] publish release notes with link to github release on forum in new locked topic
|
||||
- [ ] close all issues with label `ready (coming with next release)`
|
||||
- [ ] release new documentation by merging `develop` on `master` in documentation repository
|
||||
- [ ] publish new version on [npm](https://www.npmjs.com/package/magicmirror)
|
||||
- [ ] use a clean environment (e.g. container)
|
||||
- [ ] clone this repository with the new `master` branch and `cd` into the local repository directory
|
||||
- [ ] 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
|
||||
- [ ] execute `npm publish`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# The MIT License (MIT)
|
||||
|
||||
Copyright © 2016-2024 Michael Teeuw
|
||||
Copyright © 2016-2026 Michael Teeuw
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
|
||||
22
README.md
22
README.md
@@ -1,20 +1,22 @@
|
||||

|
||||
# 
|
||||
|
||||
<p style="text-align: center">
|
||||
<a href="https://choosealicense.com/licenses/mit">
|
||||
<img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License">
|
||||
</a>
|
||||
<img src="https://img.shields.io/github/actions/workflow/status/magicmirrororg/magicmirror/automated-tests.yaml" alt="GitHub Actions">
|
||||
<img src="https://img.shields.io/github/check-runs/magicmirrororg/magicmirror/master" alt="Build Status">
|
||||
<a href="https://github.com/MagicMirrorOrg/MagicMirror">
|
||||
<img src="https://img.shields.io/github/stars/magicmirrororg/magicmirror?style=social">
|
||||
</a>
|
||||
<img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License">
|
||||
</a>
|
||||
<img src="https://img.shields.io/github/actions/workflow/status/magicmirrororg/magicmirror/automated-tests.yaml" alt="GitHub Actions">
|
||||
<img src="https://img.shields.io/github/check-runs/magicmirrororg/magicmirror/master" alt="Build Status">
|
||||
<a href="https://github.com/MagicMirrorOrg/MagicMirror">
|
||||
<img src="https://img.shields.io/github/stars/magicmirrororg/magicmirror?style=social" alt="GitHub Stars">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
**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).
|
||||
|
||||
MagicMirror² 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!
|
||||
|
||||

|
||||
|
||||
## Documentation
|
||||
|
||||
For 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).
|
||||
@@ -24,7 +26,7 @@ For the full documentation including **[installation instructions](https://docs.
|
||||
- Website: [https://magicmirror.builders](https://magicmirror.builders)
|
||||
- Documentation: [https://docs.magicmirror.builders](https://docs.magicmirror.builders)
|
||||
- Forum: [https://forum.magicmirror.builders](https://forum.magicmirror.builders)
|
||||
- Technical discussions: https://forum.magicmirror.builders/category/11/core-system
|
||||
- Technical discussions: <https://forum.magicmirror.builders/category/11/core-system>
|
||||
- Discord: [https://discord.gg/J5BAtvx](https://discord.gg/J5BAtvx)
|
||||
- Blog: [https://michaelteeuw.nl/tagged/magicmirror](https://michaelteeuw.nl/tagged/magicmirror)
|
||||
- Donations: [https://magicmirror.builders/#donate](https://magicmirror.builders/#donate)
|
||||
@@ -49,5 +51,5 @@ If we receive enough donations we might even be able to free up some working hou
|
||||
To donate, please follow [this](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=G5D8E9MR5DTD2&source=url) link.
|
||||
|
||||
<p style="text-align: center">
|
||||
<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>
|
||||
<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>
|
||||
</p>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
});
|
||||
|
||||
// determine if "--use-tls"-flag was provided
|
||||
config["tls"] = process.argv.indexOf("--use-tls") > 0;
|
||||
config.tls = process.argv.indexOf("--use-tls") > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,6 +83,17 @@
|
||||
if (["localhost", "127.0.0.1", "::1", "::ffff:127.0.0.1", undefined].indexOf(config.address) === -1) {
|
||||
getServerConfig(`${prefix}${config.address}:${config.port}/config/`)
|
||||
.then(function (configReturn) {
|
||||
// check environment for DISPLAY or WAYLAND_DISPLAY
|
||||
const elecParams = ["js/electron.js"];
|
||||
if (process.env.WAYLAND_DISPLAY) {
|
||||
console.log(`Client: Using WAYLAND_DISPLAY=${process.env.WAYLAND_DISPLAY}`);
|
||||
elecParams.push("--enable-features=UseOzonePlatform");
|
||||
elecParams.push("--ozone-platform=wayland");
|
||||
} else if (process.env.DISPLAY) {
|
||||
console.log(`Client: Using DISPLAY=${process.env.DISPLAY}`);
|
||||
} else {
|
||||
fail("Error: Requires environment variable WAYLAND_DISPLAY or DISPLAY, none is provided.");
|
||||
}
|
||||
// Pass along the server config via an environment variable
|
||||
const env = Object.create(process.env);
|
||||
env.clientonly = true; // set to pass to electron.js
|
||||
@@ -94,7 +105,7 @@
|
||||
|
||||
// Spawn electron application
|
||||
const electron = require("electron");
|
||||
const child = require("node:child_process").spawn(electron, ["js/electron.js"], options);
|
||||
const child = require("node:child_process").spawn(electron, elecParams, options);
|
||||
|
||||
// Pipe all child process output to current stdout
|
||||
child.stdout.on("data", function (buf) {
|
||||
|
||||
@@ -28,7 +28,11 @@ let config = {
|
||||
httpsCertificate: "", // HTTPS Certificate path, only require when useHttps is true
|
||||
|
||||
language: "en",
|
||||
locale: "en-US",
|
||||
locale: "en-US", // this variable is provided as a consistent location
|
||||
// it is currently only used by 3rd party modules. no MagicMirror code uses this value
|
||||
// as we have no usage, we have no constraints on what this field holds
|
||||
// see https://en.wikipedia.org/wiki/Locale_(computer_software) for the possibilities
|
||||
|
||||
logLevel: ["INFO", "LOG", "WARN", "ERROR"], // Add "DEBUG" for even more logging
|
||||
timeFormat: 24,
|
||||
units: "metric",
|
||||
|
||||
@@ -3,69 +3,109 @@
|
||||
"language": "en",
|
||||
"words": [
|
||||
"aarch",
|
||||
"Adak",
|
||||
"Alvinger",
|
||||
"Ampio",
|
||||
"andrezibaia",
|
||||
"angeldeejay",
|
||||
"apikey",
|
||||
"apiontek",
|
||||
"armv",
|
||||
"ashishtank",
|
||||
"autoplay",
|
||||
"Autorestart",
|
||||
"beada",
|
||||
"Behaviour",
|
||||
"Binney",
|
||||
"bluemanos",
|
||||
"bnitkin",
|
||||
"bokmål",
|
||||
"bouncyflip",
|
||||
"boxspinner",
|
||||
"Brasileiro",
|
||||
"Brento",
|
||||
"browserwindow",
|
||||
"bryanzzhu",
|
||||
"btoconnor",
|
||||
"bughaver",
|
||||
"bugsounet",
|
||||
"buxxi",
|
||||
"byday",
|
||||
"calcage",
|
||||
"calendarfetcher",
|
||||
"calendarfetcherutils",
|
||||
"calendarutils",
|
||||
"calevents",
|
||||
"chamakura",
|
||||
"Citypage",
|
||||
"cjbrunner",
|
||||
"clearsky",
|
||||
"clientonly",
|
||||
"clockfaces",
|
||||
"cloudcover",
|
||||
"cmdline",
|
||||
"codac",
|
||||
"Codrops",
|
||||
"cornerexpand",
|
||||
"Crazylegstoo",
|
||||
"crazyscot",
|
||||
"Creepin",
|
||||
"currentweather",
|
||||
"CUSTOMCSS",
|
||||
"customregions",
|
||||
"cxmj",
|
||||
"Cymraeg",
|
||||
"dariom",
|
||||
"darksky",
|
||||
"dataheaders",
|
||||
"Datamart",
|
||||
"dateheader",
|
||||
"dateheaders",
|
||||
"datekey",
|
||||
"dathbe",
|
||||
"davide",
|
||||
"DAYAFTERTOMORROW",
|
||||
"DAYBEFOREYESTERDAY",
|
||||
"defaultmodules",
|
||||
"Deificit",
|
||||
"Descr",
|
||||
"dewpoint",
|
||||
"dgoth",
|
||||
"difflink",
|
||||
"dismissttl",
|
||||
"Displayer",
|
||||
"dkallen",
|
||||
"drivelist",
|
||||
"DTEND",
|
||||
"DTSTAMP",
|
||||
"DTSTART",
|
||||
"Duffman",
|
||||
"earlman",
|
||||
"easyas",
|
||||
"eddiehung",
|
||||
"Edgardos",
|
||||
"Ekristoffe",
|
||||
"elec",
|
||||
"elif",
|
||||
"eltociear",
|
||||
"endfor",
|
||||
"endmacro",
|
||||
"envcanada",
|
||||
"envsub",
|
||||
"envsubst",
|
||||
"eouia",
|
||||
"Evapotranspration",
|
||||
"exdate",
|
||||
"exdates",
|
||||
"expectedheaders",
|
||||
"exploader",
|
||||
"ezeholz",
|
||||
"Fadesteps",
|
||||
"Faizan",
|
||||
"feedme",
|
||||
"feelslike",
|
||||
"Fenner",
|
||||
"Feuchte",
|
||||
"fewieden",
|
||||
"fixuppm",
|
||||
"flopp",
|
||||
@@ -74,15 +114,30 @@
|
||||
"forecastweather",
|
||||
"fortawesome",
|
||||
"frameguard",
|
||||
"freezinglevel",
|
||||
"Frysk",
|
||||
"fullarticle",
|
||||
"fulldate",
|
||||
"fullday",
|
||||
"fullscreen",
|
||||
"geraki",
|
||||
"Gevoelstemperatuur",
|
||||
"GHSA",
|
||||
"ghsas",
|
||||
"grenagit",
|
||||
"Halfclear",
|
||||
"heavyrain",
|
||||
"heavyrainandthunder",
|
||||
"heavyrainshowers",
|
||||
"heavyrainshowersandthunder",
|
||||
"heavysleet",
|
||||
"heavysleetshowersandthunder",
|
||||
"heavysnow",
|
||||
"heavysnowandthunder",
|
||||
"Heiko",
|
||||
"Hirschberger",
|
||||
"hourlyweather",
|
||||
"humidex",
|
||||
"Hwind",
|
||||
"ical",
|
||||
"illimarkangur",
|
||||
@@ -92,12 +147,14 @@
|
||||
"jakemulley",
|
||||
"jakobsarwary",
|
||||
"jalibu",
|
||||
"jargordon",
|
||||
"jetson",
|
||||
"jkriegshauser",
|
||||
"jsdocs",
|
||||
"jsonlint",
|
||||
"jupadin",
|
||||
"kaennchenstruggle",
|
||||
"Kalenderwoche",
|
||||
"kenzal",
|
||||
"Keyport",
|
||||
"khassel",
|
||||
@@ -109,12 +166,18 @@
|
||||
"Knapoc",
|
||||
"Koepke",
|
||||
"kolbyjack",
|
||||
"Komplex",
|
||||
"krekos",
|
||||
"Kristjan",
|
||||
"krukle",
|
||||
"labwc",
|
||||
"Landis",
|
||||
"larryare",
|
||||
"Lastberechnung",
|
||||
"letsencrypt",
|
||||
"libgpiod",
|
||||
"Lightspeed",
|
||||
"loadingcircle",
|
||||
"locationforecast",
|
||||
"lockstring",
|
||||
"lstrip",
|
||||
@@ -125,12 +188,15 @@
|
||||
"martingron",
|
||||
"marvai",
|
||||
"mastermerge",
|
||||
"matchtype",
|
||||
"maxentries",
|
||||
"Meteo",
|
||||
"michaelteeuw",
|
||||
"michmich",
|
||||
"Midori",
|
||||
"mirontoli",
|
||||
"MISSINGLANG",
|
||||
"mixasgr",
|
||||
"MMPM",
|
||||
"modernizr",
|
||||
"modulename",
|
||||
@@ -139,89 +205,152 @@
|
||||
"Ñandú",
|
||||
"nathannaveen",
|
||||
"naveensrinivasan",
|
||||
"nbsp",
|
||||
"ndom",
|
||||
"Nerfzooka",
|
||||
"NEWSFEED",
|
||||
"newsfeedfetcher",
|
||||
"newsfetcher",
|
||||
"newsitems",
|
||||
"nfogal",
|
||||
"njwilliams",
|
||||
"nonrepeating",
|
||||
"Norsk",
|
||||
"nunjuck",
|
||||
"odroid",
|
||||
"oemel",
|
||||
"oldconfig",
|
||||
"onecall",
|
||||
"onevent",
|
||||
"openmeteo",
|
||||
"openmeto",
|
||||
"openweathermap",
|
||||
"oraclesean",
|
||||
"oscarb",
|
||||
"pcat",
|
||||
"philnagel",
|
||||
"pirateweather",
|
||||
"plained",
|
||||
"plebcity",
|
||||
"pmax",
|
||||
"pmean",
|
||||
"pmedian",
|
||||
"pmin",
|
||||
"Português",
|
||||
"PRECIP",
|
||||
"Problema",
|
||||
"psieg",
|
||||
"pubdate",
|
||||
"radokristof",
|
||||
"rajniszp",
|
||||
"rebuilded",
|
||||
"Reis",
|
||||
"rejas",
|
||||
"relativehumidity",
|
||||
"Resig",
|
||||
"roboto",
|
||||
"rohitdharavath",
|
||||
"Rosso",
|
||||
"Rothfusz",
|
||||
"rrule",
|
||||
"savvadam",
|
||||
"sdetweil",
|
||||
"searchstr",
|
||||
"sendheaders",
|
||||
"serveronly",
|
||||
"sexualized",
|
||||
"Sitecode",
|
||||
"skpanagiotis",
|
||||
"SMHI",
|
||||
"Snille",
|
||||
"snowandthunder",
|
||||
"snowshowersandthunder",
|
||||
"socketclient",
|
||||
"socketio",
|
||||
"spectron",
|
||||
"Starinvest",
|
||||
"stationid",
|
||||
"STEADMAN",
|
||||
"sthuber",
|
||||
"Stieber",
|
||||
"strinner",
|
||||
"stylelintrc",
|
||||
"subclassing",
|
||||
"sunaction",
|
||||
"suncalc",
|
||||
"suntimes",
|
||||
"symboltest",
|
||||
"systeminformation",
|
||||
"tada",
|
||||
"taglist",
|
||||
"Teeuw",
|
||||
"Teil",
|
||||
"TESTMODE",
|
||||
"thomasrockhu",
|
||||
"thumbslider",
|
||||
"timeformat",
|
||||
"titlereplacestr",
|
||||
"titlesearchstr",
|
||||
"todaytemp",
|
||||
"tomzt",
|
||||
"trunc",
|
||||
"ttlms",
|
||||
"ukmetoffice",
|
||||
"ukmetofficedatahub",
|
||||
"unitless",
|
||||
"unixtime",
|
||||
"unparseable",
|
||||
"updatenotification",
|
||||
"uxdt",
|
||||
"Vaice",
|
||||
"veeck",
|
||||
"verjaardag",
|
||||
"VEVENT",
|
||||
"vgtu",
|
||||
"Vitest",
|
||||
"Voelt",
|
||||
"Vorberechnung",
|
||||
"vppencilsharpener",
|
||||
"Wallys",
|
||||
"Weatherbit",
|
||||
"weathercode",
|
||||
"WEATHERDATA",
|
||||
"Weatherflow",
|
||||
"weatherforecast",
|
||||
"weathergov",
|
||||
"weathericon",
|
||||
"weathericons",
|
||||
"weatherobject",
|
||||
"weatherprovider",
|
||||
"weatherutils",
|
||||
"webcal",
|
||||
"winddirection",
|
||||
"windgusts",
|
||||
"windspeed",
|
||||
"Woolridge",
|
||||
"worktree",
|
||||
"Wsymb",
|
||||
"xlarge",
|
||||
"xmark",
|
||||
"xrandr",
|
||||
"xsmall",
|
||||
"xsorifc",
|
||||
"xwindows",
|
||||
"xxxe",
|
||||
"Ybbet",
|
||||
"yearmatch",
|
||||
"yearmatchgroup"
|
||||
],
|
||||
"ignorePaths": ["node_modules/**", "modules/**", "vendor/node_modules/**", "translations/**", "tests/mocks/**", "tests/e2e/modules/clock_es_spec.js", "fonts/roboto.css"],
|
||||
"ignorePaths": [
|
||||
"css/roboto.css",
|
||||
"node_modules/**",
|
||||
"modules/!(default)/**",
|
||||
"modules/default/**/translations/!(en).json",
|
||||
"modules/default/calendar/windowsZones.json",
|
||||
"modules/default/clock/faces/*.svg",
|
||||
"modules/default/weather/providers/yr.js",
|
||||
"tests/mocks/**",
|
||||
"tests/e2e/modules/clock_es_spec.js",
|
||||
"translations/**"
|
||||
],
|
||||
"dictionaries": ["node"]
|
||||
}
|
||||
|
||||
25
css/main.css
25
css/main.css
@@ -239,3 +239,28 @@ sup {
|
||||
border-spacing: 0;
|
||||
border-collapse: separate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container Definitions.
|
||||
*/
|
||||
|
||||
.region .container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.region .container.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.region.left .flex {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.region.center .flex {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.region.right .flex {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 100;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-100-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-100-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-100-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-100-normal.woff") format("woff");
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
@@ -14,11 +14,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 100;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-cyrillic-100-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-cyrillic-100-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-cyrillic-100-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-cyrillic-100-normal.woff") format("woff");
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
@@ -26,11 +26,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 100;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-greek-ext-100-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-greek-ext-100-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-greek-ext-100-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-greek-ext-100-normal.woff") format("woff");
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
@@ -38,11 +38,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 100;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-greek-100-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-greek-100-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-greek-100-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-greek-100-normal.woff") format("woff");
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
@@ -50,11 +50,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 100;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-vietnamese-100-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-vietnamese-100-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-vietnamese-100-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-vietnamese-100-normal.woff") format("woff");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -62,11 +62,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 100;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-latin-ext-100-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-latin-ext-100-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-latin-ext-100-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-latin-ext-100-normal.woff") format("woff");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -74,11 +74,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 100;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-latin-100-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-latin-100-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-latin-100-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-latin-100-normal.woff") format("woff");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -86,11 +86,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 300;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-300-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-300-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-300-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-300-normal.woff") format("woff");
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
@@ -98,11 +98,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 300;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-cyrillic-300-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-cyrillic-300-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-cyrillic-300-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-cyrillic-300-normal.woff") format("woff");
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
@@ -110,11 +110,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 300;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-greek-ext-300-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-greek-ext-300-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-greek-ext-300-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-greek-ext-300-normal.woff") format("woff");
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
@@ -122,11 +122,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 300;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-greek-300-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-greek-300-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-greek-300-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-greek-300-normal.woff") format("woff");
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
@@ -134,11 +134,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 300;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-vietnamese-300-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-vietnamese-300-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-vietnamese-300-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-vietnamese-300-normal.woff") format("woff");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -146,11 +146,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 300;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-latin-ext-300-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-latin-ext-300-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-latin-ext-300-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-latin-ext-300-normal.woff") format("woff");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -158,11 +158,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 300;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-latin-300-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-latin-300-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-latin-300-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-latin-300-normal.woff") format("woff");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -170,11 +170,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-400-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-400-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-400-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-400-normal.woff") format("woff");
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
@@ -182,11 +182,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-cyrillic-400-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-cyrillic-400-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-cyrillic-400-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-cyrillic-400-normal.woff") format("woff");
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
@@ -194,11 +194,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-greek-ext-400-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-greek-ext-400-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-greek-ext-400-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-greek-ext-400-normal.woff") format("woff");
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
@@ -206,11 +206,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-greek-400-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-greek-400-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-greek-400-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-greek-400-normal.woff") format("woff");
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
@@ -218,11 +218,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-vietnamese-400-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-vietnamese-400-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-vietnamese-400-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-vietnamese-400-normal.woff") format("woff");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -230,11 +230,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-latin-ext-400-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-latin-ext-400-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-latin-ext-400-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-latin-ext-400-normal.woff") format("woff");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -242,11 +242,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-latin-400-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-latin-400-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-latin-400-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-latin-400-normal.woff") format("woff");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -254,11 +254,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 500;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-500-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-500-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-500-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-500-normal.woff") format("woff");
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
@@ -266,11 +266,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 500;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-cyrillic-500-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-cyrillic-500-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-cyrillic-500-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-cyrillic-500-normal.woff") format("woff");
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
@@ -278,11 +278,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 500;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-greek-ext-500-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-greek-ext-500-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-greek-ext-500-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-greek-ext-500-normal.woff") format("woff");
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
@@ -290,11 +290,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 500;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-greek-500-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-greek-500-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-greek-500-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-greek-500-normal.woff") format("woff");
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
@@ -302,11 +302,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 500;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-vietnamese-500-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-vietnamese-500-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-vietnamese-500-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-vietnamese-500-normal.woff") format("woff");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -314,11 +314,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 500;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-latin-ext-500-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-latin-ext-500-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-latin-ext-500-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-latin-ext-500-normal.woff") format("woff");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -326,11 +326,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 500;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-latin-500-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-latin-500-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-latin-500-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-latin-500-normal.woff") format("woff");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -338,11 +338,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-700-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-700-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-700-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-cyrillic-ext-700-normal.woff") format("woff");
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
@@ -350,11 +350,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-cyrillic-700-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-cyrillic-700-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-cyrillic-700-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-cyrillic-700-normal.woff") format("woff");
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
@@ -362,11 +362,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-greek-ext-700-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-greek-ext-700-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-greek-ext-700-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-greek-ext-700-normal.woff") format("woff");
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
@@ -374,11 +374,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-greek-700-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-greek-700-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-greek-700-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-greek-700-normal.woff") format("woff");
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
@@ -386,11 +386,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-vietnamese-700-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-vietnamese-700-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-vietnamese-700-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-vietnamese-700-normal.woff") format("woff");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -398,11 +398,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-latin-ext-700-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-latin-ext-700-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-latin-ext-700-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-latin-ext-700-normal.woff") format("woff");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -410,11 +410,11 @@
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto/files/roboto-latin-700-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto/files/roboto-latin-700-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-latin-700-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto/files/roboto-latin-700-normal.woff") format("woff");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -422,11 +422,11 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 300;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-ext-300-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-ext-300-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-ext-300-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-ext-300-normal.woff") format("woff");
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
@@ -434,11 +434,11 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 300;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-300-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-300-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-300-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-300-normal.woff") format("woff");
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
@@ -446,11 +446,11 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 300;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-ext-300-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-ext-300-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-ext-300-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-ext-300-normal.woff") format("woff");
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
@@ -458,11 +458,11 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 300;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-300-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-300-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-300-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-300-normal.woff") format("woff");
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
@@ -470,11 +470,11 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 300;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-vietnamese-300-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-vietnamese-300-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-vietnamese-300-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-vietnamese-300-normal.woff") format("woff");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -482,11 +482,11 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 300;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-ext-300-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-ext-300-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-ext-300-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-ext-300-normal.woff") format("woff");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -494,11 +494,11 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 300;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-300-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-300-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-300-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-300-normal.woff") format("woff");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -506,11 +506,11 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-ext-400-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-ext-400-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-ext-400-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-ext-400-normal.woff") format("woff");
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
@@ -518,11 +518,11 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-400-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-400-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-400-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-400-normal.woff") format("woff");
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
@@ -530,11 +530,11 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-ext-400-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-ext-400-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-ext-400-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-ext-400-normal.woff") format("woff");
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
@@ -542,11 +542,11 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-400-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-400-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-400-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-400-normal.woff") format("woff");
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
@@ -554,11 +554,11 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-vietnamese-400-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-vietnamese-400-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-vietnamese-400-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-vietnamese-400-normal.woff") format("woff");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -566,11 +566,11 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-ext-400-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-ext-400-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-ext-400-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-ext-400-normal.woff") format("woff");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -578,11 +578,11 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-400-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-400-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-400-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-400-normal.woff") format("woff");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -590,11 +590,11 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-ext-700-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-ext-700-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-ext-700-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-ext-700-normal.woff") format("woff");
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
@@ -602,11 +602,11 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-700-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-700-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-700-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-cyrillic-700-normal.woff") format("woff");
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
@@ -614,11 +614,11 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-ext-700-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-ext-700-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-ext-700-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-ext-700-normal.woff") format("woff");
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
@@ -626,11 +626,11 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-700-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-700-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-700-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-greek-700-normal.woff") format("woff");
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
@@ -638,11 +638,11 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-vietnamese-700-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-vietnamese-700-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-vietnamese-700-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-vietnamese-700-normal.woff") format("woff");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -650,11 +650,11 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-ext-700-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-ext-700-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-ext-700-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-ext-700-normal.woff") format("woff");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -662,10 +662,10 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
font-style: normal;
|
||||
font-display: var(--fontsource-display, swap);
|
||||
font-display: swap;
|
||||
font-weight: 700;
|
||||
src:
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-700-normal.woff2") format("woff2"),
|
||||
url("node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-700-normal.woff") format("woff");
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-700-normal.woff2") format("woff2"),
|
||||
url("../node_modules/@fontsource/roboto-condensed/files/roboto-condensed-latin-700-normal.woff") format("woff");
|
||||
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;
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import eslintPluginJest from "eslint-plugin-jest";
|
||||
import eslintPluginJs from "@eslint/js";
|
||||
import eslintPluginStylistic from "@stylistic/eslint-plugin";
|
||||
import {defineConfig, globalIgnores} from "eslint/config";
|
||||
import globals from "globals";
|
||||
import {flatConfigs as importX} from "eslint-plugin-import-x";
|
||||
import js from "@eslint/js";
|
||||
import jsdocPlugin from "eslint-plugin-jsdoc";
|
||||
import packageJson from "eslint-plugin-package-json";
|
||||
import playwright from "eslint-plugin-playwright";
|
||||
import stylistic from "@stylistic/eslint-plugin";
|
||||
import vitest from "eslint-plugin-vitest";
|
||||
|
||||
const config = [
|
||||
eslintPluginJs.configs.recommended,
|
||||
export default defineConfig([
|
||||
globalIgnores(["config/**", "modules/**/*", "!modules/default/**", "js/positions.js"]),
|
||||
{
|
||||
files: ["**/*.js"],
|
||||
languageOptions: {
|
||||
@@ -12,7 +17,7 @@ const config = [
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
...vitest.environments.env.globals,
|
||||
Log: "readonly",
|
||||
MM: "readonly",
|
||||
Module: "readonly",
|
||||
@@ -20,13 +25,9 @@ const config = [
|
||||
moment: "readonly"
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
...eslintPluginStylistic.configs["all-flat"].plugins,
|
||||
...eslintPluginJest.configs["flat/recommended"].plugins
|
||||
},
|
||||
plugins: {js, stylistic, vitest},
|
||||
extends: [importX.recommended, vitest.configs.recommended, "js/recommended", jsdocPlugin.configs["flat/recommended"], "stylistic/all"],
|
||||
rules: {
|
||||
...eslintPluginStylistic.configs["all-flat"].rules,
|
||||
...eslintPluginJest.configs["flat/recommended"].rules,
|
||||
"@stylistic/array-element-newline": ["error", "consistent"],
|
||||
"@stylistic/arrow-parens": ["error", "always"],
|
||||
"@stylistic/brace-style": "off",
|
||||
@@ -51,16 +52,31 @@ const config = [
|
||||
"@stylistic/semi": ["error", "always"],
|
||||
"@stylistic/space-before-function-paren": ["error", "always"],
|
||||
"@stylistic/spaced-comment": "off",
|
||||
"dot-notation": "error",
|
||||
eqeqeq: "error",
|
||||
"id-length": "off",
|
||||
"import-x/extensions": "error",
|
||||
"import-x/newline-after-import": "error",
|
||||
"import-x/order": "error",
|
||||
"init-declarations": "off",
|
||||
"jest/consistent-test-it": "warn",
|
||||
"jest/no-done-callback": "warn",
|
||||
"jest/prefer-expect-resolves": "warn",
|
||||
"jest/prefer-mock-promise-shorthand": "warn",
|
||||
"jest/prefer-to-be": "warn",
|
||||
"jest/prefer-to-have-length": "warn",
|
||||
"max-lines-per-function": ["warn", 350],
|
||||
"vitest/consistent-test-it": "warn",
|
||||
"vitest/expect-expect": [
|
||||
"warn",
|
||||
{
|
||||
assertFunctionNames: [
|
||||
"expect",
|
||||
"testElementLength",
|
||||
"testTextContain",
|
||||
"doTest",
|
||||
"runAnimationTest",
|
||||
"waitForAnimationClass",
|
||||
"assertNoAnimationWithin"
|
||||
]
|
||||
}
|
||||
],
|
||||
"vitest/prefer-to-be": "warn",
|
||||
"vitest/prefer-to-have-length": "warn",
|
||||
"max-lines-per-function": ["warn", 400],
|
||||
"max-statements": "off",
|
||||
"no-global-assign": "off",
|
||||
"no-inline-comments": "off",
|
||||
@@ -71,16 +87,30 @@ const config = [
|
||||
"no-ternary": "off",
|
||||
"no-throw-literal": "error",
|
||||
"no-undefined": "off",
|
||||
"no-unneeded-ternary": "error",
|
||||
"no-unused-vars": "off",
|
||||
"no-useless-return": "error",
|
||||
"no-warning-comments": "off",
|
||||
"object-shorthand": ["error", "methods"],
|
||||
"one-var": "off",
|
||||
"prefer-destructuring": "off",
|
||||
"prefer-template": "error",
|
||||
"sort-keys": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["**/*.js"],
|
||||
ignores: [
|
||||
"clientonly/index.js",
|
||||
"js/logger.js",
|
||||
"tests/**/*.js"
|
||||
],
|
||||
rules: {"no-console": "error"}
|
||||
},
|
||||
{
|
||||
files: ["**/package.json"],
|
||||
plugins: {packageJson},
|
||||
extends: ["packageJson/recommended"]
|
||||
},
|
||||
{
|
||||
files: ["**/*.mjs"],
|
||||
languageOptions: {
|
||||
@@ -90,21 +120,19 @@ const config = [
|
||||
},
|
||||
sourceType: "module"
|
||||
},
|
||||
plugins: {
|
||||
...eslintPluginStylistic.configs["all-flat"].plugins
|
||||
},
|
||||
plugins: {js, stylistic},
|
||||
extends: [importX.recommended, "js/all", "stylistic/all"],
|
||||
rules: {
|
||||
...eslintPluginStylistic.configs["all-flat"].rules,
|
||||
"@stylistic/array-element-newline": "off",
|
||||
"@stylistic/indent": ["error", "tab"],
|
||||
"@stylistic/object-property-newline": ["error", {allowAllPropertiesOnSameLine: true}],
|
||||
"@stylistic/padded-blocks": ["error", "never"],
|
||||
"@stylistic/quote-props": ["error", "as-needed"],
|
||||
"func-style": "off",
|
||||
"import/namespace": "off",
|
||||
"import-x/no-unresolved": ["error", {ignore: ["eslint/config"]}],
|
||||
"max-lines-per-function": ["error", 100],
|
||||
"no-magic-numbers": "off",
|
||||
"one-var": "off",
|
||||
"prefer-destructuring": "off"
|
||||
"one-var": ["error", "never"],
|
||||
"sort-keys": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -114,8 +142,10 @@ const config = [
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ["config/**", "modules/**", "!modules/default/**", "js/positions.js"]
|
||||
files: ["tests/e2e/**/*.js"],
|
||||
extends: [playwright.configs["flat/recommended"]],
|
||||
rules: {
|
||||
"playwright/no-standalone-expect": "off"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export default config;
|
||||
]);
|
||||
|
||||
29
fonts/package-lock.json
generated
29
fonts/package-lock.json
generated
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"name": "magicmirror-fonts",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "magicmirror-fonts",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fontsource/roboto": "^5.1.0",
|
||||
"@fontsource/roboto-condensed": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/roboto": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.1.0.tgz",
|
||||
"integrity": "sha512-cFRRC1s6RqPygeZ8Uw/acwVHqih8Czjt6Q0MwoUoDe9U3m4dH1HmNDRBZyqlMSFwgNAUKgFImncKdmDHyKpwdg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@fontsource/roboto-condensed": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/roboto-condensed/-/roboto-condensed-5.1.0.tgz",
|
||||
"integrity": "sha512-cTS62X9bgR6H+3qRtaDwt0I+3ocitMPalyr2OrzJtilIcuEo4my8UA4VVhOgr0OI2Sk9JNrNYcSxkv0k4XuKtQ==",
|
||||
"license": "OFL-1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "magicmirror-fonts",
|
||||
"version": "1.0.0",
|
||||
"description": "Package for fonts use by MagicMirror² core.",
|
||||
"bugs": {
|
||||
"url": "https://github.com/MagicMirrorOrg/MagicMirror/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MagicMirrorOrg/MagicMirror"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fontsource/roboto": "^5.1.0",
|
||||
"@fontsource/roboto-condensed": "^5.1.0"
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,13 @@
|
||||
|
||||
<link rel="icon" href="data:;base64,iVBORw0KGgo=" />
|
||||
<link rel="stylesheet" type="text/css" href="css/main.css" />
|
||||
<link rel="stylesheet" type="text/css" href="fonts/roboto.css" />
|
||||
<link rel="stylesheet" type="text/css" href="vendor/node_modules/animate.css/animate.min.css" />
|
||||
<link rel="stylesheet" type="text/css" href="css/roboto.css" />
|
||||
<link rel="stylesheet" type="text/css" href="node_modules/animate.css/animate.min.css" />
|
||||
<!-- custom.css is loaded by the loader.js to make sure it's loaded after the module css files. -->
|
||||
|
||||
<script type="text/javascript">
|
||||
window.mmVersion = "#VERSION#";
|
||||
window.mmTestMode = "#TESTMODE#";
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -41,10 +42,10 @@
|
||||
</div>
|
||||
<div class="region fullscreen above"><div class="container"></div></div>
|
||||
<script type="text/javascript" src="socket.io/socket.io.js"></script>
|
||||
<script type="text/javascript" src="vendor/node_modules/nunjucks/browser/nunjucks.min.js"></script>
|
||||
<script type="text/javascript" src="node_modules/nunjucks/browser/nunjucks.min.js"></script>
|
||||
<script type="text/javascript" src="js/defaults.js"></script>
|
||||
<script type="text/javascript" src="#CONFIG_FILE#"></script>
|
||||
<script type="text/javascript" src="vendor/vendor.js"></script>
|
||||
<script type="text/javascript" src="js/vendor.js"></script>
|
||||
<script type="text/javascript" src="modules/default/defaultmodules.js"></script>
|
||||
<script type="text/javascript" src="modules/default/utils.js"></script>
|
||||
<script type="text/javascript" src="js/logger.js"></script>
|
||||
|
||||
@@ -1,33 +1,41 @@
|
||||
module.exports = async () => {
|
||||
return {
|
||||
verbose: true,
|
||||
testTimeout: 20000,
|
||||
testSequencer: "<rootDir>/tests/utils/test_sequencer.js",
|
||||
projects: [
|
||||
{
|
||||
displayName: "unit",
|
||||
globalSetup: "<rootDir>/tests/unit/helpers/global-setup.js",
|
||||
moduleNameMapper: {
|
||||
logger: "<rootDir>/js/logger.js"
|
||||
},
|
||||
testMatch: ["**/tests/unit/**/*.[jt]s?(x)"],
|
||||
testPathIgnorePatterns: ["<rootDir>/tests/unit/mocks", "<rootDir>/tests/unit/helpers"]
|
||||
},
|
||||
{
|
||||
displayName: "electron",
|
||||
testMatch: ["**/tests/electron/**/*.[jt]s?(x)"],
|
||||
testPathIgnorePatterns: ["<rootDir>/tests/electron/helpers"]
|
||||
},
|
||||
{
|
||||
displayName: "e2e",
|
||||
setupFilesAfterEnv: ["<rootDir>/tests/e2e/helpers/mock-console.js"],
|
||||
testMatch: ["**/tests/e2e/**/*.[jt]s?(x)"],
|
||||
modulePaths: ["<rootDir>/js/"],
|
||||
testPathIgnorePatterns: ["<rootDir>/tests/e2e/helpers", "<rootDir>/tests/e2e/mocks"]
|
||||
}
|
||||
],
|
||||
collectCoverageFrom: ["./clientonly/**/*.js", "./js/**/*.js", "./modules/default/**/*.js", "./serveronly/**/*.js"],
|
||||
coverageReporters: ["lcov", "text"],
|
||||
coverageProvider: "v8"
|
||||
};
|
||||
const aliasMapper = {
|
||||
logger: "<rootDir>/js/logger.js"
|
||||
};
|
||||
|
||||
const config = {
|
||||
verbose: true,
|
||||
testTimeout: 20000,
|
||||
testSequencer: "<rootDir>/tests/utils/test_sequencer.js",
|
||||
projects: [
|
||||
{
|
||||
displayName: "unit",
|
||||
globalSetup: "<rootDir>/tests/unit/helpers/global-setup.js",
|
||||
moduleNameMapper: aliasMapper,
|
||||
testMatch: ["**/tests/unit/**/*.[jt]s?(x)"],
|
||||
testPathIgnorePatterns: ["<rootDir>/tests/unit/mocks", "<rootDir>/tests/unit/helpers"]
|
||||
},
|
||||
{
|
||||
displayName: "electron",
|
||||
testMatch: ["**/tests/electron/**/*.[jt]s?(x)"],
|
||||
moduleNameMapper: aliasMapper,
|
||||
testPathIgnorePatterns: ["<rootDir>/tests/electron/helpers"]
|
||||
},
|
||||
{
|
||||
displayName: "e2e",
|
||||
testMatch: ["**/tests/e2e/**/*.[jt]s?(x)"],
|
||||
modulePaths: ["<rootDir>/js/"],
|
||||
moduleNameMapper: aliasMapper,
|
||||
testPathIgnorePatterns: ["<rootDir>/tests/e2e/helpers", "<rootDir>/tests/e2e/mocks"]
|
||||
}
|
||||
],
|
||||
collectCoverageFrom: [
|
||||
"<rootDir>/clientonly/**/*.js",
|
||||
"<rootDir>/js/**/*.js",
|
||||
"<rootDir>/modules/default/**/*.js",
|
||||
"<rootDir>/serveronly/**/*.js"
|
||||
],
|
||||
coverageReporters: ["lcov", "text"],
|
||||
coverageProvider: "v8"
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
|
||||
31
js/alias-resolver.js
Normal file
31
js/alias-resolver.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// Internal alias mapping for default and 3rd party modules.
|
||||
// Provides short require identifiers: "logger" and "node_helper".
|
||||
// For a future ESM migration, replace this with a public export/import surface.
|
||||
|
||||
const path = require("node:path");
|
||||
const Module = require("module");
|
||||
|
||||
const root = path.join(__dirname, "..");
|
||||
|
||||
// Keep this list minimal; do not add new aliases without architectural review.
|
||||
const ALIASES = {
|
||||
logger: "js/logger.js",
|
||||
node_helper: "js/node_helper.js"
|
||||
};
|
||||
|
||||
// Resolve to absolute paths now.
|
||||
const resolved = Object.fromEntries(
|
||||
Object.entries(ALIASES).map(([k, rel]) => [k, path.join(root, rel)])
|
||||
);
|
||||
|
||||
// Prevent multiple patching if this file is required more than once.
|
||||
if (!Module._mmAliasPatched) {
|
||||
const origResolveFilename = Module._resolveFilename;
|
||||
Module._resolveFilename = function (request, parent, isMain, options) {
|
||||
if (Object.prototype.hasOwnProperty.call(resolved, request)) {
|
||||
return resolved[request];
|
||||
}
|
||||
return origResolveFilename.call(this, request, parent, isMain, options);
|
||||
};
|
||||
Module._mmAliasPatched = true; // non-enumerable marker would be overkill here
|
||||
}
|
||||
@@ -132,7 +132,7 @@ function addAnimateCSS (element, animation, animationTime) {
|
||||
const node = document.getElementById(element);
|
||||
if (!node) {
|
||||
// don't execute animate: we don't find div
|
||||
Log.warn("addAnimateCSS: node not found for", element);
|
||||
Log.warn("node not found for adding", element);
|
||||
return;
|
||||
}
|
||||
node.style.setProperty("--animate-duration", `${animationTime}s`);
|
||||
@@ -149,9 +149,10 @@ function removeAnimateCSS (element, animation) {
|
||||
const node = document.getElementById(element);
|
||||
if (!node) {
|
||||
// don't execute animate: we don't find div
|
||||
Log.warn("removeAnimateCSS: node not found for", element);
|
||||
Log.warn("node not found for removing", element);
|
||||
return;
|
||||
}
|
||||
node.classList.remove("animate__animated", animationName);
|
||||
node.style.removeProperty("--animate-duration");
|
||||
}
|
||||
if (typeof window === "undefined") module.exports = { AnimateCSSIn, AnimateCSSOut };
|
||||
|
||||
78
js/app.js
78
js/app.js
@@ -1,25 +1,31 @@
|
||||
// Alias modules mentioned in package.js under _moduleAliases.
|
||||
require("module-alias/register");
|
||||
// Load lightweight internal alias resolver
|
||||
require("./alias-resolver");
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const envsub = require("envsub");
|
||||
const Log = require("logger");
|
||||
|
||||
// global absolute root path
|
||||
global.root_path = path.resolve(`${__dirname}/../`);
|
||||
|
||||
const Server = require(`${__dirname}/server`);
|
||||
const Utils = require(`${__dirname}/utils`);
|
||||
const defaultModules = require(`${__dirname}/../modules/default/defaultmodules`);
|
||||
const { getEnvVarsAsObj } = require(`${__dirname}/server_functions`);
|
||||
|
||||
const defaultModules = require(`${global.root_path}/modules/default/defaultmodules`);
|
||||
// used to control fetch timeout for node_helpers
|
||||
const { setGlobalDispatcher, Agent } = require("undici");
|
||||
const { getEnvVarsAsObj, getConfigFilePath } = require("#server_functions");
|
||||
// common timeout value, provide environment override in case
|
||||
const fetch_timeout = process.env.mmFetchTimeout !== undefined ? process.env.mmFetchTimeout : 30000;
|
||||
|
||||
// Get version number.
|
||||
global.version = require(`${__dirname}/../package.json`).version;
|
||||
global.version = require(`${global.root_path}/package.json`).version;
|
||||
global.mmTestMode = process.env.mmTestMode === "true";
|
||||
Log.log(`Starting MagicMirror: v${global.version}`);
|
||||
|
||||
// Log system information.
|
||||
Utils.logSystemInformation();
|
||||
|
||||
// global absolute root path
|
||||
global.root_path = path.resolve(`${__dirname}/../`);
|
||||
Utils.logSystemInformation(global.version);
|
||||
|
||||
if (process.env.MM_CONFIG_FILE) {
|
||||
global.configuration_file = process.env.MM_CONFIG_FILE.replace(`${global.root_path}/`, "");
|
||||
@@ -59,19 +65,19 @@ function App () {
|
||||
async function loadConfig () {
|
||||
Log.log("Loading config ...");
|
||||
const defaults = require(`${__dirname}/defaults`);
|
||||
if (process.env.JEST_WORKER_ID !== undefined) {
|
||||
// if we are running with jest
|
||||
if (global.mmTestMode) {
|
||||
// if we are running in test mode
|
||||
defaults.address = "0.0.0.0";
|
||||
}
|
||||
|
||||
// For this check proposed to TestSuite
|
||||
// https://forum.magicmirror.builders/topic/1456/test-suite-for-magicmirror/8
|
||||
const configFilename = path.resolve(global.configuration_file || `${global.root_path}/config/config.js`);
|
||||
const configFilename = getConfigFilePath();
|
||||
let templateFile = `${configFilename}.template`;
|
||||
|
||||
// check if templateFile exists
|
||||
try {
|
||||
fs.accessSync(templateFile, fs.F_OK);
|
||||
fs.accessSync(templateFile, fs.constants.F_OK);
|
||||
} catch (err) {
|
||||
templateFile = null;
|
||||
Log.log("config template file not exists, no envsubst");
|
||||
@@ -120,7 +126,7 @@ function App () {
|
||||
require(`${global.root_path}/js/check_config.js`);
|
||||
|
||||
try {
|
||||
fs.accessSync(configFilename, fs.F_OK);
|
||||
fs.accessSync(configFilename, fs.constants.F_OK);
|
||||
const c = require(configFilename);
|
||||
if (Object.keys(c).length === 0) {
|
||||
Log.error("WARNING! Config file appears empty, maybe missing module.exports last line?");
|
||||
@@ -147,11 +153,23 @@ function App () {
|
||||
*/
|
||||
function checkDeprecatedOptions (userConfig) {
|
||||
const deprecated = require(`${global.root_path}/js/deprecated`);
|
||||
const deprecatedOptions = deprecated.configs;
|
||||
|
||||
// check for deprecated core options
|
||||
const deprecatedOptions = deprecated.configs;
|
||||
const usedDeprecated = deprecatedOptions.filter((option) => userConfig.hasOwnProperty(option));
|
||||
if (usedDeprecated.length > 0) {
|
||||
Log.warn(`WARNING! Your config is using deprecated options: ${usedDeprecated.join(", ")}. Check README and CHANGELOG for more up-to-date ways of getting the same functionality.`);
|
||||
Log.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.`);
|
||||
}
|
||||
|
||||
// check for deprecated module options
|
||||
for (const element of userConfig.modules) {
|
||||
if (deprecated[element.module] !== undefined && element.config !== undefined) {
|
||||
const deprecatedModuleOptions = deprecated[element.module];
|
||||
const usedDeprecatedModuleOptions = deprecatedModuleOptions.filter((option) => element.config.hasOwnProperty(option));
|
||||
if (usedDeprecatedModuleOptions.length > 0) {
|
||||
Log.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.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,24 +181,24 @@ function App () {
|
||||
const elements = module.split("/");
|
||||
const moduleName = elements[elements.length - 1];
|
||||
const env = getEnvVarsAsObj();
|
||||
let moduleFolder = `${__dirname}/../${env.modulesDir}/${module}`;
|
||||
let moduleFolder = path.resolve(`${global.root_path}/${env.modulesDir}`, module);
|
||||
|
||||
if (defaultModules.includes(moduleName)) {
|
||||
const defaultModuleFolder = `${__dirname}/../modules/default/${module}`;
|
||||
if (process.env.JEST_WORKER_ID === undefined) {
|
||||
const defaultModuleFolder = path.resolve(`${global.root_path}/modules/default/`, module);
|
||||
if (!global.mmTestMode) {
|
||||
moduleFolder = defaultModuleFolder;
|
||||
} else {
|
||||
// running in Jest, allow defaultModules placed under moduleDir for testing
|
||||
if (env.modulesDir === "modules") {
|
||||
// running in test mode, allow defaultModules placed under moduleDir for testing
|
||||
if (env.modulesDir === "modules" || env.modulesDir === "tests/mocks") {
|
||||
moduleFolder = defaultModuleFolder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const moduleFile = `${moduleFolder}/${module}.js`;
|
||||
const moduleFile = `${moduleFolder}/${moduleName}.js`;
|
||||
|
||||
try {
|
||||
fs.accessSync(moduleFile, fs.R_OK);
|
||||
fs.accessSync(moduleFile, fs.constants.R_OK);
|
||||
} catch (e) {
|
||||
Log.warn(`No ${moduleFile} found for module: ${moduleName}.`);
|
||||
}
|
||||
@@ -189,7 +207,7 @@ function App () {
|
||||
|
||||
let loadHelper = true;
|
||||
try {
|
||||
fs.accessSync(helperPath, fs.R_OK);
|
||||
fs.accessSync(helperPath, fs.constants.R_OK);
|
||||
} catch (e) {
|
||||
loadHelper = false;
|
||||
Log.log(`No helper found for module: ${moduleName}.`);
|
||||
@@ -197,7 +215,13 @@ function App () {
|
||||
|
||||
// if the helper was found
|
||||
if (loadHelper) {
|
||||
const Module = require(helperPath);
|
||||
let Module;
|
||||
try {
|
||||
Module = require(helperPath);
|
||||
} catch (e) {
|
||||
Log.error(`Error when loading ${moduleName}:`, e.message);
|
||||
return;
|
||||
}
|
||||
let m = new Module();
|
||||
|
||||
if (m.requiresVersion) {
|
||||
@@ -288,6 +312,8 @@ function App () {
|
||||
}
|
||||
}
|
||||
|
||||
setGlobalDispatcher(new Agent({ connect: { timeout: fetch_timeout } }));
|
||||
|
||||
await loadModules(modules);
|
||||
|
||||
httpServer = new Server(config);
|
||||
@@ -338,7 +364,7 @@ function App () {
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error(`Error when stopping node_helper for module ${nodeHelper.name}:`);
|
||||
console.error(error);
|
||||
Log.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
// Ensure internal require aliases (e.g., "logger") resolve when this file is run as a standalone script
|
||||
require("./alias-resolver");
|
||||
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const { styleText } = require("node:util");
|
||||
const Ajv = require("ajv");
|
||||
const colors = require("ansis");
|
||||
const globals = require("globals");
|
||||
const { Linter } = require("eslint");
|
||||
const Log = require("logger");
|
||||
|
||||
const rootPath = path.resolve(`${__dirname}/../`);
|
||||
const Log = require(`${rootPath}/js/logger.js`);
|
||||
const Utils = require(`${rootPath}/js/utils.js`);
|
||||
|
||||
const linter = new Linter({ configType: "flat" });
|
||||
@@ -28,16 +31,18 @@ function getConfigFile () {
|
||||
function checkConfigFile () {
|
||||
const configFileName = getConfigFile();
|
||||
|
||||
// Check if file is present
|
||||
if (fs.existsSync(configFileName) === false) {
|
||||
throw new Error(`File not found: ${configFileName}\nNo config file present!`);
|
||||
}
|
||||
|
||||
// Check permission
|
||||
// Check if file exists and is accessible
|
||||
try {
|
||||
fs.accessSync(configFileName, fs.F_OK);
|
||||
fs.accessSync(configFileName, fs.constants.R_OK);
|
||||
} catch (error) {
|
||||
throw new Error(`${error}\nNo permission to access config file!`);
|
||||
if (error.code === "ENOENT") {
|
||||
Log.error(`File not found: ${configFileName}`);
|
||||
} else if (error.code === "EACCES") {
|
||||
Log.error(`No permission to read config file: ${configFileName}`);
|
||||
} else {
|
||||
Log.error(`Cannot access config file: ${configFileName}\n${error.message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate syntax of the configuration file.
|
||||
@@ -52,15 +57,20 @@ function checkConfigFile () {
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
"no-sparse-arrays": "error",
|
||||
"no-undef": "error"
|
||||
}
|
||||
},
|
||||
configFileName
|
||||
);
|
||||
|
||||
if (errors.length === 0) {
|
||||
Log.info(colors.green("Your configuration file doesn't contain syntax errors :)"));
|
||||
Log.info(styleText("green", "Your configuration file doesn't contain syntax errors :)"));
|
||||
validateModulePositions(configFileName);
|
||||
} else {
|
||||
let errorMessage = "Your configuration file contains syntax errors :(";
|
||||
@@ -68,10 +78,15 @@ function checkConfigFile () {
|
||||
for (const error of errors) {
|
||||
errorMessage += `\nLine ${error.line} column ${error.column}: ${error.message}`;
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
Log.error(errorMessage);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} configFileName - The path and filename of the configuration file to validate.
|
||||
*/
|
||||
function validateModulePositions (configFileName) {
|
||||
Log.info("Checking modules structure configuration ...");
|
||||
|
||||
@@ -91,8 +106,7 @@ function validateModulePositions (configFileName) {
|
||||
type: "string"
|
||||
},
|
||||
position: {
|
||||
type: "string",
|
||||
enum: positionList
|
||||
type: "string"
|
||||
}
|
||||
},
|
||||
required: ["module"]
|
||||
@@ -107,7 +121,17 @@ function validateModulePositions (configFileName) {
|
||||
|
||||
const valid = validate(data);
|
||||
if (valid) {
|
||||
Log.info(colors.green("Your modules structure configuration doesn't contain errors :)"));
|
||||
Log.info(styleText("green", "Your modules structure configuration doesn't contain errors :)"));
|
||||
|
||||
// Check for unknown positions (warning only, not an error)
|
||||
if (data.modules) {
|
||||
for (const [index, module] of data.modules.entries()) {
|
||||
if (module.position && !positionList.includes(module.position)) {
|
||||
Log.warn(`Module ${index} ("${module.module}") uses unknown position: "${module.position}"`);
|
||||
Log.warn(`Known positions are: ${positionList.join(", ")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const module = validate.errors[0].instancePath.split("/")[2];
|
||||
const position = validate.errors[0].instancePath.split("/")[3];
|
||||
@@ -120,12 +144,14 @@ function validateModulePositions (configFileName) {
|
||||
errorMessage += validate.errors[0].message;
|
||||
}
|
||||
Log.error(errorMessage);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
checkConfigFile();
|
||||
} catch (error) {
|
||||
Log.error(error.message);
|
||||
const message = error && error.message ? error.message : error;
|
||||
Log.error(`Unexpected error: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ const defaults = {
|
||||
position: "middle_center",
|
||||
classes: "xsmall",
|
||||
config: {
|
||||
text: "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>npm run config:check</pre>"
|
||||
text: "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>"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -70,7 +70,7 @@ const defaults = {
|
||||
position: "bottom_bar",
|
||||
classes: "xsmall dimmed",
|
||||
config: {
|
||||
text: "www.michaelteeuw.nl"
|
||||
text: "https://magicmirror.builders/"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
module.exports = {
|
||||
configs: ["kioskmode"]
|
||||
configs: ["kioskmode"],
|
||||
clock: ["secondsColor"]
|
||||
};
|
||||
|
||||
@@ -76,6 +76,23 @@ function createWindow () {
|
||||
|
||||
const electronOptions = Object.assign({}, electronOptionsDefaults, config.electronOptions);
|
||||
|
||||
if (process.env.MOCK_DATE !== undefined) {
|
||||
// if we are running tests and we want to mock the current date
|
||||
const fakeNow = new Date(process.env.MOCK_DATE).valueOf();
|
||||
Date = class extends Date {
|
||||
constructor (...args) {
|
||||
if (args.length === 0) {
|
||||
super(fakeNow);
|
||||
} else {
|
||||
super(...args);
|
||||
}
|
||||
}
|
||||
};
|
||||
const __DateNowOffset = fakeNow - Date.now();
|
||||
const __DateNow = Date.now;
|
||||
Date.now = () => __DateNow() + __DateNowOffset;
|
||||
}
|
||||
|
||||
// Create the browser window.
|
||||
mainWindow = new BrowserWindow(electronOptions);
|
||||
|
||||
@@ -85,7 +102,7 @@ function createWindow () {
|
||||
*/
|
||||
|
||||
let prefix;
|
||||
if ((config["tls"] !== null && config["tls"]) || config.useHttps) {
|
||||
if ((config.tls !== null && config.tls) || config.useHttps) {
|
||||
prefix = "https://";
|
||||
} else {
|
||||
prefix = "http://";
|
||||
@@ -95,10 +112,10 @@ function createWindow () {
|
||||
const port = process.env.MM_PORT || config.port;
|
||||
mainWindow.loadURL(`${prefix}${address}:${port}`);
|
||||
|
||||
// Open the DevTools if run with "npm start dev"
|
||||
// Open the DevTools if run with "node --run start:dev"
|
||||
if (process.argv.includes("dev")) {
|
||||
if (process.env.JEST_WORKER_ID !== undefined) {
|
||||
// if we are running with jest
|
||||
if (process.env.mmTestMode) {
|
||||
// if we are running tests
|
||||
const devtools = new BrowserWindow(electronOptions);
|
||||
mainWindow.webContents.setDevToolsWebContents(devtools.webContents);
|
||||
}
|
||||
@@ -134,11 +151,11 @@ function createWindow () {
|
||||
//remove response headers that prevent sites of being embedded into iframes if configured
|
||||
mainWindow.webContents.session.webRequest.onHeadersReceived((details, callback) => {
|
||||
let curHeaders = details.responseHeaders;
|
||||
if (config["ignoreXOriginHeader"] || false) {
|
||||
if (config.ignoreXOriginHeader || false) {
|
||||
curHeaders = Object.fromEntries(Object.entries(curHeaders).filter((header) => !(/x-frame-options/i).test(header[0])));
|
||||
}
|
||||
|
||||
if (config["ignoreContentSecurityPolicy"] || false) {
|
||||
if (config.ignoreContentSecurityPolicy || false) {
|
||||
curHeaders = Object.fromEntries(Object.entries(curHeaders).filter((header) => !(/content-security-policy/i).test(header[0])));
|
||||
}
|
||||
|
||||
@@ -152,8 +169,8 @@ function createWindow () {
|
||||
|
||||
// Quit when all windows are closed.
|
||||
app.on("window-all-closed", function () {
|
||||
if (process.env.JEST_WORKER_ID !== undefined) {
|
||||
// if we are running with jest
|
||||
if (process.env.mmTestMode) {
|
||||
// if we are running tests
|
||||
app.quit();
|
||||
} else {
|
||||
createWindow();
|
||||
|
||||
63
js/ip_access_control.js
Normal file
63
js/ip_access_control.js
Normal file
@@ -0,0 +1,63 @@
|
||||
const ipaddr = require("ipaddr.js");
|
||||
const Log = require("logger");
|
||||
|
||||
/**
|
||||
* Checks if a client IP matches any entry in the whitelist
|
||||
* @param {string} clientIp - The IP address to check
|
||||
* @param {string[]} whitelist - Array of IP addresses or CIDR ranges
|
||||
* @returns {boolean} True if IP is allowed
|
||||
*/
|
||||
function isAllowed (clientIp, whitelist) {
|
||||
try {
|
||||
const addr = ipaddr.process(clientIp);
|
||||
|
||||
return whitelist.some((entry) => {
|
||||
try {
|
||||
// CIDR notation
|
||||
if (entry.includes("/")) {
|
||||
const [rangeAddr, prefixLen] = ipaddr.parseCIDR(entry);
|
||||
return addr.match(rangeAddr, prefixLen);
|
||||
}
|
||||
|
||||
// Single IP address - let ipaddr.process normalize both
|
||||
const allowedAddr = ipaddr.process(entry);
|
||||
return addr.toString() === allowedAddr.toString();
|
||||
} catch (err) {
|
||||
Log.warn(`Invalid whitelist entry: ${entry}`);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
Log.warn(`Failed to parse client IP: ${clientIp}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an Express middleware for IP whitelisting
|
||||
* @param {string[]} whitelist - Array of allowed IP addresses or CIDR ranges
|
||||
* @returns {import("express").RequestHandler} Express middleware function
|
||||
*/
|
||||
function ipAccessControl (whitelist) {
|
||||
// Empty whitelist means allow all
|
||||
if (!Array.isArray(whitelist) || whitelist.length === 0) {
|
||||
return function (req, res, next) {
|
||||
res.header("Access-Control-Allow-Origin", "*");
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
return function (req, res, next) {
|
||||
const clientIp = req.ip || req.socket.remoteAddress;
|
||||
|
||||
if (isAllowed(clientIp, whitelist)) {
|
||||
res.header("Access-Control-Allow-Origin", "*");
|
||||
next();
|
||||
} else {
|
||||
Log.log(`IP ${clientIp} is not allowed to access the mirror`);
|
||||
res.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.");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { ipAccessControl };
|
||||
67
js/loader.js
67
js/loader.js
@@ -10,13 +10,36 @@ const Loader = (function () {
|
||||
|
||||
/* Private Methods */
|
||||
|
||||
/**
|
||||
* Get environment variables from config.
|
||||
* @returns {object} Env vars with modulesDir and customCss paths from config.
|
||||
*/
|
||||
const getEnvVarsFromConfig = function () {
|
||||
return {
|
||||
modulesDir: config.foreignModulesDir || "modules",
|
||||
customCss: config.customCss || "css/custom.css"
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve object of env variables.
|
||||
* @returns {object} with key: values as assembled in js/server_functions.js
|
||||
*/
|
||||
const getEnvVars = async function () {
|
||||
const res = await fetch(`${location.protocol}//${location.host}/env`);
|
||||
return JSON.parse(await res.text());
|
||||
// In test mode, skip server fetch and use config values directly
|
||||
if (typeof process !== "undefined" && process.env && process.env.mmTestMode === "true") {
|
||||
return getEnvVarsFromConfig();
|
||||
}
|
||||
|
||||
// In production, fetch env vars from server
|
||||
try {
|
||||
const res = await fetch(new URL("env", `${location.origin}${config.basePath}`));
|
||||
return JSON.parse(await res.text());
|
||||
} catch (error) {
|
||||
// Fallback to config values if server fetch fails
|
||||
Log.error("Unable to retrieve env configuration", error);
|
||||
return getEnvVarsFromConfig();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -84,7 +107,7 @@ const Loader = (function () {
|
||||
if (window.name !== "jsdom") {
|
||||
moduleFolder = defaultModuleFolder;
|
||||
} else {
|
||||
// running in Jest, allow defaultModules placed under moduleDir for testing
|
||||
// running in test mode, allow defaultModules placed under moduleDir for testing
|
||||
if (envVars.modulesDir === "modules") {
|
||||
moduleFolder = defaultModuleFolder;
|
||||
}
|
||||
@@ -108,7 +131,8 @@ const Loader = (function () {
|
||||
header: moduleData.header,
|
||||
configDeepMerge: typeof moduleData.configDeepMerge === "boolean" ? moduleData.configDeepMerge : false,
|
||||
config: moduleData.config,
|
||||
classes: typeof moduleData.classes !== "undefined" ? `${moduleData.classes} ${module}` : module
|
||||
classes: typeof moduleData.classes !== "undefined" ? `${moduleData.classes} ${module}` : module,
|
||||
order: (typeof moduleData.order === "number" && Number.isInteger(moduleData.order)) ? moduleData.order : 0
|
||||
});
|
||||
});
|
||||
|
||||
@@ -217,29 +241,22 @@ const Loader = (function () {
|
||||
* Load all modules as defined in the config.
|
||||
*/
|
||||
async loadModules () {
|
||||
let moduleData = await getModuleData();
|
||||
const moduleData = await getModuleData();
|
||||
const envVars = await getEnvVars();
|
||||
const customCss = envVars.customCss;
|
||||
|
||||
/**
|
||||
* @returns {Promise<void>} when all modules are loaded
|
||||
*/
|
||||
const loadNextModule = async function () {
|
||||
if (moduleData.length > 0) {
|
||||
const nextModule = moduleData[0];
|
||||
await loadModule(nextModule);
|
||||
moduleData = moduleData.slice(1);
|
||||
await loadNextModule();
|
||||
} else {
|
||||
// All modules loaded. Load custom.css
|
||||
// This is done after all the modules so we can
|
||||
// overwrite all the defined styles.
|
||||
await loadFile(customCss);
|
||||
// custom.css loaded. Start all modules.
|
||||
await startModules();
|
||||
}
|
||||
};
|
||||
await loadNextModule();
|
||||
// Load all modules
|
||||
for (const module of moduleData) {
|
||||
await loadModule(module);
|
||||
}
|
||||
|
||||
// Load custom.css
|
||||
// Since this happens after loading the modules,
|
||||
// it overwrites the default styles.
|
||||
await loadFile(customCss);
|
||||
|
||||
// Start all modules.
|
||||
await startModules();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -266,7 +283,7 @@ const Loader = (function () {
|
||||
// This file is available in the vendor folder.
|
||||
// Load it from this vendor folder.
|
||||
loadedFiles.push(fileName.toLowerCase());
|
||||
return loadFile(`vendor/${vendor[fileName]}`);
|
||||
return loadFile(`${vendor[fileName]}`);
|
||||
}
|
||||
|
||||
// File not loaded yet.
|
||||
|
||||
71
js/logger.js
71
js/logger.js
@@ -1,36 +1,67 @@
|
||||
// This logger is very simple, but needs to be extended.
|
||||
(function (root, factory) {
|
||||
if (typeof exports === "object") {
|
||||
if (process.env.JEST_WORKER_ID === undefined) {
|
||||
const colors = require("ansis");
|
||||
if (process.env.mmTestMode !== "true") {
|
||||
const { styleText } = require("node:util");
|
||||
|
||||
// add timestamps in front of log messages
|
||||
require("console-stamp")(console, {
|
||||
format: ":date(yyyy-mm-dd HH:MM:ss.l) :label(7) :msg",
|
||||
format: ":date(yyyy-mm-dd HH:MM:ss.l) :label(7) :pre() :msg",
|
||||
tokens: {
|
||||
pre: () => {
|
||||
const err = new Error();
|
||||
Error.prepareStackTrace = (_, stack) => stack;
|
||||
const stack = err.stack;
|
||||
Error.prepareStackTrace = undefined;
|
||||
try {
|
||||
for (const line of stack) {
|
||||
const file = line.getFileName();
|
||||
if (file && !file.includes("node:") && !file.includes("js/logger.js") && !file.includes("node_modules")) {
|
||||
const filename = file.replace(/.*\/(.*).js/, "$1");
|
||||
const filepath = file.replace(/.*\/(.*)\/.*.js/, "$1");
|
||||
if (filepath === "js") {
|
||||
return styleText("grey", `[${filename}]`);
|
||||
} else {
|
||||
return styleText("grey", `[${filepath}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return styleText("grey", "[unknown]");
|
||||
}
|
||||
},
|
||||
label: (arg) => {
|
||||
const { method, defaultTokens } = arg;
|
||||
let label = defaultTokens.label(arg);
|
||||
if (method === "error") {
|
||||
label = colors.red(label);
|
||||
} else if (method === "warn") {
|
||||
label = colors.yellow(label);
|
||||
} else if (method === "debug") {
|
||||
label = colors.bgBlue(label);
|
||||
} else if (method === "info") {
|
||||
label = colors.blue(label);
|
||||
switch (method) {
|
||||
case "error":
|
||||
label = styleText("red", label);
|
||||
break;
|
||||
case "warn":
|
||||
label = styleText("yellow", label);
|
||||
break;
|
||||
case "debug":
|
||||
label = styleText("bgBlue", label);
|
||||
break;
|
||||
case "info":
|
||||
label = styleText("blue", label);
|
||||
break;
|
||||
}
|
||||
return label;
|
||||
},
|
||||
msg: (arg) => {
|
||||
const { method, defaultTokens } = arg;
|
||||
let msg = defaultTokens.msg(arg);
|
||||
if (method === "error") {
|
||||
msg = colors.red(msg);
|
||||
} else if (method === "warn") {
|
||||
msg = colors.yellow(msg);
|
||||
} else if (method === "info") {
|
||||
msg = colors.blue(msg);
|
||||
switch (method) {
|
||||
case "error":
|
||||
msg = styleText("red", msg);
|
||||
break;
|
||||
case "warn":
|
||||
msg = styleText("yellow", msg);
|
||||
break;
|
||||
case "info":
|
||||
msg = styleText("blue", msg);
|
||||
break;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
@@ -47,8 +78,8 @@
|
||||
let logLevel;
|
||||
let enableLog;
|
||||
if (typeof exports === "object") {
|
||||
// in nodejs and not running with jest
|
||||
enableLog = process.env.JEST_WORKER_ID === undefined;
|
||||
// in nodejs and not running in test mode
|
||||
enableLog = process.env.mmTestMode !== "true";
|
||||
} else {
|
||||
// in browser and not running with jsdom
|
||||
enableLog = typeof window === "object" && window.name !== "jsdom";
|
||||
@@ -66,7 +97,7 @@
|
||||
groupEnd: Function.prototype.bind.call(console.groupEnd, console),
|
||||
time: Function.prototype.bind.call(console.time, console),
|
||||
timeEnd: Function.prototype.bind.call(console.timeEnd, console),
|
||||
timeStamp: Function.prototype.bind.call(console.timeStamp, console)
|
||||
timeStamp: console.timeStamp ? Function.prototype.bind.call(console.timeStamp, console) : function () {}
|
||||
};
|
||||
|
||||
logLevel.setLogLevel = function (newLevel) {
|
||||
|
||||
38
js/main.js
38
js/main.js
@@ -1,4 +1,4 @@
|
||||
/* global Loader, defaults, Translator, addAnimateCSS, removeAnimateCSS, AnimateCSSIn, AnimateCSSOut, modulePositions */
|
||||
/* global Loader, defaults, Translator, addAnimateCSS, removeAnimateCSS, AnimateCSSIn, AnimateCSSOut, modulePositions, io */
|
||||
|
||||
const MM = (function () {
|
||||
let modules = [];
|
||||
@@ -30,6 +30,8 @@ const MM = (function () {
|
||||
dom.className = `module ${dom.className} ${module.data.classes}`;
|
||||
}
|
||||
|
||||
dom.style.order = (typeof module.data.order === "number" && Number.isInteger(module.data.order)) ? module.data.order : 0;
|
||||
|
||||
dom.opacity = 0;
|
||||
wrapper.appendChild(dom);
|
||||
|
||||
@@ -88,7 +90,7 @@ const MM = (function () {
|
||||
/**
|
||||
* Send a notification to all modules.
|
||||
* @param {string} notification The identifier of the notification.
|
||||
* @param {*} payload The payload of the notification.
|
||||
* @param {object} payload The payload of the notification.
|
||||
* @param {Module} sender The module that sent the notification.
|
||||
* @param {Module} [sendTo] The (optional) module to send the notification to.
|
||||
*/
|
||||
@@ -260,13 +262,12 @@ const MM = (function () {
|
||||
* Hide the module.
|
||||
* @param {Module} module The module to hide.
|
||||
* @param {number} speed The speed of the hide animation.
|
||||
* @param {Function} callback Called when the animation is done.
|
||||
* @param {Promise} callback Called when the animation is done.
|
||||
* @param {object} [options] Optional settings for the hide method.
|
||||
*/
|
||||
const hideModule = function (module, speed, callback, options = {}) {
|
||||
// set lockString if set in options.
|
||||
if (options.lockString) {
|
||||
// Log.log("Has lockstring: " + options.lockString);
|
||||
if (module.lockStrings.indexOf(options.lockString) === -1) {
|
||||
module.lockStrings.push(options.lockString);
|
||||
}
|
||||
@@ -345,7 +346,7 @@ const MM = (function () {
|
||||
* Show the module.
|
||||
* @param {Module} module The module to show.
|
||||
* @param {number} speed The speed of the show animation.
|
||||
* @param {Function} callback Called when the animation is done.
|
||||
* @param {Promise} callback Called when the animation is done.
|
||||
* @param {object} [options] Optional settings for the show method.
|
||||
*/
|
||||
const showModule = function (module, speed, callback, options = {}) {
|
||||
@@ -463,7 +464,8 @@ const MM = (function () {
|
||||
}
|
||||
});
|
||||
|
||||
wrapper.style.display = showWrapper ? "block" : "none";
|
||||
// move container definitions to main CSS
|
||||
wrapper.className = showWrapper ? "container" : "container hidden";
|
||||
});
|
||||
};
|
||||
|
||||
@@ -549,7 +551,7 @@ const MM = (function () {
|
||||
|
||||
/**
|
||||
* Walks thru a collection of modules and executes the callback with the module as an argument.
|
||||
* @param {Function} callback The function to execute with the module as an argument.
|
||||
* @param {module} callback The function to execute with the module as an argument.
|
||||
*/
|
||||
const enumerate = function (callback) {
|
||||
modules.map(function (module) {
|
||||
@@ -603,18 +605,30 @@ const MM = (function () {
|
||||
|
||||
createDomObjects();
|
||||
|
||||
// Setup global socket listener for RELOAD event (watch mode)
|
||||
if (typeof io !== "undefined") {
|
||||
const socket = io("/", {
|
||||
path: `${config.basePath || "/"}socket.io`
|
||||
});
|
||||
|
||||
socket.on("RELOAD", () => {
|
||||
Log.warn("Reload notification received from server");
|
||||
window.location.reload(true);
|
||||
});
|
||||
}
|
||||
|
||||
if (config.reloadAfterServerRestart) {
|
||||
setInterval(async () => {
|
||||
// if server startup time has changed (which means server was restarted)
|
||||
// the client reloads the mm page
|
||||
try {
|
||||
const res = await fetch(`${location.protocol}//${location.host}/startup`);
|
||||
const res = await fetch(`${location.protocol}//${location.host}${config.basePath}startup`);
|
||||
const curr = await res.text();
|
||||
if (startUp === "") startUp = curr;
|
||||
if (startUp !== curr) {
|
||||
startUp = "";
|
||||
window.location.reload(true);
|
||||
console.warn("Refreshing Website because server was restarted");
|
||||
Log.warn("Refreshing Website because server was restarted");
|
||||
}
|
||||
} catch (err) {
|
||||
Log.error(`MagicMirror not reachable: ${err}`);
|
||||
@@ -626,7 +640,7 @@ const MM = (function () {
|
||||
/**
|
||||
* Send a notification to all modules.
|
||||
* @param {string} notification The identifier of the notification.
|
||||
* @param {*} payload The payload of the notification.
|
||||
* @param {object} payload The payload of the notification.
|
||||
* @param {Module} sender The module that sent the notification.
|
||||
*/
|
||||
sendNotification (notification, payload, sender) {
|
||||
@@ -685,7 +699,7 @@ const MM = (function () {
|
||||
* Hide the module.
|
||||
* @param {Module} module The module to hide.
|
||||
* @param {number} speed The speed of the hide animation.
|
||||
* @param {Function} callback Called when the animation is done.
|
||||
* @param {Promise} callback Called when the animation is done.
|
||||
* @param {object} [options] Optional settings for the hide method.
|
||||
*/
|
||||
hideModule (module, speed, callback, options) {
|
||||
@@ -697,7 +711,7 @@ const MM = (function () {
|
||||
* Show the module.
|
||||
* @param {Module} module The module to show.
|
||||
* @param {number} speed The speed of the show animation.
|
||||
* @param {Function} callback Called when the animation is done.
|
||||
* @param {Promise} callback Called when the animation is done.
|
||||
* @param {object} [options] Optional settings for the show method.
|
||||
*/
|
||||
showModule (module, speed, callback, options) {
|
||||
|
||||
45
js/module.js
45
js/module.js
@@ -7,9 +7,9 @@
|
||||
const Module = Class.extend({
|
||||
|
||||
/**
|
||||
********************************************************
|
||||
* All methods (and properties) below can be subclassed. *
|
||||
********************************************************
|
||||
*********************************************************
|
||||
* All methods (and properties) below can be overridden. *
|
||||
*********************************************************
|
||||
*/
|
||||
|
||||
// Set the minimum MagicMirror² module version for this module.
|
||||
@@ -38,7 +38,6 @@ const Module = Class.extend({
|
||||
* Called when the module is instantiated.
|
||||
*/
|
||||
init () {
|
||||
//Log.log(this.defaults);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -68,7 +67,7 @@ const Module = Class.extend({
|
||||
* Returns a map of translation files the module requires to be loaded.
|
||||
*
|
||||
* return Map<String, String> -
|
||||
* @returns {*} A map with langKeys and filenames.
|
||||
* @returns {Map} A map with langKeys and filenames.
|
||||
*/
|
||||
getTranslations () {
|
||||
return false;
|
||||
@@ -76,8 +75,8 @@ const Module = Class.extend({
|
||||
|
||||
/**
|
||||
* Generates the dom which needs to be displayed. This method is called by the MagicMirror² core.
|
||||
* This method can to be subclassed if the module wants to display info on the mirror.
|
||||
* Alternatively, the getTemplate method could be subclassed.
|
||||
* This method can to be overridden if the module wants to display info on the mirror.
|
||||
* Alternatively, the getTemplate method could be overridden.
|
||||
* @returns {HTMLElement|Promise} The dom or a promise with the dom to display.
|
||||
*/
|
||||
getDom () {
|
||||
@@ -110,7 +109,7 @@ const Module = Class.extend({
|
||||
/**
|
||||
* Generates the header string which needs to be displayed if a user has a header configured for this module.
|
||||
* This method is called by the MagicMirror² core, but only if the user has configured a default header for the module.
|
||||
* This method needs to be subclassed if the module wants to display modified headers on the mirror.
|
||||
* This method needs to be overridden if the module wants to display modified headers on the mirror.
|
||||
* @returns {string} The header to display above the header.
|
||||
*/
|
||||
getHeader () {
|
||||
@@ -119,8 +118,8 @@ const Module = Class.extend({
|
||||
|
||||
/**
|
||||
* Returns the template for the module which is used by the default getDom implementation.
|
||||
* This method needs to be subclassed if the module wants to use a template.
|
||||
* It can either return a template sting, or a template filename.
|
||||
* This method needs to be overridden if the module wants to use a template.
|
||||
* It can either return a template string, or a template filename.
|
||||
* If the string ends with '.html' it's considered a file from within the module's folder.
|
||||
* @returns {string} The template string of filename.
|
||||
*/
|
||||
@@ -130,7 +129,7 @@ const Module = Class.extend({
|
||||
|
||||
/**
|
||||
* Returns the data to be used in the template.
|
||||
* This method needs to be subclassed if the module wants to use a custom data.
|
||||
* This method needs to be overridden if the module wants to use a custom data.
|
||||
* @returns {object} The data for the template
|
||||
*/
|
||||
getTemplateData () {
|
||||
@@ -140,14 +139,14 @@ const Module = Class.extend({
|
||||
/**
|
||||
* Called by the MagicMirror² core when a notification arrives.
|
||||
* @param {string} notification The identifier of the notification.
|
||||
* @param {*} payload The payload of the notification.
|
||||
* @param {object} payload The payload of the notification.
|
||||
* @param {Module} sender The module that sent the notification.
|
||||
*/
|
||||
notificationReceived (notification, payload, sender) {
|
||||
if (sender) {
|
||||
// Log.log(this.name + " received a module notification: " + notification + " from sender: " + sender.name);
|
||||
Log.debug(`${this.name} received a module notification: ${notification} from sender: ${sender.name}`);
|
||||
} else {
|
||||
// Log.log(this.name + " received a system notification: " + notification);
|
||||
Log.debug(`${this.name} received a system notification: ${notification}`);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -176,7 +175,7 @@ const Module = Class.extend({
|
||||
/**
|
||||
* Called when a socket notification arrives.
|
||||
* @param {string} notification The identifier of the notification.
|
||||
* @param {*} payload The payload of the notification.
|
||||
* @param {object} payload The payload of the notification.
|
||||
*/
|
||||
socketNotificationReceived (notification, payload) {
|
||||
Log.log(`${this.name} received a socket notification: ${notification} - Payload: ${payload}`);
|
||||
@@ -197,9 +196,9 @@ const Module = Class.extend({
|
||||
},
|
||||
|
||||
/**
|
||||
********************************************
|
||||
* The methods below don't need subclassing. *
|
||||
********************************************
|
||||
***********************************************
|
||||
* The methods below should not be overridden. *
|
||||
***********************************************
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -344,7 +343,7 @@ const Module = Class.extend({
|
||||
/**
|
||||
* Send a notification to all modules.
|
||||
* @param {string} notification The identifier of the notification.
|
||||
* @param {*} payload The payload of the notification.
|
||||
* @param {object} payload The payload of the notification.
|
||||
*/
|
||||
sendNotification (notification, payload) {
|
||||
MM.sendNotification(notification, payload, this);
|
||||
@@ -353,7 +352,7 @@ const Module = Class.extend({
|
||||
/**
|
||||
* Send a socket notification to the node helper.
|
||||
* @param {string} notification The identifier of the notification.
|
||||
* @param {*} payload The payload of the notification.
|
||||
* @param {object} payload The payload of the notification.
|
||||
*/
|
||||
sendSocketNotification (notification, payload) {
|
||||
this.socket().sendNotification(notification, payload);
|
||||
@@ -362,7 +361,7 @@ const Module = Class.extend({
|
||||
/**
|
||||
* Hide this module.
|
||||
* @param {number} speed The speed of the hide animation.
|
||||
* @param {Function} callback Called when the animation is done.
|
||||
* @param {Promise} callback Called when the animation is done.
|
||||
* @param {object} [options] Optional settings for the hide method.
|
||||
*/
|
||||
hide (speed, callback, options = {}) {
|
||||
@@ -389,7 +388,7 @@ const Module = Class.extend({
|
||||
/**
|
||||
* Show this module.
|
||||
* @param {number} speed The speed of the show animation.
|
||||
* @param {Function} callback Called when the animation is done.
|
||||
* @param {Promise} callback Called when the animation is done.
|
||||
* @param {object} [options] Optional settings for the show method.
|
||||
*/
|
||||
show (speed, callback, options) {
|
||||
@@ -415,7 +414,7 @@ const Module = Class.extend({
|
||||
});
|
||||
|
||||
/**
|
||||
* Merging MagicMirror² (or other) default/config script by @bugsounet
|
||||
* Merging MagicMirror² (or other) default/config script by `@bugsounet`
|
||||
* Merge 2 objects or/with array
|
||||
*
|
||||
* Usage:
|
||||
|
||||
18
js/module_functions.js
Normal file
18
js/module_functions.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Schedule the timer for the next update
|
||||
* @param {object} timer The timer of the module
|
||||
* @param {bigint} intervalMS interval in milliseconds
|
||||
* @param {Promise} callback function to call when the timer expires
|
||||
*/
|
||||
const scheduleTimer = function (timer, intervalMS, callback) {
|
||||
if (process.env.mmTestMode !== "true") {
|
||||
// only set timer when not running in test mode
|
||||
let tmr = timer;
|
||||
clearTimeout(tmr);
|
||||
tmr = setTimeout(function () {
|
||||
callback();
|
||||
}, intervalMS);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { scheduleTimer };
|
||||
@@ -27,7 +27,7 @@ const NodeHelper = Class.extend({
|
||||
/**
|
||||
* This method is called when a socket notification arrives.
|
||||
* @param {string} notification The identifier of the notification.
|
||||
* @param {*} payload The payload of the notification.
|
||||
* @param {object} payload The payload of the notification.
|
||||
*/
|
||||
socketNotificationReceived (notification, payload) {
|
||||
Log.log(`${this.name} received a socket notification: ${notification} - Payload: ${payload}`);
|
||||
@@ -113,8 +113,11 @@ NodeHelper.checkFetchError = function (error) {
|
||||
let error_type = "MODULE_ERROR_UNSPECIFIED";
|
||||
if (error.code === "EAI_AGAIN") {
|
||||
error_type = "MODULE_ERROR_NO_CONNECTION";
|
||||
} else if (error.message === "Unauthorized") {
|
||||
error_type = "MODULE_ERROR_UNAUTHORIZED";
|
||||
} else {
|
||||
const message = typeof error.message === "string" ? error.message.toLowerCase() : "";
|
||||
if (message.includes("unauthorized") || message.includes("http 401") || message.includes("http 403")) {
|
||||
error_type = "MODULE_ERROR_UNAUTHORIZED";
|
||||
}
|
||||
}
|
||||
return error_type;
|
||||
};
|
||||
|
||||
198
js/releasenotes.js
Normal file
198
js/releasenotes.js
Normal file
@@ -0,0 +1,198 @@
|
||||
/* eslint no-console: "off" */
|
||||
const util = require("node:util");
|
||||
const exec = util.promisify(require("node:child_process").exec);
|
||||
const fs = require("node:fs");
|
||||
|
||||
const createReleaseNotes = async () => {
|
||||
let repoName = "MagicMirrorOrg/MagicMirror";
|
||||
if (process.env.GITHUB_REPOSITORY) {
|
||||
repoName = process.env.GITHUB_REPOSITORY;
|
||||
}
|
||||
const baseUrl = `https://api.github.com/repos/${repoName}`;
|
||||
|
||||
const getOptions = (type) => {
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
return { method: `${type}`, headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } };
|
||||
} else {
|
||||
return { method: `${type}` };
|
||||
}
|
||||
};
|
||||
|
||||
const execShell = async (command) => {
|
||||
const { stdout = "", stderr = "" } = await exec(command);
|
||||
if (stderr) console.error(`Error in execShell executing command ${command}: ${stderr}`);
|
||||
return stdout;
|
||||
};
|
||||
|
||||
// Check Draft Release
|
||||
const draftReleases = [];
|
||||
const jsonReleases = await fetch(`${baseUrl}/releases`, getOptions("GET")).then((res) => res.json());
|
||||
for (const rel of jsonReleases) {
|
||||
if (rel.draft && rel.tag_name === "" && rel.published_at === null && rel.name === "unreleased") draftReleases.push(rel);
|
||||
}
|
||||
|
||||
let draftReleaseId = 0;
|
||||
if (draftReleases.length > 1) {
|
||||
throw new Error("More than one draft release found, exiting.");
|
||||
} else {
|
||||
if (draftReleases[0]) draftReleaseId = draftReleases[0].id;
|
||||
}
|
||||
|
||||
// Get last Git Tag
|
||||
const gitTag = await execShell("git describe --tags `git rev-list --tags --max-count=1`");
|
||||
const lastTag = gitTag.toString().replaceAll("\n", "");
|
||||
console.info(`latest tag is ${lastTag}`);
|
||||
|
||||
// Get Git Commits
|
||||
const gitOut = await execShell(`git log develop --pretty=format:"%H --- %s" --after="$(git log -1 --format=%aI ${lastTag})"`);
|
||||
console.info(gitOut);
|
||||
const commits = gitOut.toString().split("\n");
|
||||
|
||||
// Get Node engine version from package.json
|
||||
const nodeVersion = JSON.parse(fs.readFileSync("package.json")).engines.node;
|
||||
|
||||
// Search strings
|
||||
const labelArr = ["alert", "calendar", "clock", "compliments", "helloworld", "newsfeed", "updatenotification", "weather", "envcanada", "openmeteo", "openweathermap", "smhi", "ukmetoffice", "yr", "eslint", "bump", "dependencies", "deps", "logg", "translation", "test", "ci"];
|
||||
|
||||
// Map search strings to categories
|
||||
const getFirstLabel = (text) => {
|
||||
let res;
|
||||
labelArr.every((item) => {
|
||||
const labelIncl = text.includes(item);
|
||||
if (labelIncl) {
|
||||
switch (item) {
|
||||
case "ci":
|
||||
case "test":
|
||||
res = "testing";
|
||||
break;
|
||||
case "logg":
|
||||
res = "logging";
|
||||
break;
|
||||
case "eslint":
|
||||
case "bump":
|
||||
case "deps":
|
||||
res = "dependencies";
|
||||
break;
|
||||
case "envcanada":
|
||||
case "openmeteo":
|
||||
case "openweathermap":
|
||||
case "smhi":
|
||||
case "ukmetoffice":
|
||||
case "yr":
|
||||
case "weather":
|
||||
res = "modules/weather";
|
||||
break;
|
||||
case "alert":
|
||||
res = "modules/alert";
|
||||
break;
|
||||
case "calendar":
|
||||
res = "modules/calendar";
|
||||
break;
|
||||
case "clock":
|
||||
res = "modules/clock";
|
||||
break;
|
||||
case "compliments":
|
||||
res = "modules/compliments";
|
||||
break;
|
||||
case "helloworld":
|
||||
res = "modules/helloworld";
|
||||
break;
|
||||
case "newsfeed":
|
||||
res = "modules/newsfeed";
|
||||
break;
|
||||
case "updatenotification":
|
||||
res = "modules/updatenotification";
|
||||
break;
|
||||
default:
|
||||
res = item;
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
if (!res) res = "core";
|
||||
return res;
|
||||
};
|
||||
|
||||
const grouped = {};
|
||||
const contrib = [];
|
||||
const sha = [];
|
||||
|
||||
// Loop through each Commit
|
||||
for (const item of commits) {
|
||||
|
||||
const cm = item.trim();
|
||||
// ignore `prepare release` line
|
||||
if (cm.length > 0 && !cm.match(/^.* --- prepare .*-develop$/gi)) {
|
||||
|
||||
const [ref, title] = cm.split(" --- ");
|
||||
|
||||
const groupTitle = getFirstLabel(title.toLowerCase());
|
||||
|
||||
if (!grouped[groupTitle]) {
|
||||
grouped[groupTitle] = [];
|
||||
}
|
||||
|
||||
grouped[groupTitle].push(`- ${title}`);
|
||||
|
||||
sha.push(ref);
|
||||
}
|
||||
}
|
||||
|
||||
// function to remove duplicates
|
||||
const sortedArr = (arr) => {
|
||||
return arr.filter((item,
|
||||
index) => (arr.indexOf(item) === index && item !== "@dependabot[bot]")).sort(function (a, b) {
|
||||
return a.toLowerCase().localeCompare(b.toLowerCase());
|
||||
});
|
||||
};
|
||||
|
||||
// Get Contributors logins
|
||||
for (const ref of sha) {
|
||||
const jsonRes = await fetch(`${baseUrl}/commits/${ref}`, getOptions("GET")).then((res) => res.json());
|
||||
|
||||
if (jsonRes && jsonRes.author && jsonRes.author.login) contrib.push(`@${jsonRes.author.login}`);
|
||||
}
|
||||
|
||||
// Build Markdown content
|
||||
let markdown = "## Release Notes\n";
|
||||
|
||||
markdown += `Thanks to: ${sortedArr(contrib).join(", ")}\n`;
|
||||
markdown += `> ⚠️ This release needs nodejs version ${nodeVersion}\n`;
|
||||
markdown += "\n";
|
||||
markdown += `[Compare to previous Release ${lastTag}](https://github.com/${repoName}/compare/${lastTag}...develop)\n\n`;
|
||||
|
||||
const sorted = Object.keys(grouped)
|
||||
.sort() // Sort the keys alphabetically
|
||||
.reduce((obj, key) => {
|
||||
obj[key] = grouped[key]; // Rebuild the object with sorted keys
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
for (const group in sorted) {
|
||||
markdown += `\n### [${group}]\n`;
|
||||
markdown += `${sorted[group].join("\n")}\n`;
|
||||
}
|
||||
|
||||
console.info(markdown);
|
||||
|
||||
// Create Github Release
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
if (draftReleaseId > 0) {
|
||||
// delete release
|
||||
await fetch(`${baseUrl}/releases/${draftReleaseId}`, getOptions("DELETE"));
|
||||
console.info(`Old Release with id ${draftReleaseId} deleted.`);
|
||||
}
|
||||
|
||||
const relContent = getOptions("POST");
|
||||
relContent.body = JSON.stringify(
|
||||
{ tag_name: "", name: "unreleased", body: `${markdown}`, draft: true }
|
||||
);
|
||||
const createRelease = await fetch(`${baseUrl}/releases`, relContent).then((res) => res.json());
|
||||
console.info(`New release created with id ${createRelease.id}, GitHub-Url: ${createRelease.html_url}`);
|
||||
}
|
||||
};
|
||||
|
||||
createReleaseNotes();
|
||||
65
js/server.js
65
js/server.js
@@ -3,12 +3,14 @@ const http = require("node:http");
|
||||
const https = require("node:https");
|
||||
const path = require("node:path");
|
||||
const express = require("express");
|
||||
const ipfilter = require("express-ipfilter").IpFilter;
|
||||
const helmet = require("helmet");
|
||||
const socketio = require("socket.io");
|
||||
|
||||
const Log = require("logger");
|
||||
const { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars } = require("./server_functions");
|
||||
const { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars } = require("#server_functions");
|
||||
|
||||
const { ipAccessControl } = require(`${__dirname}/ip_access_control`);
|
||||
|
||||
const vendor = require(`${__dirname}/vendor`);
|
||||
|
||||
/**
|
||||
* Server
|
||||
@@ -41,7 +43,9 @@ function Server (config) {
|
||||
origin: /.*$/,
|
||||
credentials: true
|
||||
},
|
||||
allowEIO3: true
|
||||
allowEIO3: true,
|
||||
pingInterval: 120000, // server → client ping every 2 mins
|
||||
pingTimeout: 120000 // wait up to 2 mins for client pong
|
||||
});
|
||||
|
||||
server.on("connection", (socket) => {
|
||||
@@ -52,32 +56,46 @@ function Server (config) {
|
||||
});
|
||||
|
||||
Log.log(`Starting server on port ${port} ... `);
|
||||
|
||||
// Add explicit error handling BEFORE calling listen so we can give user-friendly feedback
|
||||
server.once("error", (err) => {
|
||||
if (err && err.code === "EADDRINUSE") {
|
||||
const bindAddr = config.address || "localhost";
|
||||
const portInUseMessage = [
|
||||
"",
|
||||
"────────────────────────────────────────────────────────────────",
|
||||
` PORT IN USE: ${bindAddr}:${port}`,
|
||||
"",
|
||||
" Another process (most likely another MagicMirror instance)",
|
||||
" is already using this port.",
|
||||
"",
|
||||
" Stop the other process (free the port) or use a different port.",
|
||||
"────────────────────────────────────────────────────────────────"
|
||||
].join("\n");
|
||||
Log.error(portInUseMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.error("Failed to start server:", err);
|
||||
});
|
||||
|
||||
server.listen(port, config.address || "localhost");
|
||||
|
||||
if (config.ipWhitelist instanceof Array && config.ipWhitelist.length === 0) {
|
||||
Log.warn("You're using a full whitelist configuration to allow for all IPs");
|
||||
}
|
||||
|
||||
app.use(function (req, res, next) {
|
||||
ipfilter(config.ipWhitelist, { mode: config.ipWhitelist.length === 0 ? "deny" : "allow", log: false })(req, res, function (err) {
|
||||
if (err === undefined) {
|
||||
res.header("Access-Control-Allow-Origin", "*");
|
||||
return next();
|
||||
}
|
||||
Log.log(err.message);
|
||||
res.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.");
|
||||
});
|
||||
});
|
||||
|
||||
app.use(ipAccessControl(config.ipWhitelist));
|
||||
app.use(helmet(config.httpHeaders));
|
||||
app.use("/js", express.static(__dirname));
|
||||
|
||||
let directories = ["/config", "/css", "/fonts", "/modules", "/vendor", "/translations"];
|
||||
if (process.env.JEST_WORKER_ID !== undefined) {
|
||||
// add tests directories only when running tests
|
||||
directories.push("/tests/configs", "/tests/mocks");
|
||||
let directories = ["/config", "/css", "/modules", "/node_modules/animate.css", "/node_modules/@fontsource", "/node_modules/@fortawesome", "/translations", "/tests/configs", "/tests/mocks"];
|
||||
for (const [key, value] of Object.entries(vendor)) {
|
||||
const dirArr = value.split("/");
|
||||
if (dirArr[0] === "node_modules") directories.push(`/${dirArr[0]}/${dirArr[1]}`);
|
||||
}
|
||||
for (const directory of directories) {
|
||||
const uniqDirs = [...new Set(directories)];
|
||||
for (const directory of uniqDirs) {
|
||||
app.use(directory, express.static(path.resolve(global.root_path + directory)));
|
||||
}
|
||||
|
||||
@@ -93,6 +111,13 @@ function Server (config) {
|
||||
|
||||
app.get("/", (req, res) => getHtml(req, res));
|
||||
|
||||
// Reload endpoint for watch mode - triggers browser reload
|
||||
app.get("/reload", (req, res) => {
|
||||
Log.info("Reload request received, notifying all clients");
|
||||
io.emit("RELOAD");
|
||||
res.status(200).send("OK");
|
||||
});
|
||||
|
||||
server.on("listening", () => {
|
||||
resolve({
|
||||
app,
|
||||
|
||||
@@ -30,6 +30,7 @@ function getStartup (req, res) {
|
||||
* Only the url-param of the input request url is required. It must be the last parameter.
|
||||
* @param {Request} req - the request
|
||||
* @param {Response} res - the result
|
||||
* @returns {Promise<void>} A promise that resolves when the response is sent
|
||||
*/
|
||||
async function cors (req, res) {
|
||||
try {
|
||||
@@ -40,26 +41,32 @@ async function cors (req, res) {
|
||||
if (!match) {
|
||||
url = `invalid url: ${req.url}`;
|
||||
Log.error(url);
|
||||
res.send(url);
|
||||
return res.status(400).send(url);
|
||||
} else {
|
||||
url = match[1];
|
||||
|
||||
const headersToSend = getHeadersToSend(req.url);
|
||||
const expectedReceivedHeaders = geExpectedReceivedHeaders(req.url);
|
||||
|
||||
Log.log(`cors url: ${url}`);
|
||||
const response = await fetch(url, { headers: headersToSend });
|
||||
|
||||
for (const header of expectedReceivedHeaders) {
|
||||
const headerValue = response.headers.get(header);
|
||||
if (header) res.set(header, headerValue);
|
||||
const response = await fetch(url, { headers: headersToSend });
|
||||
if (response.ok) {
|
||||
for (const header of expectedReceivedHeaders) {
|
||||
const headerValue = response.headers.get(header);
|
||||
if (header) res.set(header, headerValue);
|
||||
}
|
||||
const data = await response.text();
|
||||
res.send(data);
|
||||
} else {
|
||||
throw new Error(`Response status: ${response.status}`);
|
||||
}
|
||||
const data = await response.text();
|
||||
res.send(data);
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error(error);
|
||||
res.send(error);
|
||||
// Only log errors in non-test environments to keep test output clean
|
||||
if (process.env.mmTestMode !== "true") {
|
||||
Log.error(`Error in CORS request: ${error}`);
|
||||
}
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +76,7 @@ async function cors (req, res) {
|
||||
* @returns {object} An object specifying name and value of the headers.
|
||||
*/
|
||||
function getHeadersToSend (url) {
|
||||
const headersToSend = { "User-Agent": `Mozilla/5.0 MagicMirror/${global.version}` };
|
||||
const headersToSend = { "User-Agent": getUserAgent() };
|
||||
const headersToSendMatch = new RegExp("sendheaders=(.+?)(&|$)", "g").exec(url);
|
||||
if (headersToSendMatch) {
|
||||
const headers = headersToSendMatch[1].split(",");
|
||||
@@ -109,6 +116,7 @@ function geExpectedReceivedHeaders (url) {
|
||||
function getHtml (req, res) {
|
||||
let html = fs.readFileSync(path.resolve(`${global.root_path}/index.html`), { encoding: "utf8" });
|
||||
html = html.replace("#VERSION#", global.version);
|
||||
html = html.replace("#TESTMODE#", global.mmTestMode);
|
||||
|
||||
let configFile = "config/config.js";
|
||||
if (typeof global.configuration_file !== "undefined") {
|
||||
@@ -128,6 +136,27 @@ function getVersion (req, res) {
|
||||
res.send(global.version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the preferred `User-Agent`
|
||||
* @returns {string} `User-Agent` to be used
|
||||
*/
|
||||
function getUserAgent () {
|
||||
const defaultUserAgent = `Mozilla/5.0 (Node.js ${Number(process.version.match(/^v(\d+\.\d+)/)[1])}) MagicMirror/${global.version}`;
|
||||
|
||||
if (typeof config === "undefined") {
|
||||
return defaultUserAgent;
|
||||
}
|
||||
|
||||
switch (typeof config.userAgent) {
|
||||
case "function":
|
||||
return config.userAgent();
|
||||
case "string":
|
||||
return config.userAgent;
|
||||
default:
|
||||
return defaultUserAgent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets environment variables needed in the browser.
|
||||
* @returns {object} environment variables key: values
|
||||
@@ -154,4 +183,22 @@ function getEnvVars (req, res) {
|
||||
res.send(obj);
|
||||
}
|
||||
|
||||
module.exports = { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars, getEnvVarsAsObj };
|
||||
/**
|
||||
* Get the config file path from environment or default location
|
||||
* @returns {string} The absolute config file path
|
||||
*/
|
||||
function getConfigFilePath () {
|
||||
// Ensure root_path is set (for standalone contexts like watcher)
|
||||
if (!global.root_path) {
|
||||
global.root_path = path.resolve(`${__dirname}/../`);
|
||||
}
|
||||
|
||||
// Check environment variable if global not set
|
||||
if (!global.configuration_file && process.env.MM_CONFIG_FILE) {
|
||||
global.configuration_file = process.env.MM_CONFIG_FILE;
|
||||
}
|
||||
|
||||
return path.resolve(global.configuration_file || `${global.root_path}/config/config.js`);
|
||||
}
|
||||
|
||||
module.exports = { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars, getEnvVarsAsObj, getUserAgent, getConfigFilePath };
|
||||
|
||||
@@ -13,7 +13,9 @@ const MMSocket = function (moduleName) {
|
||||
base = config.basePath;
|
||||
}
|
||||
this.socket = io(`/${this.moduleName}`, {
|
||||
path: `${base}socket.io`
|
||||
path: `${base}socket.io`,
|
||||
pingInterval: 120000, // send pings every 2 mins
|
||||
pingTimeout: 120000 // wait up to 2 mins for a pong
|
||||
});
|
||||
|
||||
let notificationCallback = function () {};
|
||||
|
||||
@@ -3,30 +3,24 @@
|
||||
const Translator = (function () {
|
||||
|
||||
/**
|
||||
* Load a JSON file via XHR.
|
||||
* Load a JSON file via fetch.
|
||||
* @param {string} file Path of the file we want to load.
|
||||
* @returns {Promise<object>} the translations in the specified file
|
||||
*/
|
||||
async function loadJSON (file) {
|
||||
const xhr = new XMLHttpRequest();
|
||||
return new Promise(function (resolve) {
|
||||
xhr.overrideMimeType("application/json");
|
||||
xhr.open("GET", file, true);
|
||||
xhr.onreadystatechange = function () {
|
||||
if (xhr.readyState === 4 && xhr.status === 200) {
|
||||
// needs error handler try/catch at least
|
||||
let fileInfo = null;
|
||||
try {
|
||||
fileInfo = JSON.parse(xhr.responseText);
|
||||
} catch (exception) {
|
||||
// nothing here, but don't die
|
||||
Log.error(` loading json file =${file} failed`);
|
||||
}
|
||||
resolve(fileInfo);
|
||||
}
|
||||
};
|
||||
xhr.send(null);
|
||||
});
|
||||
const baseHref = document.baseURI;
|
||||
const url = new URL(file, baseHref);
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unexpected response status: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (exception) {
|
||||
Log.error(`Loading json file =${file} failed`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -67,22 +61,18 @@ const Translator = (function () {
|
||||
}
|
||||
|
||||
if (this.translations[module.name] && key in this.translations[module.name]) {
|
||||
// Log.log("Got translation for " + key + " from module translation: ");
|
||||
return createStringFromTemplate(this.translations[module.name][key], variables);
|
||||
}
|
||||
|
||||
if (key in this.coreTranslations) {
|
||||
// Log.log("Got translation for " + key + " from core translation.");
|
||||
return createStringFromTemplate(this.coreTranslations[key], variables);
|
||||
}
|
||||
|
||||
if (this.translationsFallback[module.name] && key in this.translationsFallback[module.name]) {
|
||||
// Log.log("Got translation for " + key + " from module translation fallback.");
|
||||
return createStringFromTemplate(this.translationsFallback[module.name][key], variables);
|
||||
}
|
||||
|
||||
if (key in this.coreTranslationsFallback) {
|
||||
// Log.log("Got translation for " + key + " from core translation fallback.");
|
||||
return createStringFromTemplate(this.coreTranslationsFallback[key], variables);
|
||||
}
|
||||
|
||||
@@ -96,7 +86,7 @@ const Translator = (function () {
|
||||
* @param {boolean} isFallback Flag to indicate fallback translations.
|
||||
*/
|
||||
async load (module, file, isFallback) {
|
||||
Log.log(`${module.name} - Load translation${isFallback ? " fallback" : ""}: ${file}`);
|
||||
Log.log(`[translator] ${module.name} - Load translation${isFallback ? " fallback" : ""}: ${file}`);
|
||||
|
||||
if (this.translationsFallback[module.name]) {
|
||||
return;
|
||||
@@ -113,10 +103,10 @@ const Translator = (function () {
|
||||
*/
|
||||
async loadCoreTranslations (lang) {
|
||||
if (lang in translations) {
|
||||
Log.log(`Loading core translation file: ${translations[lang]}`);
|
||||
Log.log(`[translator] Loading core translation file: ${translations[lang]}`);
|
||||
this.coreTranslations = await loadJSON(translations[lang]);
|
||||
} else {
|
||||
Log.log("Configured language not found in core translations.");
|
||||
Log.log("[translator] Configured language not found in core translations.");
|
||||
}
|
||||
|
||||
await this.loadCoreTranslationsFallback();
|
||||
@@ -129,7 +119,7 @@ const Translator = (function () {
|
||||
async loadCoreTranslationsFallback () {
|
||||
let first = Object.keys(translations)[0];
|
||||
if (first) {
|
||||
Log.log(`Loading core translation fallback file: ${translations[first]}`);
|
||||
Log.log(`[translator] Loading core translation fallback file: ${translations[first]}`);
|
||||
this.coreTranslationsFallback = await loadJSON(translations[first]);
|
||||
}
|
||||
}
|
||||
|
||||
59
js/utils.js
59
js/utils.js
@@ -1,11 +1,7 @@
|
||||
const execSync = require("node:child_process").execSync;
|
||||
const path = require("node:path");
|
||||
|
||||
const rootPath = path.resolve(`${__dirname}/../`);
|
||||
const Log = require(`${rootPath}/js/logger.js`);
|
||||
const os = require("node:os");
|
||||
const fs = require("node:fs");
|
||||
const si = require("systeminformation");
|
||||
const Log = require("logger");
|
||||
|
||||
const modulePositions = []; // will get list from index.html
|
||||
const regionRegEx = /"region ([^"]*)/i;
|
||||
@@ -14,26 +10,34 @@ const discoveredPositionsJSFilename = "js/positions.js";
|
||||
|
||||
module.exports = {
|
||||
|
||||
async logSystemInformation () {
|
||||
async logSystemInformation (mirrorVersion) {
|
||||
try {
|
||||
let installedNodeVersion = execSync("node -v", { encoding: "utf-8" }).replace("v", "").replace(/(?:\r\n|\r|\n)/g, "");
|
||||
const system = await si.system();
|
||||
const osInfo = await si.osInfo();
|
||||
const versions = await si.versions();
|
||||
|
||||
const staticData = await si.get({
|
||||
system: "manufacturer, model, raspberry, virtual",
|
||||
osInfo: "platform, distro, release, arch",
|
||||
versions: "kernel, node, npm, pm2"
|
||||
});
|
||||
let systemDataString = "System information:";
|
||||
systemDataString += `\n### SYSTEM: manufacturer: ${staticData["system"]["manufacturer"]}; model: ${staticData["system"]["model"]}; virtual: ${staticData["system"]["virtual"]}`;
|
||||
systemDataString += `\n### OS: platform: ${staticData["osInfo"]["platform"]}; distro: ${staticData["osInfo"]["distro"]}; release: ${staticData["osInfo"]["release"]}; arch: ${staticData["osInfo"]["arch"]}; kernel: ${staticData["versions"]["kernel"]}`;
|
||||
systemDataString += `\n### VERSIONS: electron: ${process.versions.electron}; used node: ${staticData["versions"]["node"]}; installed node: ${installedNodeVersion}; npm: ${staticData["versions"]["npm"]}; pm2: ${staticData["versions"]["pm2"]}`;
|
||||
systemDataString += `\n### OTHER: timeZone: ${Intl.DateTimeFormat().resolvedOptions().timeZone}; ELECTRON_ENABLE_GPU: ${process.env.ELECTRON_ENABLE_GPU}`;
|
||||
const usedNodeVersion = process.version.replace("v", "");
|
||||
const installedNodeVersion = versions.node;
|
||||
const totalRam = (os.totalmem() / 1024 / 1024).toFixed(2);
|
||||
const freeRam = (os.freemem() / 1024 / 1024).toFixed(2);
|
||||
const usedRam = ((os.totalmem() - os.freemem()) / 1024 / 1024).toFixed(2);
|
||||
|
||||
let systemDataString = [
|
||||
"\n#### System Information ####",
|
||||
`- SYSTEM: manufacturer: ${system.manufacturer}; model: ${system.model}; virtual: ${system.virtual}; MM: ${mirrorVersion}`,
|
||||
`- OS: platform: ${osInfo.platform}; distro: ${osInfo.distro}; release: ${osInfo.release}; arch: ${osInfo.arch}; kernel: ${versions.kernel}`,
|
||||
`- VERSIONS: electron: ${process.versions.electron}; used node: ${usedNodeVersion}; installed node: ${installedNodeVersion}; npm: ${versions.npm}; pm2: ${versions.pm2}`,
|
||||
`- ENV: XDG_SESSION_TYPE: ${process.env.XDG_SESSION_TYPE}; MM_CONFIG_FILE: ${process.env.MM_CONFIG_FILE}`,
|
||||
` WAYLAND_DISPLAY: ${process.env.WAYLAND_DISPLAY}; DISPLAY: ${process.env.DISPLAY}; ELECTRON_ENABLE_GPU: ${process.env.ELECTRON_ENABLE_GPU}`,
|
||||
`- RAM: total: ${totalRam} MB; free: ${freeRam} MB; used: ${usedRam} MB`,
|
||||
`- OTHERS: uptime: ${Math.floor(os.uptime() / 60)} minutes; timeZone: ${Intl.DateTimeFormat().resolvedOptions().timeZone}`
|
||||
].join("\n");
|
||||
Log.info(systemDataString);
|
||||
|
||||
// Return is currently only for jest
|
||||
// Return is currently only for tests
|
||||
return systemDataString;
|
||||
} catch (e) {
|
||||
Log.error(e);
|
||||
} catch (error) {
|
||||
Log.error(error);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -52,7 +56,7 @@ module.exports = {
|
||||
// if not already discovered
|
||||
if (modulePositions.length === 0) {
|
||||
// get the lines of the index.html
|
||||
const lines = fs.readFileSync(indexFileName).toString().split(os.EOL);
|
||||
const lines = fs.readFileSync(indexFileName).toString().split("\n");
|
||||
// loop thru the lines
|
||||
lines.forEach((line) => {
|
||||
// run the regex on each line
|
||||
@@ -61,11 +65,18 @@ module.exports = {
|
||||
if (results && results.length > 0) {
|
||||
// get the position parts and replace space with underscore
|
||||
const positionName = results[1].replace(" ", "_");
|
||||
// add it to the list
|
||||
modulePositions.push(positionName);
|
||||
// add it to the list only if not already present (avoid duplicates)
|
||||
if (!modulePositions.includes(positionName)) {
|
||||
modulePositions.push(positionName);
|
||||
}
|
||||
}
|
||||
});
|
||||
fs.writeFileSync(discoveredPositionsJSFilename, `const modulePositions=${JSON.stringify(modulePositions)}`);
|
||||
try {
|
||||
fs.writeFileSync(discoveredPositionsJSFilename, `const modulePositions=${JSON.stringify(modulePositions)}`);
|
||||
}
|
||||
catch (error) {
|
||||
Log.error("unable to write js/positions.js with the discovered module positions\nmake the MagicMirror/js folder writeable by the user starting MagicMirror");
|
||||
}
|
||||
}
|
||||
// return the list to the caller
|
||||
return modulePositions;
|
||||
|
||||
@@ -6,7 +6,7 @@ const vendor = {
|
||||
"font-awesome.css": "css/font-awesome.css",
|
||||
"nunjucks.js": "node_modules/nunjucks/browser/nunjucks.min.js",
|
||||
"suncalc.js": "node_modules/suncalc/suncalc.js",
|
||||
"croner.js": "node_modules/croner/dist/croner.umd.min.js"
|
||||
"croner.js": "node_modules/croner/dist/croner.umd.js"
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
@@ -25,10 +25,13 @@ Module.register("alert", {
|
||||
da: "translations/da.json",
|
||||
de: "translations/de.json",
|
||||
en: "translations/en.json",
|
||||
eo: "translations/eo.json",
|
||||
es: "translations/es.json",
|
||||
fr: "translations/fr.json",
|
||||
hu: "translations/hu.json",
|
||||
nl: "translations/nl.json",
|
||||
pt: "translations/pt.json",
|
||||
"pt-br": "translations/pt-br.json",
|
||||
ru: "translations/ru.json",
|
||||
th: "translations/th.json"
|
||||
};
|
||||
@@ -123,7 +126,7 @@ Module.register("alert", {
|
||||
return new Promise((resolve) => {
|
||||
this.nunjucksEnvironment().render(this.getTemplate(type), data, function (err, res) {
|
||||
if (err) {
|
||||
Log.error("Failed to render alert", err);
|
||||
Log.error("[alert] Failed to render alert", err);
|
||||
}
|
||||
|
||||
resolve(res);
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
// notice, warning, error, success
|
||||
// will add class ns-type-warning, ns-type-error or ns-type-success
|
||||
type: "notice",
|
||||
// if the user doesn´t close the notification then we remove it
|
||||
// if the user doesn't close the notification then we remove it
|
||||
// after the following time
|
||||
ttl: 6000,
|
||||
al_no: "ns-box",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
font-size: 70%;
|
||||
position: relative;
|
||||
display: table;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
max-width: 100%;
|
||||
border-width: 1px;
|
||||
border-radius: 5px;
|
||||
@@ -35,7 +35,7 @@
|
||||
top: 40%;
|
||||
width: 40%;
|
||||
height: auto;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
{% if imageUrl or imageFA %}
|
||||
{% set imageHeight = imageHeight if imageHeight else "80px" %}
|
||||
{% if imageUrl %}
|
||||
<img src="{{ imageUrl }}"
|
||||
height="{{ imageHeight }}"
|
||||
style="margin-bottom: 10px" />
|
||||
{% else %}
|
||||
<span class="bright fas fa-{{ imageFA }}"
|
||||
style="margin-bottom: 10px;
|
||||
font-size: {{ imageHeight }}"></span>
|
||||
{% endif %}
|
||||
<br />
|
||||
{% set imageHeight = imageHeight if imageHeight else "80px" %}
|
||||
{% if imageUrl %}
|
||||
<img src="{{ imageUrl }}" height="{{ imageHeight }}" style="margin-bottom: 10px" />
|
||||
{% else %}
|
||||
<span
|
||||
class="bright fas fa-{{ imageFA }}"
|
||||
style="margin-bottom: 10px;
|
||||
font-size: {{ imageHeight }}"
|
||||
></span>
|
||||
{% endif %}
|
||||
<br />
|
||||
{% endif %}
|
||||
{% if title %}
|
||||
<span class="thin dimmed medium">{{ title if titleType == 'text' else title | safe }}</span>
|
||||
<span class="thin dimmed medium">{{ title if titleType == 'text' else title | safe }}</span>
|
||||
{% endif %}
|
||||
{% if message %}
|
||||
{% if title %}<br />{% endif %}
|
||||
<span class="light bright small">{{ message if messageType == 'text' else message | safe }}</span>
|
||||
{% if title %}<br />{% endif %}
|
||||
<span class="light bright small">{{ message if messageType == 'text' else message | safe }}</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% if title %}
|
||||
<span class="thin dimmed medium">{{ title if titleType == 'text' else title | safe }}</span>
|
||||
<span class="thin dimmed medium">{{ title if titleType == 'text' else title | safe }}</span>
|
||||
{% endif %}
|
||||
{% if message %}
|
||||
{% if title %}<br />{% endif %}
|
||||
<span class="light bright small">{{ message if messageType == 'text' else message | safe }}</span>
|
||||
{% if title %}<br />{% endif %}
|
||||
<span class="light bright small">{{ message if messageType == 'text' else message | safe }}</span>
|
||||
{% endif %}
|
||||
|
||||
4
modules/default/alert/translations/el.json
Normal file
4
modules/default/alert/translations/el.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"sysTitle": "MagicMirror² Ειδοποίηση",
|
||||
"welcome": "Καλώς ήρθατε, η εκκίνηση ήταν επιτυχής!"
|
||||
}
|
||||
4
modules/default/alert/translations/eo.json
Normal file
4
modules/default/alert/translations/eo.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"sysTitle": "MagicMirror²-sciigo",
|
||||
"welcome": "Bonvenon, lanĉo sukcesis!"
|
||||
}
|
||||
4
modules/default/alert/translations/pt-br.json
Normal file
4
modules/default/alert/translations/pt-br.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"sysTitle": "Notificação do MagicMirror²",
|
||||
"welcome": "Bem-vindo, o sistema iniciou com sucesso!"
|
||||
}
|
||||
4
modules/default/alert/translations/pt.json
Normal file
4
modules/default/alert/translations/pt.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"sysTitle": "Notificação do MagicMirror²",
|
||||
"welcome": "Bem-vindo, o sistema iniciou com sucesso!"
|
||||
}
|
||||
@@ -2,23 +2,14 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
padding-left: 0;
|
||||
padding-right: 10px;
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.calendar .symbol span {
|
||||
padding-top: 4px;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.calendar .title {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
vertical-align: top;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.calendar .time {
|
||||
padding-left: 30px;
|
||||
padding-left: 20px;
|
||||
text-align: right;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ Module.register("calendar", {
|
||||
limitDays: 0, // Limit the number of days shown, 0 = no limit
|
||||
pastDaysCount: 0,
|
||||
displaySymbol: true,
|
||||
defaultSymbol: "calendar-alt", // Fontawesome Symbol see https://fontawesome.com/cheatsheet?from=io
|
||||
defaultSymbol: "calendar-days", // Fontawesome Symbol see https://fontawesome.com/search?ic=free&o=r
|
||||
defaultSymbolClassName: "fas fa-fw fa-",
|
||||
showLocation: false,
|
||||
displayRepeatingCountTitle: false,
|
||||
@@ -68,8 +68,6 @@ Module.register("calendar", {
|
||||
updateOnFetch: true
|
||||
},
|
||||
|
||||
requiresVersion: "2.1.0",
|
||||
|
||||
// Define required scripts.
|
||||
getStyles () {
|
||||
return ["calendar.css", "font-awesome.css"];
|
||||
@@ -77,7 +75,7 @@ Module.register("calendar", {
|
||||
|
||||
// Define required scripts.
|
||||
getScripts () {
|
||||
return ["calendarutils.js", "moment.js"];
|
||||
return ["calendarutils.js", "moment.js", "moment-timezone.js"];
|
||||
},
|
||||
|
||||
// Define required translations.
|
||||
@@ -96,12 +94,12 @@ Module.register("calendar", {
|
||||
Log.info(`Starting module: ${this.name}`);
|
||||
|
||||
if (this.config.colored) {
|
||||
Log.warn("Your are using the deprecated config values 'colored'. Please switch to 'coloredSymbol' & 'coloredText'!");
|
||||
Log.warn("[calendar] Your are using the deprecated config values 'colored'. Please switch to 'coloredSymbol' & 'coloredText'!");
|
||||
this.config.coloredText = true;
|
||||
this.config.coloredSymbol = true;
|
||||
}
|
||||
if (this.config.coloredSymbolOnly) {
|
||||
Log.warn("Your are using the deprecated config values 'coloredSymbolOnly'. Please switch to 'coloredSymbol' & 'coloredText'!");
|
||||
Log.warn("[calendar] Your are using the deprecated config values 'coloredSymbolOnly'. Please switch to 'coloredSymbol' & 'coloredText'!");
|
||||
this.config.coloredText = false;
|
||||
this.config.coloredSymbol = true;
|
||||
}
|
||||
@@ -143,7 +141,7 @@ Module.register("calendar", {
|
||||
|
||||
// we check user and password here for backwards compatibility with old configs
|
||||
if (calendar.user && calendar.pass) {
|
||||
Log.warn("Deprecation warning: Please update your calendar authentication configuration.");
|
||||
Log.warn("[calendar] Deprecation warning: Please update your calendar authentication configuration.");
|
||||
Log.warn("https://docs.magicmirror.builders/modules/calendar.html#configuration-options");
|
||||
calendar.auth = {
|
||||
user: calendar.user,
|
||||
@@ -160,7 +158,7 @@ Module.register("calendar", {
|
||||
|
||||
// for backward compatibility titleReplace
|
||||
if (typeof this.config.titleReplace !== "undefined") {
|
||||
Log.warn("Deprecation warning: Please consider upgrading your calendar titleReplace configuration to customEvents.");
|
||||
Log.warn("[calendar] Deprecation warning: Please consider upgrading your calendar titleReplace configuration to customEvents.");
|
||||
for (const [titlesearchstr, titlereplacestr] of Object.entries(this.config.titleReplace)) {
|
||||
this.config.customEvents.push({ keyword: ".*", transform: { search: titlesearchstr, replace: titlereplacestr } });
|
||||
}
|
||||
@@ -169,11 +167,16 @@ Module.register("calendar", {
|
||||
this.selfUpdate();
|
||||
},
|
||||
|
||||
notificationReceived (notification, payload, sender) {
|
||||
if (notification === "FETCH_CALENDAR") {
|
||||
if (this.hasCalendarURL(payload.url)) {
|
||||
this.sendSocketNotification(notification, { url: payload.url, id: this.identifier });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Override socket notification handler.
|
||||
socketNotificationReceived (notification, payload) {
|
||||
if (notification === "FETCH_CALENDAR") {
|
||||
this.sendSocketNotification(notification, { url: payload.url, id: this.identifier });
|
||||
}
|
||||
|
||||
if (this.identifier !== payload.id) {
|
||||
return;
|
||||
@@ -181,13 +184,27 @@ Module.register("calendar", {
|
||||
|
||||
if (notification === "CALENDAR_EVENTS") {
|
||||
if (this.hasCalendarURL(payload.url)) {
|
||||
this.calendarData[payload.url] = payload.events;
|
||||
// have we received events for this url
|
||||
if (!this.calendarData[payload.url]) {
|
||||
// no, setup the structure to hold the info
|
||||
this.calendarData[payload.url] = { events: null, checksum: null };
|
||||
}
|
||||
// save the event list
|
||||
this.calendarData[payload.url].events = payload.events;
|
||||
|
||||
this.error = null;
|
||||
this.loaded = true;
|
||||
|
||||
if (this.config.broadcastEvents) {
|
||||
this.broadcastEvents();
|
||||
}
|
||||
// if the checksum is the same
|
||||
if (this.calendarData[payload.url].checksum === payload.checksum) {
|
||||
// then don't update the UI
|
||||
return;
|
||||
}
|
||||
// haven't seen or the checksum is different
|
||||
this.calendarData[payload.url].checksum = payload.checksum;
|
||||
|
||||
if (!this.config.updateOnFetch) {
|
||||
if (this.calendarDisplayer[payload.url] === undefined) {
|
||||
@@ -196,7 +213,7 @@ Module.register("calendar", {
|
||||
// set this calendar as displayed
|
||||
this.calendarDisplayer[payload.url] = true;
|
||||
} else {
|
||||
Log.debug("[Calendar] DOM not updated waiting self update()");
|
||||
Log.debug("[calendar] DOM not updated waiting self update()");
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -210,18 +227,8 @@ Module.register("calendar", {
|
||||
this.updateDom(this.config.animationSpeed);
|
||||
},
|
||||
|
||||
eventEndingWithinNextFullTimeUnit (event, ONE_DAY) {
|
||||
const now = new Date();
|
||||
return event.endDate - now <= ONE_DAY;
|
||||
},
|
||||
|
||||
// Override dom generator.
|
||||
getDom () {
|
||||
const ONE_SECOND = 1000; // 1,000 milliseconds
|
||||
const ONE_MINUTE = ONE_SECOND * 60;
|
||||
const ONE_HOUR = ONE_MINUTE * 60;
|
||||
const ONE_DAY = ONE_HOUR * 24;
|
||||
|
||||
const events = this.createEventList(true);
|
||||
const wrapper = document.createElement("table");
|
||||
wrapper.className = this.config.tableClass;
|
||||
@@ -253,7 +260,9 @@ Module.register("calendar", {
|
||||
let lastSeenDate = "";
|
||||
|
||||
events.forEach((event, index) => {
|
||||
const dateAsString = moment(event.startDate, "x").format(this.config.dateFormat);
|
||||
const eventStartDateMoment = this.timestampToMoment(event.startDate);
|
||||
const eventEndDateMoment = this.timestampToMoment(event.endDate);
|
||||
const dateAsString = eventStartDateMoment.format(this.config.dateFormat);
|
||||
if (this.config.timeFormat === "dateheaders") {
|
||||
if (lastSeenDate !== dateAsString) {
|
||||
const dateRow = document.createElement("tr");
|
||||
@@ -310,15 +319,12 @@ Module.register("calendar", {
|
||||
}
|
||||
|
||||
const symbolClass = this.symbolClassForUrl(event.url);
|
||||
symbolWrapper.className = `symbol align-right ${symbolClass}`;
|
||||
symbolWrapper.className = `symbol ${symbolClass}`;
|
||||
|
||||
const symbols = this.symbolsForEvent(event);
|
||||
symbols.forEach((s, index) => {
|
||||
symbols.forEach((s) => {
|
||||
const symbol = document.createElement("span");
|
||||
symbol.className = s;
|
||||
if (index > 0) {
|
||||
symbol.style.paddingLeft = "5px";
|
||||
}
|
||||
symbolWrapper.appendChild(symbol);
|
||||
});
|
||||
eventWrapper.appendChild(symbolWrapper);
|
||||
@@ -335,10 +341,12 @@ Module.register("calendar", {
|
||||
repeatingCountTitle = this.countTitleForUrl(event.url);
|
||||
|
||||
if (repeatingCountTitle !== "") {
|
||||
const thisYear = new Date(parseInt(event.startDate)).getFullYear(),
|
||||
const thisYear = eventStartDateMoment.year(),
|
||||
yearDiff = thisYear - event.firstYear;
|
||||
|
||||
repeatingCountTitle = `, ${yearDiff} ${repeatingCountTitle}`;
|
||||
if (yearDiff > 0) {
|
||||
repeatingCountTitle = `, ${yearDiff} ${repeatingCountTitle}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,14 +398,14 @@ Module.register("calendar", {
|
||||
timeWrapper.className = `time light ${this.config.flipDateHeaderTitle ? "align-right " : "align-left "}${this.timeClassForUrl(event.url)}`;
|
||||
timeWrapper.style.paddingLeft = "2px";
|
||||
timeWrapper.style.textAlign = this.config.flipDateHeaderTitle ? "right" : "left";
|
||||
timeWrapper.innerHTML = moment(event.startDate, "x").format("LT");
|
||||
timeWrapper.innerHTML = eventStartDateMoment.format("LT");
|
||||
|
||||
// Add endDate to dataheaders if showEnd is enabled
|
||||
if (this.config.showEnd) {
|
||||
if (this.config.showEndsOnlyWithDuration && event.startDate === event.endDate) {
|
||||
// no duration here, don't display end
|
||||
} else {
|
||||
timeWrapper.innerHTML += ` - ${CalendarUtils.capFirst(moment(event.endDate, "x").format("LT"))}`;
|
||||
timeWrapper.innerHTML += ` - ${CalendarUtils.capFirst(eventEndDateMoment.format("LT"))}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,36 +418,43 @@ Module.register("calendar", {
|
||||
const timeWrapper = document.createElement("td");
|
||||
|
||||
eventWrapper.appendChild(titleWrapper);
|
||||
const now = new Date();
|
||||
const now = moment();
|
||||
|
||||
if (this.config.timeFormat === "absolute") {
|
||||
// Use dateFormat
|
||||
timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").format(this.config.dateFormat));
|
||||
timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.dateFormat));
|
||||
// Add end time if showEnd
|
||||
if (this.config.showEnd) {
|
||||
if (this.config.showEndsOnlyWithDuration && event.startDate === event.endDate) {
|
||||
// no duration here, don't display end
|
||||
} else {
|
||||
// and has a duration
|
||||
if (event.startDate !== event.endDate) {
|
||||
timeWrapper.innerHTML += "-";
|
||||
timeWrapper.innerHTML += CalendarUtils.capFirst(moment(event.endDate, "x").format(this.config.dateEndFormat));
|
||||
timeWrapper.innerHTML += CalendarUtils.capFirst(eventEndDateMoment.format(this.config.dateEndFormat));
|
||||
}
|
||||
}
|
||||
|
||||
// For full day events we use the fullDayEventDateFormat
|
||||
if (event.fullDayEvent) {
|
||||
//subtract one second so that fullDayEvents end at 23:59:59, and not at 0:00:00 one the next day
|
||||
event.endDate -= ONE_SECOND;
|
||||
timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").format(this.config.fullDayEventDateFormat));
|
||||
} else if (this.config.getRelative > 0 && event.startDate < now) {
|
||||
eventEndDateMoment.subtract(1, "second");
|
||||
timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.fullDayEventDateFormat));
|
||||
// only show end if requested and allowed and the dates are different
|
||||
if (this.config.showEnd && !this.config.showEndsOnlyWithDuration && !eventStartDateMoment.isSame(eventEndDateMoment, "d")) {
|
||||
timeWrapper.innerHTML += "-";
|
||||
timeWrapper.innerHTML += CalendarUtils.capFirst(eventEndDateMoment.format(this.config.fullDayEventDateFormat));
|
||||
} else if (!eventStartDateMoment.isSame(eventEndDateMoment, "d") && eventStartDateMoment.isBefore(now)) {
|
||||
timeWrapper.innerHTML = CalendarUtils.capFirst(now.format(this.config.fullDayEventDateFormat));
|
||||
}
|
||||
} else if (this.config.getRelative > 0 && eventStartDateMoment.isBefore(now)) {
|
||||
// Ongoing and getRelative is set
|
||||
timeWrapper.innerHTML = CalendarUtils.capFirst(
|
||||
this.translate("RUNNING", {
|
||||
fallback: `${this.translate("RUNNING")} {timeUntilEnd}`,
|
||||
timeUntilEnd: moment(event.endDate, "x").fromNow(true)
|
||||
timeUntilEnd: eventEndDateMoment.fromNow(true)
|
||||
})
|
||||
);
|
||||
} else if (this.config.urgency > 0 && event.startDate - now < this.config.urgency * ONE_DAY) {
|
||||
} else if (this.config.urgency > 0 && eventStartDateMoment.diff(now, "d") < this.config.urgency) {
|
||||
// Within urgency days
|
||||
timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").fromNow());
|
||||
timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.fromNow());
|
||||
}
|
||||
if (event.fullDayEvent && this.config.nextDaysRelative) {
|
||||
// Full days events within the next two days
|
||||
@@ -447,9 +462,9 @@ Module.register("calendar", {
|
||||
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY"));
|
||||
} else if (event.yesterday) {
|
||||
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY"));
|
||||
} else if (event.startDate - now < ONE_DAY && event.startDate - now > 0) {
|
||||
} else if (event.tomorrow) {
|
||||
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW"));
|
||||
} else if (event.startDate - now < 2 * ONE_DAY && event.startDate - now > 0) {
|
||||
} else if (event.dayAfterTomorrow) {
|
||||
if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") {
|
||||
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW"));
|
||||
}
|
||||
@@ -457,23 +472,25 @@ Module.register("calendar", {
|
||||
}
|
||||
} else {
|
||||
// Show relative times
|
||||
if (event.startDate >= now || (event.fullDayEvent && this.eventEndingWithinNextFullTimeUnit(event, ONE_DAY))) {
|
||||
if (eventStartDateMoment.isSameOrAfter(now) || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) {
|
||||
// Use relative time
|
||||
if (!this.config.hideTime && !event.fullDayEvent) {
|
||||
timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").calendar(null, { sameElse: this.config.dateFormat }));
|
||||
Log.debug("[calendar] event not hidden and not fullday");
|
||||
timeWrapper.innerHTML = `${CalendarUtils.capFirst(eventStartDateMoment.calendar(null, { sameElse: this.config.dateFormat }))}`;
|
||||
} else {
|
||||
timeWrapper.innerHTML = CalendarUtils.capFirst(
|
||||
moment(event.startDate, "x").calendar(null, {
|
||||
Log.debug("[calendar] event full day or hidden");
|
||||
timeWrapper.innerHTML = `${CalendarUtils.capFirst(
|
||||
eventStartDateMoment.calendar(null, {
|
||||
sameDay: this.config.showTimeToday ? "LT" : `[${this.translate("TODAY")}]`,
|
||||
nextDay: `[${this.translate("TOMORROW")}]`,
|
||||
nextWeek: "dddd",
|
||||
sameElse: event.fullDayEvent ? this.config.fullDayEventDateFormat : this.config.dateFormat
|
||||
})
|
||||
);
|
||||
)}`;
|
||||
}
|
||||
if (event.fullDayEvent) {
|
||||
// Full days events within the next two days
|
||||
if (event.today || (event.fullDayEvent && this.eventEndingWithinNextFullTimeUnit(event, ONE_DAY))) {
|
||||
if (event.today || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) {
|
||||
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY"));
|
||||
} else if (event.dayBeforeYesterday) {
|
||||
if (this.translate("DAYBEFOREYESTERDAY") !== "DAYBEFOREYESTERDAY") {
|
||||
@@ -481,23 +498,25 @@ Module.register("calendar", {
|
||||
}
|
||||
} else if (event.yesterday) {
|
||||
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY"));
|
||||
} else if (event.startDate - now < ONE_DAY && event.startDate - now > 0) {
|
||||
} else if (event.tomorrow) {
|
||||
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW"));
|
||||
} else if (event.startDate - now < 2 * ONE_DAY && event.startDate - now > 0) {
|
||||
} else if (event.dayAfterTomorrow) {
|
||||
if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") {
|
||||
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW"));
|
||||
}
|
||||
}
|
||||
} else if (event.startDate - now < this.config.getRelative * ONE_HOUR) {
|
||||
Log.info("[calendar] event fullday");
|
||||
} else if (eventStartDateMoment.diff(now, "h") < this.config.getRelative) {
|
||||
Log.info("[calendar] not full day but within getRelative size");
|
||||
// If event is within getRelative hours, display 'in xxx' time format or moment.fromNow()
|
||||
timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").fromNow());
|
||||
timeWrapper.innerHTML = `${CalendarUtils.capFirst(eventStartDateMoment.fromNow())}`;
|
||||
}
|
||||
} else {
|
||||
// Ongoing event
|
||||
timeWrapper.innerHTML = CalendarUtils.capFirst(
|
||||
this.translate("RUNNING", {
|
||||
fallback: `${this.translate("RUNNING")} {timeUntilEnd}`,
|
||||
timeUntilEnd: moment(event.endDate, "x").fromNow(true)
|
||||
timeUntilEnd: eventEndDateMoment.fromNow(true)
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -576,106 +595,113 @@ Module.register("calendar", {
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* converts the given timestamp to a moment with a timezone
|
||||
* @param {number} timestamp timestamp from an event
|
||||
* @returns {moment.Moment} moment with a timezone
|
||||
*/
|
||||
timestampToMoment (timestamp) {
|
||||
return moment(timestamp, "x").tz(moment.tz.guess());
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates the sorted list of all events.
|
||||
* @param {boolean} limitNumberOfEntries Whether to filter returned events for display.
|
||||
* @returns {object[]} Array with events.
|
||||
*/
|
||||
createEventList (limitNumberOfEntries) {
|
||||
const ONE_SECOND = 1000; // 1,000 milliseconds
|
||||
const ONE_MINUTE = ONE_SECOND * 60;
|
||||
const ONE_HOUR = ONE_MINUTE * 60;
|
||||
const ONE_DAY = ONE_HOUR * 24;
|
||||
let now = moment();
|
||||
let future = now.clone().startOf("day").add(this.config.maximumNumberOfDays, "days");
|
||||
|
||||
let now, today, future;
|
||||
if (this.config.forceUseCurrentTime || this.defaults.forceUseCurrentTime) {
|
||||
now = new Date();
|
||||
today = moment().startOf("day");
|
||||
future = moment().startOf("day").add(this.config.maximumNumberOfDays, "days").toDate();
|
||||
} else {
|
||||
now = new Date(Date.now()); // Can use overridden time
|
||||
today = moment(now).startOf("day");
|
||||
future = moment(now).startOf("day").add(this.config.maximumNumberOfDays, "days").toDate();
|
||||
}
|
||||
let events = [];
|
||||
|
||||
for (const calendarUrl in this.calendarData) {
|
||||
const calendar = this.calendarData[calendarUrl];
|
||||
const calendar = this.calendarData[calendarUrl].events;
|
||||
let remainingEntries = this.maximumEntriesForUrl(calendarUrl);
|
||||
let maxPastDaysCompare = now - this.maximumPastDaysForUrl(calendarUrl) * ONE_DAY;
|
||||
let maxPastDaysCompare = now.clone().subtract(this.maximumPastDaysForUrl(calendarUrl), "days");
|
||||
let by_url_calevents = [];
|
||||
for (const e in calendar) {
|
||||
const event = JSON.parse(JSON.stringify(calendar[e])); // clone object
|
||||
const eventStartDateMoment = this.timestampToMoment(event.startDate);
|
||||
const eventEndDateMoment = this.timestampToMoment(event.endDate);
|
||||
|
||||
if (this.config.hidePrivate && event.class === "PRIVATE") {
|
||||
// do not add the current event, skip it
|
||||
continue;
|
||||
}
|
||||
if (limitNumberOfEntries) {
|
||||
if (event.endDate < maxPastDaysCompare) {
|
||||
if (eventEndDateMoment.isBefore(maxPastDaysCompare)) {
|
||||
continue;
|
||||
}
|
||||
if (this.config.hideOngoing && event.startDate < now) {
|
||||
if (this.config.hideOngoing && eventStartDateMoment.isBefore(now)) {
|
||||
continue;
|
||||
}
|
||||
if (this.config.hideDuplicates && this.listContainsEvent(events, event)) {
|
||||
continue;
|
||||
}
|
||||
if (--remainingEntries < 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
event.url = calendarUrl;
|
||||
event.today = event.startDate >= today && event.startDate < today + ONE_DAY;
|
||||
event.dayBeforeYesterday = event.startDate >= today - ONE_DAY * 2 && event.startDate < today - ONE_DAY;
|
||||
event.yesterday = event.startDate >= today - ONE_DAY && event.startDate < today;
|
||||
event.tomorrow = !event.today && event.startDate >= today + ONE_DAY && event.startDate < today + 2 * ONE_DAY;
|
||||
event.dayAfterTomorrow = !event.tomorrow && event.startDate >= today + ONE_DAY * 2 && event.startDate < today + 3 * ONE_DAY;
|
||||
event.today = eventStartDateMoment.isSame(now, "d");
|
||||
event.dayBeforeYesterday = eventStartDateMoment.isSame(now.clone().subtract(2, "days"), "d");
|
||||
event.yesterday = eventStartDateMoment.isSame(now.clone().subtract(1, "days"), "d");
|
||||
event.tomorrow = eventStartDateMoment.isSame(now.clone().add(1, "days"), "d");
|
||||
event.dayAfterTomorrow = eventStartDateMoment.isSame(now.clone().add(2, "days"), "d");
|
||||
|
||||
/*
|
||||
* if sliceMultiDayEvents is set to true, multiday events (events exceeding at least one midnight) are sliced into days,
|
||||
* otherwise, esp. in dateheaders mode it is not clear how long these events are.
|
||||
*/
|
||||
const maxCount = Math.round((event.endDate - 1 - moment(event.startDate, "x").endOf("day").format("x")) / ONE_DAY) + 1;
|
||||
const maxCount = eventEndDateMoment.diff(eventStartDateMoment, "days");
|
||||
if (this.config.sliceMultiDayEvents && maxCount > 1) {
|
||||
const splitEvents = [];
|
||||
let midnight
|
||||
= moment(event.startDate, "x")
|
||||
= eventStartDateMoment
|
||||
.clone()
|
||||
.startOf("day")
|
||||
.add(1, "day")
|
||||
.endOf("day")
|
||||
.format("x");
|
||||
.endOf("day");
|
||||
let count = 1;
|
||||
while (event.endDate > midnight) {
|
||||
while (eventEndDateMoment.isAfter(midnight)) {
|
||||
const thisEvent = JSON.parse(JSON.stringify(event)); // clone object
|
||||
thisEvent.today = thisEvent.startDate >= today && thisEvent.startDate < today + ONE_DAY;
|
||||
thisEvent.tomorrow = !thisEvent.today && thisEvent.startDate >= today + ONE_DAY && thisEvent.startDate < today + 2 * ONE_DAY;
|
||||
thisEvent.endDate = moment(midnight, "x").clone().subtract(1, "day").format("x");
|
||||
thisEvent.today = this.timestampToMoment(thisEvent.startDate).isSame(now, "d");
|
||||
thisEvent.tomorrow = this.timestampToMoment(thisEvent.startDate).isSame(now.clone().add(1, "days"), "d");
|
||||
thisEvent.endDate = midnight.clone().subtract(1, "day").format("x");
|
||||
thisEvent.title += ` (${count}/${maxCount})`;
|
||||
splitEvents.push(thisEvent);
|
||||
|
||||
event.startDate = midnight;
|
||||
event.startDate = midnight.format("x");
|
||||
count += 1;
|
||||
midnight = moment(midnight, "x").add(1, "day").endOf("day").format("x"); // next day
|
||||
midnight = midnight.clone().add(1, "day").endOf("day"); // next day
|
||||
}
|
||||
// Last day
|
||||
event.title += ` (${count}/${maxCount})`;
|
||||
event.today += event.startDate >= today && event.startDate < today + ONE_DAY;
|
||||
event.tomorrow = !event.today && event.startDate >= today + ONE_DAY && event.startDate < today + 2 * ONE_DAY;
|
||||
event.today += this.timestampToMoment(event.startDate).isSame(now, "d");
|
||||
event.tomorrow = this.timestampToMoment(event.startDate).isSame(now.clone().add(1, "days"), "d");
|
||||
splitEvents.push(event);
|
||||
|
||||
for (let splitEvent of splitEvents) {
|
||||
if (splitEvent.endDate > now && splitEvent.endDate <= future) {
|
||||
events.push(splitEvent);
|
||||
if (this.timestampToMoment(splitEvent.endDate).isAfter(now) && this.timestampToMoment(splitEvent.endDate).isSameOrBefore(future)) {
|
||||
by_url_calevents.push(splitEvent);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
events.push(event);
|
||||
by_url_calevents.push(event);
|
||||
}
|
||||
}
|
||||
if (limitNumberOfEntries) {
|
||||
// sort entries before clipping
|
||||
by_url_calevents.sort(function (a, b) {
|
||||
return a.startDate - b.startDate;
|
||||
});
|
||||
Log.debug(`[calendar] pushing ${by_url_calevents.length} events to total with room for ${remainingEntries}`);
|
||||
events = events.concat(by_url_calevents.slice(0, remainingEntries));
|
||||
Log.debug(`[calendar] events for calendar=${events.length}`);
|
||||
} else {
|
||||
events = events.concat(by_url_calevents);
|
||||
}
|
||||
}
|
||||
|
||||
Log.info(`[calendar] sorting events count=${events.length}`);
|
||||
events.sort(function (a, b) {
|
||||
return a.startDate - b.startDate;
|
||||
});
|
||||
@@ -688,34 +714,28 @@ Module.register("calendar", {
|
||||
* Limit the number of days displayed
|
||||
* If limitDays is set > 0, limit display to that number of days
|
||||
*/
|
||||
if (this.config.limitDays > 0) {
|
||||
let newEvents = [];
|
||||
let lastDate = today.clone().subtract(1, "days").format("YYYYMMDD");
|
||||
let days = 0;
|
||||
for (const ev of events) {
|
||||
let eventDate = moment(ev.startDate, "x").format("YYYYMMDD");
|
||||
if (this.config.limitDays > 0 && events.length > 0) { // watch out for initial display before events arrive from helper
|
||||
// Group all events by date, events on the same date will be in a list with the key being the date.
|
||||
const eventsByDate = Object.groupBy(events, (ev) => this.timestampToMoment(ev.startDate).format("YYYY-MM-DD"));
|
||||
const newEvents = [];
|
||||
let currentDate = moment();
|
||||
let daysCollected = 0;
|
||||
|
||||
/*
|
||||
* if date of event is later than lastdate
|
||||
* check if we already are showing max unique days
|
||||
*/
|
||||
if (eventDate > lastDate) {
|
||||
// if the only entry in the first day is a full day event that day is not counted as unique
|
||||
if (!this.config.limitDaysNeverSkip && newEvents.length === 1 && days === 1 && newEvents[0].fullDayEvent) {
|
||||
days--;
|
||||
}
|
||||
days++;
|
||||
if (days > this.config.limitDays) {
|
||||
continue;
|
||||
} else {
|
||||
lastDate = eventDate;
|
||||
}
|
||||
while (daysCollected < this.config.limitDays) {
|
||||
const dateStr = currentDate.format("YYYY-MM-DD");
|
||||
// Check if there are events on the currentDate
|
||||
if (eventsByDate[dateStr] && eventsByDate[dateStr].length > 0) {
|
||||
// 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.
|
||||
newEvents.push(...eventsByDate[dateStr].filter((ev) => this.timestampToMoment(ev.endDate).isAfter(moment())));
|
||||
// Since we found a day with events, increase the daysCollected by 1
|
||||
daysCollected++;
|
||||
}
|
||||
newEvents.push(ev);
|
||||
// Search for the next day
|
||||
currentDate.add(1, "day");
|
||||
}
|
||||
events = newEvents;
|
||||
}
|
||||
|
||||
Log.info(`[calendar] slicing events total maxCount=${this.config.maximumEntries}`);
|
||||
return events.slice(0, this.config.maximumEntries);
|
||||
},
|
||||
|
||||
@@ -870,7 +890,7 @@ Module.register("calendar", {
|
||||
* @param {string} url The calendar url
|
||||
* @param {string} property The property to look for
|
||||
* @param {string} defaultValue The value if the property is not found
|
||||
* @returns {*} The property
|
||||
* @returns {string} The property
|
||||
*/
|
||||
getCalendarProperty (url, property, defaultValue) {
|
||||
for (const calendar of this.config.calendars) {
|
||||
@@ -886,9 +906,13 @@ Module.register("calendar", {
|
||||
let p = this.getCalendarProperty(url, property, defaultValue);
|
||||
if (property === "symbol" || property === "recurringSymbol" || property === "fullDaySymbol") {
|
||||
const className = this.getCalendarProperty(url, "symbolClassName", this.config.defaultSymbolClassName);
|
||||
p = className + p;
|
||||
if (p instanceof Array) {
|
||||
let t = [];
|
||||
p.forEach((n) => { t.push(className + n); });
|
||||
p = t;
|
||||
}
|
||||
else p = className + p;
|
||||
}
|
||||
|
||||
if (!(p instanceof Array)) p = [p];
|
||||
return p;
|
||||
},
|
||||
@@ -899,7 +923,7 @@ Module.register("calendar", {
|
||||
|
||||
/**
|
||||
* Broadcasts the events to all other modules for reuse.
|
||||
* The all events available in one array, sorted on startdate.
|
||||
* The all events available in one array, sorted on startDate.
|
||||
*/
|
||||
broadcastEvents () {
|
||||
const eventList = this.createEventList(false);
|
||||
@@ -926,7 +950,7 @@ Module.register("calendar", {
|
||||
setTimeout(
|
||||
() => {
|
||||
setInterval(() => {
|
||||
Log.debug("[Calendar] self update");
|
||||
Log.debug("[calendar] self update");
|
||||
if (this.config.updateOnFetch) {
|
||||
this.updateDom(1);
|
||||
} else {
|
||||
|
||||
@@ -1,140 +1,222 @@
|
||||
const https = require("node:https");
|
||||
const ical = require("node-ical");
|
||||
const Log = require("logger");
|
||||
const NodeHelper = require("node_helper");
|
||||
const CalendarFetcherUtils = require("./calendarfetcherutils");
|
||||
const { getUserAgent } = require("#server_functions");
|
||||
|
||||
const FIFTEEN_MINUTES = 15 * 60 * 1000;
|
||||
const THIRTY_MINUTES = 30 * 60 * 1000;
|
||||
const MAX_SERVER_BACKOFF = 3;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} url The url of the calendar to fetch
|
||||
* @param {number} reloadInterval Time in ms the calendar is fetched again
|
||||
* @param {string[]} excludedEvents An array of words / phrases from event titles that will be excluded from being shown.
|
||||
* @param {number} maximumEntries The maximum number of events fetched.
|
||||
* @param {number} maximumNumberOfDays The maximum number of days an event should be in the future.
|
||||
* @param {object} auth The object containing options for authentication against the calendar.
|
||||
* @param {boolean} includePastEvents If true events from the past maximumNumberOfDays will be fetched too
|
||||
* @param {boolean} selfSignedCert If true, the server certificate is not verified against the list of supplied CAs.
|
||||
* CalendarFetcher - Fetches and parses iCal calendar data with MagicMirror-focused error handling
|
||||
* @class
|
||||
*/
|
||||
const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, includePastEvents, selfSignedCert) {
|
||||
let reloadTimer = null;
|
||||
let events = [];
|
||||
|
||||
let fetchFailedCallback = function () {};
|
||||
let eventsReceivedCallback = function () {};
|
||||
class CalendarFetcher {
|
||||
|
||||
/**
|
||||
* Initiates calendar fetch.
|
||||
* Creates a new CalendarFetcher instance
|
||||
* @param {string} url - The URL of the calendar to fetch
|
||||
* @param {number} reloadInterval - Time in ms between fetches
|
||||
* @param {string[]} excludedEvents - Event titles to exclude
|
||||
* @param {number} maximumEntries - Maximum number of events to return
|
||||
* @param {number} maximumNumberOfDays - Maximum days in the future to fetch
|
||||
* @param {object} auth - Authentication options {method: 'basic'|'bearer', user, pass}
|
||||
* @param {boolean} includePastEvents - Whether to include past events
|
||||
* @param {boolean} selfSignedCert - Whether to accept self-signed certificates
|
||||
*/
|
||||
const fetchCalendar = () => {
|
||||
clearTimeout(reloadTimer);
|
||||
reloadTimer = null;
|
||||
const nodeVersion = Number(process.version.match(/^v(\d+\.\d+)/)[1]);
|
||||
let httpsAgent = null;
|
||||
let headers = {
|
||||
"User-Agent": `Mozilla/5.0 (Node.js ${nodeVersion}) MagicMirror/${global.version}`
|
||||
};
|
||||
constructor (url, reloadInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, includePastEvents, selfSignedCert) {
|
||||
this.url = url;
|
||||
this.reloadInterval = reloadInterval;
|
||||
this.excludedEvents = excludedEvents;
|
||||
this.maximumEntries = maximumEntries;
|
||||
this.maximumNumberOfDays = maximumNumberOfDays;
|
||||
this.auth = auth;
|
||||
this.includePastEvents = includePastEvents;
|
||||
this.selfSignedCert = selfSignedCert;
|
||||
|
||||
if (selfSignedCert) {
|
||||
httpsAgent = new https.Agent({
|
||||
rejectUnauthorized: false
|
||||
});
|
||||
this.events = [];
|
||||
this.reloadTimer = null;
|
||||
this.serverErrorCount = 0;
|
||||
this.lastFetch = null;
|
||||
this.fetchFailedCallback = () => {};
|
||||
this.eventsReceivedCallback = () => {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears any pending reload timer
|
||||
*/
|
||||
clearReloadTimer () {
|
||||
if (this.reloadTimer) {
|
||||
clearTimeout(this.reloadTimer);
|
||||
this.reloadTimer = null;
|
||||
}
|
||||
if (auth) {
|
||||
if (auth.method === "bearer") {
|
||||
headers.Authorization = `Bearer ${auth.pass}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules the next fetch respecting MagicMirror test mode
|
||||
* @param {number} delay - Delay in milliseconds
|
||||
*/
|
||||
scheduleNextFetch (delay) {
|
||||
const nextDelay = Math.max(delay || this.reloadInterval, this.reloadInterval);
|
||||
if (process.env.mmTestMode === "true") {
|
||||
return;
|
||||
}
|
||||
this.reloadTimer = setTimeout(() => this.fetchCalendar(), nextDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the options object for fetch
|
||||
* @returns {object} Options object containing headers (and agent if needed)
|
||||
*/
|
||||
getRequestOptions () {
|
||||
const headers = { "User-Agent": getUserAgent() };
|
||||
const options = { headers };
|
||||
|
||||
if (this.selfSignedCert) {
|
||||
options.agent = new https.Agent({ rejectUnauthorized: false });
|
||||
}
|
||||
|
||||
if (this.auth) {
|
||||
if (this.auth.method === "bearer") {
|
||||
headers.Authorization = `Bearer ${this.auth.pass}`;
|
||||
} else {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${auth.user}:${auth.pass}`).toString("base64")}`;
|
||||
headers.Authorization = `Basic ${Buffer.from(`${this.auth.user}:${this.auth.pass}`).toString("base64")}`;
|
||||
}
|
||||
}
|
||||
|
||||
fetch(url, { headers: headers, agent: httpsAgent })
|
||||
.then(NodeHelper.checkFetchStatus)
|
||||
.then((response) => response.text())
|
||||
.then((responseData) => {
|
||||
let data = [];
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the Retry-After header value
|
||||
* @param {string} retryAfter - The Retry-After header value
|
||||
* @returns {number|null} Milliseconds to wait or null if parsing failed
|
||||
*/
|
||||
parseRetryAfter (retryAfter) {
|
||||
const seconds = Number(retryAfter);
|
||||
if (!Number.isNaN(seconds) && seconds >= 0) {
|
||||
return seconds * 1000;
|
||||
}
|
||||
|
||||
const retryDate = Date.parse(retryAfter);
|
||||
if (!Number.isNaN(retryDate)) {
|
||||
return Math.max(0, retryDate - Date.now());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the retry delay for a non-ok response
|
||||
* @param {Response} response - The fetch Response object
|
||||
* @returns {{delay: number, error: Error}} Error describing the issue and computed retry delay
|
||||
*/
|
||||
getDelayForResponse (response) {
|
||||
const { status, statusText = "" } = response;
|
||||
let delay = this.reloadInterval;
|
||||
|
||||
if (status === 401 || status === 403) {
|
||||
delay = Math.max(this.reloadInterval * 5, THIRTY_MINUTES);
|
||||
Log.error(`${this.url} - Authentication failed (${status}). Waiting ${Math.round(delay / 60000)} minutes before retry.`);
|
||||
} else if (status === 429) {
|
||||
const retryAfter = response.headers.get("retry-after");
|
||||
const parsed = retryAfter ? this.parseRetryAfter(retryAfter) : null;
|
||||
delay = parsed !== null ? Math.max(parsed, this.reloadInterval) : Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES);
|
||||
Log.warn(`${this.url} - Rate limited (429). Retrying in ${Math.round(delay / 60000)} minutes.`);
|
||||
} else if (status >= 500) {
|
||||
this.serverErrorCount = Math.min(this.serverErrorCount + 1, MAX_SERVER_BACKOFF);
|
||||
delay = this.reloadInterval * Math.pow(2, this.serverErrorCount);
|
||||
Log.error(`${this.url} - Server error (${status}). Retry #${this.serverErrorCount} in ${Math.round(delay / 60000)} minutes.`);
|
||||
} else if (status >= 400) {
|
||||
delay = Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES);
|
||||
Log.error(`${this.url} - Client error (${status}). Retrying in ${Math.round(delay / 60000)} minutes.`);
|
||||
} else {
|
||||
Log.error(`${this.url} - Unexpected HTTP status ${status}.`);
|
||||
}
|
||||
|
||||
return {
|
||||
delay,
|
||||
error: new Error(`HTTP ${status} ${statusText}`.trim())
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches and processes calendar data
|
||||
*/
|
||||
async fetchCalendar () {
|
||||
this.clearReloadTimer();
|
||||
|
||||
let nextDelay = this.reloadInterval;
|
||||
try {
|
||||
const response = await fetch(this.url, this.getRequestOptions());
|
||||
if (!response.ok) {
|
||||
const { delay, error } = this.getDelayForResponse(response);
|
||||
nextDelay = delay;
|
||||
this.fetchFailedCallback(this, error);
|
||||
} else {
|
||||
this.serverErrorCount = 0;
|
||||
const responseData = await response.text();
|
||||
try {
|
||||
data = ical.parseICS(responseData);
|
||||
Log.debug(`parsed data=${JSON.stringify(data)}`);
|
||||
events = CalendarFetcherUtils.filterEvents(data, {
|
||||
excludedEvents,
|
||||
includePastEvents,
|
||||
maximumEntries,
|
||||
maximumNumberOfDays
|
||||
const parsed = ical.parseICS(responseData);
|
||||
Log.debug(`Parsed iCal data from ${this.url} with ${Object.keys(parsed).length} entries.`);
|
||||
this.events = CalendarFetcherUtils.filterEvents(parsed, {
|
||||
excludedEvents: this.excludedEvents,
|
||||
includePastEvents: this.includePastEvents,
|
||||
maximumEntries: this.maximumEntries,
|
||||
maximumNumberOfDays: this.maximumNumberOfDays
|
||||
});
|
||||
this.lastFetch = Date.now();
|
||||
this.broadcastEvents();
|
||||
} catch (error) {
|
||||
fetchFailedCallback(this, error);
|
||||
scheduleTimer();
|
||||
return;
|
||||
Log.error(`${this.url} - iCal parsing failed: ${error.message}`);
|
||||
this.fetchFailedCallback(this, error);
|
||||
}
|
||||
this.broadcastEvents();
|
||||
scheduleTimer();
|
||||
})
|
||||
.catch((error) => {
|
||||
fetchFailedCallback(this, error);
|
||||
scheduleTimer();
|
||||
});
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error(`${this.url} - Fetch failed: ${error.message}`);
|
||||
this.fetchFailedCallback(this, error);
|
||||
}
|
||||
|
||||
this.scheduleNextFetch(nextDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the timer for the next update.
|
||||
* Check if enough time has passed since the last fetch to warrant a new one.
|
||||
* Uses reloadInterval as the threshold to respect user's configured fetchInterval.
|
||||
* @returns {boolean} True if a new fetch should be performed
|
||||
*/
|
||||
const scheduleTimer = function () {
|
||||
clearTimeout(reloadTimer);
|
||||
reloadTimer = setTimeout(function () {
|
||||
fetchCalendar();
|
||||
}, reloadInterval);
|
||||
};
|
||||
|
||||
/* public methods */
|
||||
shouldRefetch () {
|
||||
if (!this.lastFetch) {
|
||||
return true;
|
||||
}
|
||||
const timeSinceLastFetch = Date.now() - this.lastFetch;
|
||||
return timeSinceLastFetch >= this.reloadInterval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate fetchCalendar();
|
||||
* Broadcasts the current events to listeners
|
||||
*/
|
||||
this.startFetch = function () {
|
||||
fetchCalendar();
|
||||
};
|
||||
broadcastEvents () {
|
||||
Log.info(`Broadcasting ${this.events.length} events from ${this.url}.`);
|
||||
this.eventsReceivedCallback(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast the existing events.
|
||||
* Sets the callback for successful event fetches
|
||||
* @param {(fetcher: CalendarFetcher) => void} callback - Called when events are received
|
||||
*/
|
||||
this.broadcastEvents = function () {
|
||||
Log.info(`Calendar-Fetcher: Broadcasting ${events.length} events from ${url}.`);
|
||||
eventsReceivedCallback(this);
|
||||
};
|
||||
onReceive (callback) {
|
||||
this.eventsReceivedCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the on success callback
|
||||
* @param {Function} callback The on success callback.
|
||||
* Sets the callback for fetch failures
|
||||
* @param {(fetcher: CalendarFetcher, error: Error) => void} callback - Called when a fetch fails
|
||||
*/
|
||||
this.onReceive = function (callback) {
|
||||
eventsReceivedCallback = callback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the on error callback
|
||||
* @param {Function} callback The on error callback.
|
||||
*/
|
||||
this.onError = function (callback) {
|
||||
fetchFailedCallback = callback;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the url of this fetcher.
|
||||
* @returns {string} The url of this fetcher.
|
||||
*/
|
||||
this.url = function () {
|
||||
return url;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns current available events for this fetcher.
|
||||
* @returns {object[]} The current available events for this fetcher.
|
||||
*/
|
||||
this.events = function () {
|
||||
return events;
|
||||
};
|
||||
};
|
||||
onError (callback) {
|
||||
this.fetchFailedCallback = callback;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CalendarFetcher;
|
||||
|
||||
@@ -1,114 +1,93 @@
|
||||
/**
|
||||
* @external Moment
|
||||
*/
|
||||
const path = require("node:path");
|
||||
const moment = require("moment");
|
||||
const moment = require("moment-timezone");
|
||||
|
||||
const zoneTable = require(path.join(__dirname, "windowsZones.json"));
|
||||
const Log = require("../../../js/logger");
|
||||
const Log = require("logger");
|
||||
|
||||
const CalendarFetcherUtils = {
|
||||
|
||||
/**
|
||||
* Calculate the time correction, either dst/std or full day in cases where
|
||||
* utc time is day before plus offset
|
||||
* @param {object} event the event which needs adjustment
|
||||
* @param {Date} date the date on which this event happens
|
||||
* @returns {number} the necessary adjustment in hours
|
||||
* Determine based on the title of an event if it should be excluded from the list of events
|
||||
* @param {object} config the global config
|
||||
* @param {string} title the title of the event
|
||||
* @returns {object} excluded: true if the event should be excluded, false otherwise
|
||||
* until: the date until the event should be excluded.
|
||||
*/
|
||||
calculateTimezoneAdjustment (event, date) {
|
||||
let adjustHours = 0;
|
||||
// if a timezone was specified
|
||||
if (!event.start.tz) {
|
||||
Log.debug(" if no tz, guess based on now");
|
||||
event.start.tz = moment.tz.guess();
|
||||
}
|
||||
Log.debug(`initial tz=${event.start.tz}`);
|
||||
|
||||
// if there is a start date specified
|
||||
if (event.start.tz) {
|
||||
// if this is a windows timezone
|
||||
if (event.start.tz.includes(" ")) {
|
||||
// use the lookup table to get theIANA name as moment and date don't know MS timezones
|
||||
let tz = CalendarFetcherUtils.getIanaTZFromMS(event.start.tz);
|
||||
Log.debug(`corrected TZ=${tz}`);
|
||||
// watch out for unregistered windows timezone names
|
||||
// if we had a successful lookup
|
||||
if (tz) {
|
||||
// change the timezone to the IANA name
|
||||
event.start.tz = tz;
|
||||
// Log.debug("corrected timezone="+event.start.tz)
|
||||
}
|
||||
}
|
||||
Log.debug(`corrected tz=${event.start.tz}`);
|
||||
let current_offset = 0; // offset from TZ string or calculated
|
||||
let mm = 0; // date with tz or offset
|
||||
let start_offset = 0; // utc offset of created with tz
|
||||
// if there is still an offset, lookup failed, use it
|
||||
if (event.start.tz.startsWith("(")) {
|
||||
const regex = /[+|-]\d*:\d*/;
|
||||
const start_offsetString = event.start.tz.match(regex).toString().split(":");
|
||||
let start_offset = parseInt(start_offsetString[0]);
|
||||
start_offset *= event.start.tz[1] === "-" ? -1 : 1;
|
||||
adjustHours = start_offset;
|
||||
Log.debug(`defined offset=${start_offset} hours`);
|
||||
current_offset = start_offset;
|
||||
event.start.tz = "";
|
||||
Log.debug(`ical offset=${current_offset} date=${date}`);
|
||||
mm = moment(date);
|
||||
let x = moment(new Date()).utcOffset();
|
||||
Log.debug(`net mins=${current_offset * 60 - x}`);
|
||||
|
||||
mm = mm.add(x - current_offset * 60, "minutes");
|
||||
adjustHours = (current_offset * 60 - x) / 60;
|
||||
event.start = mm.toDate();
|
||||
Log.debug(`adjusted date=${event.start}`);
|
||||
} else {
|
||||
// get the start time in that timezone
|
||||
let es = moment(event.start);
|
||||
// check for start date prior to start of daylight changing date
|
||||
if (es.format("YYYY") < 2007) {
|
||||
es.set("year", 2013); // if so, use a closer date
|
||||
}
|
||||
Log.debug(`start date/time=${es.toDate()}`);
|
||||
start_offset = moment.tz(es, event.start.tz).utcOffset();
|
||||
Log.debug(`start offset=${start_offset}`);
|
||||
|
||||
Log.debug(`start date/time w tz =${moment.tz(moment(event.start), event.start.tz).toDate()}`);
|
||||
|
||||
// get the specified date in that timezone
|
||||
mm = moment.tz(moment(date), event.start.tz);
|
||||
Log.debug(`event date=${mm.toDate()}`);
|
||||
current_offset = mm.utcOffset();
|
||||
}
|
||||
Log.debug(`event offset=${current_offset} hour=${mm.format("H")} event date=${mm.toDate()}`);
|
||||
|
||||
// if the offset is greater than 0, east of london
|
||||
if (current_offset !== start_offset) {
|
||||
// big offset
|
||||
Log.debug("offset");
|
||||
let h = parseInt(mm.format("H"));
|
||||
// check if the event time is less than the offset
|
||||
if (h > 0 && h < Math.abs(current_offset) / 60) {
|
||||
// if so, rrule created a wrong date (utc day, oops, with utc yesterday adjusted time)
|
||||
// we need to fix that
|
||||
//adjustHours = 24;
|
||||
// Log.debug("adjusting date")
|
||||
}
|
||||
//-300 > -240
|
||||
//if (Math.abs(current_offset) > Math.abs(start_offset)){
|
||||
if (current_offset > start_offset) {
|
||||
adjustHours -= 1;
|
||||
Log.debug("adjust down 1 hour dst change");
|
||||
//} else if (Math.abs(current_offset) < Math.abs(start_offset)) {
|
||||
} else if (current_offset < start_offset) {
|
||||
adjustHours += 1;
|
||||
Log.debug("adjust up 1 hour dst change");
|
||||
}
|
||||
shouldEventBeExcluded (config, title) {
|
||||
for (const filterConfig of config.excludedEvents) {
|
||||
const match = CalendarFetcherUtils.checkEventAgainstFilter(title, filterConfig);
|
||||
if (match) {
|
||||
return {
|
||||
excluded: !match.until,
|
||||
until: match.until
|
||||
};
|
||||
}
|
||||
}
|
||||
Log.debug(`adjustHours=${adjustHours}`);
|
||||
return adjustHours;
|
||||
|
||||
return {
|
||||
excluded: false,
|
||||
until: null
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get local timezone.
|
||||
* This method makes it easier to test if different timezones cause problems by changing this implementation.
|
||||
* @returns {string} timezone
|
||||
*/
|
||||
getLocalTimezone () {
|
||||
return moment.tz.guess();
|
||||
},
|
||||
|
||||
/**
|
||||
* This function returns a list of moments for a recurring event.
|
||||
* @param {object} event the current event which is a recurring event
|
||||
* @param {moment.Moment} pastLocalMoment The past date to search for recurring events
|
||||
* @param {moment.Moment} futureLocalMoment The future date to search for recurring events
|
||||
* @param {number} durationInMs the duration of the event, this is used to take into account currently running events
|
||||
* @returns {moment.Moment[]} All moments for the recurring event
|
||||
*/
|
||||
getMomentsFromRecurringEvent (event, pastLocalMoment, futureLocalMoment, durationInMs) {
|
||||
const rule = event.rrule;
|
||||
const isFullDayEvent = CalendarFetcherUtils.isFullDayEvent(event);
|
||||
const eventTimezone = event.start.tz || CalendarFetcherUtils.getLocalTimezone();
|
||||
|
||||
// rrule.js interprets years < 1900 as offsets from 1900, causing issues with some birthday calendars
|
||||
if (rule.origOptions?.dtstart?.getFullYear() < 1900) {
|
||||
rule.origOptions.dtstart.setFullYear(1900);
|
||||
}
|
||||
if (rule.options?.dtstart?.getFullYear() < 1900) {
|
||||
rule.options.dtstart.setFullYear(1900);
|
||||
}
|
||||
|
||||
// Expand search window to include ongoing events
|
||||
const oneDayInMs = 24 * 60 * 60 * 1000;
|
||||
const searchFromDate = pastLocalMoment.clone().subtract(Math.max(durationInMs, oneDayInMs), "milliseconds").toDate();
|
||||
const searchToDate = futureLocalMoment.clone().add(1, "days").toDate();
|
||||
|
||||
// For all-day events, extend "until" to end of day to include the final occurrence
|
||||
if (isFullDayEvent && rule.options?.until) {
|
||||
rule.options.until = moment(rule.options.until).endOf("day").toDate();
|
||||
}
|
||||
|
||||
// Clear tzid to prevent rrule.js from double-adjusting times
|
||||
if (rule.options) {
|
||||
rule.options.tzid = null;
|
||||
}
|
||||
|
||||
const dates = rule.between(searchFromDate, searchToDate, true) || [];
|
||||
|
||||
// Convert dates to moments in the appropriate timezone
|
||||
// rrule.js returns UTC dates with tzid cleared, so we interpret them in the event's original timezone
|
||||
return dates.map((date) => {
|
||||
if (isFullDayEvent) {
|
||||
// For all-day events, anchor to calendar day in event's timezone
|
||||
return moment.tz(date, eventTimezone).startOf("day");
|
||||
}
|
||||
// For timed events, preserve the time in the event's original timezone
|
||||
return moment.tz(date, "UTC").tz(eventTimezone, true);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -120,34 +99,33 @@ const CalendarFetcherUtils = {
|
||||
filterEvents (data, config) {
|
||||
const newEvents = [];
|
||||
|
||||
// limitFunction doesn't do much limiting, see comment re: the dates
|
||||
// array in rrule section below as to why we need to do the filtering
|
||||
// ourselves
|
||||
const limitFunction = function (date, i) {
|
||||
return true;
|
||||
};
|
||||
|
||||
const eventDate = function (event, time) {
|
||||
return CalendarFetcherUtils.isFullDayEvent(event) ? moment(event[time]).startOf("day") : moment(event[time]);
|
||||
const startMoment = event[time].tz ? moment.tz(event[time], event[time].tz) : moment.tz(event[time], CalendarFetcherUtils.getLocalTimezone());
|
||||
return CalendarFetcherUtils.isFullDayEvent(event) ? startMoment.startOf("day") : startMoment;
|
||||
};
|
||||
|
||||
Log.debug(`There are ${Object.entries(data).length} calendar entries.`);
|
||||
|
||||
const now = new Date(Date.now());
|
||||
const todayLocal = moment(now).startOf("day").toDate();
|
||||
const futureLocalDate
|
||||
= moment(now)
|
||||
const now = moment();
|
||||
const pastLocalMoment = config.includePastEvents ? now.clone().startOf("day").subtract(config.maximumNumberOfDays, "days") : now;
|
||||
const futureLocalMoment
|
||||
= now
|
||||
.clone()
|
||||
.startOf("day")
|
||||
.add(config.maximumNumberOfDays, "days")
|
||||
.subtract(1, "seconds") // Subtract 1 second so that events that start on the middle of the night will not repeat.
|
||||
.toDate();
|
||||
// Subtract 1 second so that events that start on the middle of the night will not repeat.
|
||||
.subtract(1, "seconds");
|
||||
|
||||
Object.entries(data).forEach(([key, event]) => {
|
||||
Log.debug("Processing entry...");
|
||||
let pastLocalDate = todayLocal;
|
||||
|
||||
if (config.includePastEvents) {
|
||||
pastLocalDate = moment(now).startOf("day").subtract(config.maximumNumberOfDays, "days").toDate();
|
||||
const title = CalendarFetcherUtils.getTitleFromEvent(event);
|
||||
Log.debug(`title: ${title}`);
|
||||
|
||||
// Return quickly if event should be excluded.
|
||||
let { excluded, eventFilterUntil } = this.shouldEventBeExcluded(config, title);
|
||||
if (excluded) {
|
||||
return;
|
||||
}
|
||||
|
||||
// FIXME: Ugly fix to solve the facebook birthday issue.
|
||||
@@ -160,322 +138,79 @@ const CalendarFetcherUtils = {
|
||||
}
|
||||
|
||||
if (event.type === "VEVENT") {
|
||||
Log.debug(`Event:\n${JSON.stringify(event)}`);
|
||||
let startMoment = eventDate(event, "start");
|
||||
let endMoment;
|
||||
Log.debug(`Event:\n${JSON.stringify(event, null, 2)}`);
|
||||
let eventStartMoment = eventDate(event, "start");
|
||||
let eventEndMoment;
|
||||
|
||||
if (typeof event.end !== "undefined") {
|
||||
endMoment = eventDate(event, "end");
|
||||
eventEndMoment = eventDate(event, "end");
|
||||
} else if (typeof event.duration !== "undefined") {
|
||||
endMoment = startMoment.clone().add(moment.duration(event.duration));
|
||||
eventEndMoment = eventStartMoment.clone().add(moment.duration(event.duration));
|
||||
} else {
|
||||
if (!isFacebookBirthday) {
|
||||
// make copy of start date, separate storage area
|
||||
endMoment = moment(startMoment.valueOf());
|
||||
eventEndMoment = eventStartMoment.clone();
|
||||
} else {
|
||||
endMoment = moment(startMoment).add(1, "days");
|
||||
eventEndMoment = eventStartMoment.clone().add(1, "days");
|
||||
}
|
||||
}
|
||||
|
||||
Log.debug(`start: ${startMoment.toDate()}`);
|
||||
Log.debug(`end:: ${endMoment.toDate()}`);
|
||||
Log.debug(`start: ${eventStartMoment.toDate()}`);
|
||||
Log.debug(`end: ${eventEndMoment.toDate()}`);
|
||||
|
||||
// Calculate the duration of the event for use with recurring events.
|
||||
const durationMs = endMoment.valueOf() - startMoment.valueOf();
|
||||
const durationMs = eventEndMoment.valueOf() - eventStartMoment.valueOf();
|
||||
Log.debug(`duration: ${durationMs}`);
|
||||
|
||||
// FIXME: Since the parsed json object from node-ical comes with time information
|
||||
// this check could be removed (?)
|
||||
if (event.start.length === 8) {
|
||||
startMoment = startMoment.startOf("day");
|
||||
}
|
||||
|
||||
const title = CalendarFetcherUtils.getTitleFromEvent(event);
|
||||
Log.debug(`title: ${title}`);
|
||||
|
||||
let excluded = false,
|
||||
dateFilter = null;
|
||||
|
||||
for (let f in config.excludedEvents) {
|
||||
let filter = config.excludedEvents[f],
|
||||
testTitle = title.toLowerCase(),
|
||||
until = null,
|
||||
useRegex = false,
|
||||
regexFlags = "g";
|
||||
|
||||
if (filter instanceof Object) {
|
||||
if (typeof filter.until !== "undefined") {
|
||||
until = filter.until;
|
||||
}
|
||||
|
||||
if (typeof filter.regex !== "undefined") {
|
||||
useRegex = filter.regex;
|
||||
}
|
||||
|
||||
// If additional advanced filtering is added in, this section
|
||||
// must remain last as we overwrite the filter object with the
|
||||
// filterBy string
|
||||
if (filter.caseSensitive) {
|
||||
filter = filter.filterBy;
|
||||
testTitle = title;
|
||||
} else if (useRegex) {
|
||||
filter = filter.filterBy;
|
||||
testTitle = title;
|
||||
regexFlags += "i";
|
||||
} else {
|
||||
filter = filter.filterBy.toLowerCase();
|
||||
}
|
||||
} else {
|
||||
filter = filter.toLowerCase();
|
||||
}
|
||||
|
||||
if (CalendarFetcherUtils.titleFilterApplies(testTitle, filter, useRegex, regexFlags)) {
|
||||
if (until) {
|
||||
dateFilter = until;
|
||||
} else {
|
||||
excluded = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (excluded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const location = event.location || false;
|
||||
const geo = event.geo || false;
|
||||
const description = event.description || false;
|
||||
|
||||
let instances = [];
|
||||
if (event.rrule && typeof event.rrule !== "undefined" && !isFacebookBirthday) {
|
||||
const rule = event.rrule;
|
||||
|
||||
const pastMoment = moment(pastLocalDate);
|
||||
const futureMoment = moment(futureLocalDate);
|
||||
|
||||
// can cause problems with e.g. birthdays before 1900
|
||||
if ((rule.options && rule.origOptions && rule.origOptions.dtstart && rule.origOptions.dtstart.getFullYear() < 1900) || (rule.options && rule.options.dtstart && rule.options.dtstart.getFullYear() < 1900)) {
|
||||
rule.origOptions.dtstart.setYear(1900);
|
||||
rule.options.dtstart.setYear(1900);
|
||||
}
|
||||
|
||||
// For recurring events, get the set of start dates that fall within the range
|
||||
// of dates we're looking for.
|
||||
// kblankenship1989 - to fix issue #1798, converting all dates to locale time first, then converting back to UTC time
|
||||
let pastLocal;
|
||||
let futureLocal;
|
||||
if (CalendarFetcherUtils.isFullDayEvent(event)) {
|
||||
Log.debug("fullday");
|
||||
// if full day event, only use the date part of the ranges
|
||||
pastLocal = pastMoment.toDate();
|
||||
futureLocal = futureMoment.toDate();
|
||||
|
||||
Log.debug(`pastLocal: ${pastLocal}`);
|
||||
Log.debug(`futureLocal: ${futureLocal}`);
|
||||
} else {
|
||||
// if we want past events
|
||||
if (config.includePastEvents) {
|
||||
// use the calculated past time for the between from
|
||||
pastLocal = pastMoment.toDate();
|
||||
} else {
|
||||
// otherwise use NOW.. cause we shouldn't use any before now
|
||||
pastLocal = moment(now).toDate(); //now
|
||||
}
|
||||
futureLocal = futureMoment.toDate(); // future
|
||||
}
|
||||
Log.debug(`Search for recurring events between: ${pastLocal} and ${futureLocal}`);
|
||||
const hasByWeekdayRule = rule.options.byweekday !== undefined && rule.options.byweekday !== null;
|
||||
const oneDayInMs = 24 * 60 * 60 * 1000;
|
||||
Log.debug(`RRule: ${rule.toString()}`);
|
||||
rule.options.tzid = null; // RRule gets *very* confused with timezones
|
||||
let dates = rule.between(new Date(pastLocal.valueOf() - oneDayInMs), new Date(futureLocal.valueOf() + oneDayInMs), true, () => { return true; });
|
||||
Log.debug(`Title: ${event.summary}, with dates: ${JSON.stringify(dates)}`);
|
||||
dates = dates.filter((d) => {
|
||||
if (JSON.stringify(d) === "null") return false;
|
||||
else return true;
|
||||
});
|
||||
|
||||
// RRule can generate dates with an incorrect recurrence date. Process the array here and apply date correction.
|
||||
if (hasByWeekdayRule) {
|
||||
Log.debug("Rule has byweekday, checking for correction");
|
||||
dates.forEach((date, index, arr) => {
|
||||
// NOTE: getTimezoneOffset() is negative of the expected value. For America/Los_Angeles under DST (GMT-7),
|
||||
// this value is +420. For Australia/Sydney under DST (GMT+11), this value is -660.
|
||||
const tzOffset = date.getTimezoneOffset() / 60;
|
||||
const hour = date.getHours();
|
||||
if ((tzOffset < 0) && (hour < -tzOffset)) { // east of GMT
|
||||
Log.debug(`East of GMT (tzOffset: ${tzOffset}) and hour=${hour} < ${-tzOffset}, Subtracting 1 day from ${date}`);
|
||||
arr[index] = new Date(date.valueOf() - oneDayInMs);
|
||||
} else if ((tzOffset > 0) && (hour >= (24 - tzOffset))) { // west of GMT
|
||||
Log.debug(`West of GMT (tzOffset: ${tzOffset}) and hour=${hour} >= 24-${tzOffset}, Adding 1 day to ${date}`);
|
||||
arr[index] = new Date(date.valueOf() + oneDayInMs);
|
||||
}
|
||||
});
|
||||
// Adjusting the dates could push it beyond the 'until' date, so filter those out here.
|
||||
if (rule.options.until !== null) {
|
||||
dates = dates.filter((date) => {
|
||||
return date.valueOf() <= rule.options.until.valueOf();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The dates array from rrule can be confused by DST. If the event was created during DST and we
|
||||
// are querying a date range during non-DST, rrule can have the incorrect time for the date range.
|
||||
// Reprocess the array here computing and applying the time offset.
|
||||
dates.forEach((date, index, arr) => {
|
||||
let adjustHours = CalendarFetcherUtils.calculateTimezoneAdjustment(event, date);
|
||||
if (adjustHours !== 0) {
|
||||
Log.debug(`Applying timezone adjustment hours=${adjustHours} to ${date}`);
|
||||
arr[index] = new Date(date.valueOf() + (adjustHours * 60 * 60 * 1000));
|
||||
}
|
||||
});
|
||||
|
||||
// The "dates" array contains the set of dates within our desired date range range that are valid
|
||||
// for the recurrence rule. *However*, it's possible for us to have a specific recurrence that
|
||||
// had its date changed from outside the range to inside the range. For the time being,
|
||||
// we'll handle this by adding *all* recurrence entries into the set of dates that we check,
|
||||
// because the logic below will filter out any recurrences that don't actually belong within
|
||||
// our display range.
|
||||
// Would be great if there was a better way to handle this.
|
||||
Log.debug(`event.recurrences: ${event.recurrences}`);
|
||||
if (event.recurrences !== undefined) {
|
||||
for (let dateKey in event.recurrences) {
|
||||
// Only add dates that weren't already in the range we added from the rrule so that
|
||||
// we don't double-add those events.
|
||||
let d = new Date(dateKey);
|
||||
if (!moment(d).isBetween(pastMoment, futureMoment)) {
|
||||
dates.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lastly, sometimes rrule doesn't include the event.start even if it is in the requested range. Ensure
|
||||
// inclusion here. Unfortunately dates.includes() doesn't find it so we have to do forEach().
|
||||
{
|
||||
let found = false;
|
||||
dates.forEach((d) => { if (d.valueOf() === event.start.valueOf()) found = true; });
|
||||
if (!found) {
|
||||
Log.debug(`event.start=${event.start} was not included in results from rrule; adding`);
|
||||
dates.splice(0, 0, event.start);
|
||||
}
|
||||
}
|
||||
|
||||
// Loop through the set of date entries to see which recurrences should be added to our event list.
|
||||
for (let d in dates) {
|
||||
let date = dates[d];
|
||||
let curEvent = event;
|
||||
let curDurationMs = durationMs;
|
||||
let showRecurrence = true;
|
||||
|
||||
startMoment = moment(date);
|
||||
|
||||
// Remove the time information of each date by using its substring, using the following method:
|
||||
// .toISOString().substring(0,10).
|
||||
// since the date is given as ISOString with YYYY-MM-DDTHH:MM:SS.SSSZ
|
||||
// (see https://momentjs.com/docs/#/displaying/as-iso-string/).
|
||||
// This must be done after `date` is adjusted
|
||||
const dateKey = date.toISOString().substring(0, 10);
|
||||
|
||||
// For each date that we're checking, it's possible that there is a recurrence override for that one day.
|
||||
if (curEvent.recurrences !== undefined && curEvent.recurrences[dateKey] !== undefined) {
|
||||
// We found an override, so for this recurrence, use a potentially different title, start date, and duration.
|
||||
curEvent = curEvent.recurrences[dateKey];
|
||||
startMoment = moment(curEvent.start);
|
||||
curDurationMs = curEvent.end.valueOf() - startMoment.valueOf();
|
||||
}
|
||||
// If there's no recurrence override, check for an exception date. Exception dates represent exceptions to the rule.
|
||||
else if (curEvent.exdate !== undefined && curEvent.exdate[dateKey] !== undefined) {
|
||||
// This date is an exception date, which means we should skip it in the recurrence pattern.
|
||||
showRecurrence = false;
|
||||
}
|
||||
Log.debug(`duration: ${curDurationMs}`);
|
||||
|
||||
endMoment = moment(startMoment.valueOf() + curDurationMs);
|
||||
if (startMoment.valueOf() === endMoment.valueOf()) {
|
||||
endMoment = endMoment.endOf("day");
|
||||
}
|
||||
|
||||
const recurrenceTitle = CalendarFetcherUtils.getTitleFromEvent(curEvent);
|
||||
|
||||
// If this recurrence ends before the start of the date range, or starts after the end of the date range, don"t add
|
||||
// it to the event list.
|
||||
if (endMoment.isBefore(pastLocal) || startMoment.isAfter(futureLocal)) {
|
||||
showRecurrence = false;
|
||||
}
|
||||
|
||||
if (CalendarFetcherUtils.timeFilterApplies(now, endMoment, dateFilter)) {
|
||||
showRecurrence = false;
|
||||
}
|
||||
|
||||
if (showRecurrence === true) {
|
||||
Log.debug(`saving event: ${description}`);
|
||||
newEvents.push({
|
||||
title: recurrenceTitle,
|
||||
startDate: startMoment.format("x"),
|
||||
endDate: endMoment.format("x"),
|
||||
fullDayEvent: CalendarFetcherUtils.isFullDayEvent(event),
|
||||
recurringEvent: true,
|
||||
class: event.class,
|
||||
firstYear: event.start.getFullYear(),
|
||||
location: location,
|
||||
geo: geo,
|
||||
description: description
|
||||
});
|
||||
}
|
||||
}
|
||||
// End recurring event parsing.
|
||||
instances = CalendarFetcherUtils.expandRecurringEvent(event, pastLocalMoment, futureLocalMoment, durationMs);
|
||||
} else {
|
||||
// Single event.
|
||||
const fullDayEvent = isFacebookBirthday ? true : CalendarFetcherUtils.isFullDayEvent(event);
|
||||
// Log.debug("full day event")
|
||||
|
||||
// if the start and end are the same, then make end the 'end of day' value (start is at 00:00:00)
|
||||
if (fullDayEvent && startMoment.valueOf() === endMoment.valueOf()) {
|
||||
endMoment = endMoment.endOf("day");
|
||||
let end = eventEndMoment;
|
||||
if (fullDayEvent && eventStartMoment.valueOf() === end.valueOf()) {
|
||||
end = end.endOf("day");
|
||||
}
|
||||
|
||||
if (config.includePastEvents) {
|
||||
// Past event is too far in the past, so skip.
|
||||
if (endMoment < pastLocalDate) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// It's not a fullday event, and it is in the past, so skip.
|
||||
if (!fullDayEvent && endMoment < now) {
|
||||
return;
|
||||
}
|
||||
instances.push({
|
||||
event: event,
|
||||
startMoment: eventStartMoment,
|
||||
endMoment: end,
|
||||
isRecurring: false
|
||||
});
|
||||
}
|
||||
|
||||
// It's a fullday event, and it is before today, So skip.
|
||||
if (fullDayEvent && endMoment <= todayLocal) {
|
||||
return;
|
||||
}
|
||||
for (const instance of instances) {
|
||||
const { event: instanceEvent, startMoment, endMoment, isRecurring } = instance;
|
||||
|
||||
// Filter logic
|
||||
if (endMoment.isBefore(pastLocalMoment) || startMoment.isAfter(futureLocalMoment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// It exceeds the maximumNumberOfDays limit, so skip.
|
||||
if (startMoment > futureLocalDate) {
|
||||
return;
|
||||
if (CalendarFetcherUtils.timeFilterApplies(now, endMoment, eventFilterUntil)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (CalendarFetcherUtils.timeFilterApplies(now, endMoment, dateFilter)) {
|
||||
return;
|
||||
}
|
||||
const title = CalendarFetcherUtils.getTitleFromEvent(instanceEvent);
|
||||
const fullDay = isFacebookBirthday ? true : CalendarFetcherUtils.isFullDayEvent(event);
|
||||
|
||||
// get correction for date saving and dst change between now and then
|
||||
let adjustHours = CalendarFetcherUtils.calculateTimezoneAdjustment(event, startMoment.toDate());
|
||||
// This shouldn't happen
|
||||
if (adjustHours) {
|
||||
Log.warn(`Unexpected timezone adjustment of ${adjustHours} hours on non-recurring event`);
|
||||
}
|
||||
// Every thing is good. Add it to the list.
|
||||
Log.debug(`saving event: ${title}`);
|
||||
newEvents.push({
|
||||
title: title,
|
||||
startDate: startMoment.add(adjustHours, "hours").format("x"),
|
||||
endDate: endMoment.add(adjustHours, "hours").format("x"),
|
||||
fullDayEvent: fullDayEvent,
|
||||
startDate: startMoment.format("x"),
|
||||
endDate: endMoment.format("x"),
|
||||
fullDayEvent: fullDay,
|
||||
recurringEvent: isRecurring,
|
||||
class: event.class,
|
||||
location: location,
|
||||
geo: geo,
|
||||
description: description
|
||||
firstYear: event.start.getFullYear(),
|
||||
location: instanceEvent.location || location,
|
||||
geo: instanceEvent.geo || geo,
|
||||
description: instanceEvent.description || description
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -488,18 +223,6 @@ const CalendarFetcherUtils = {
|
||||
return newEvents;
|
||||
},
|
||||
|
||||
/**
|
||||
* Lookup iana tz from windows
|
||||
* @param {string} msTZName the timezone name to lookup
|
||||
* @returns {string|null} the iana name or null of none is found
|
||||
*/
|
||||
getIanaTZFromMS (msTZName) {
|
||||
// Get hash entry
|
||||
const he = zoneTable[msTZName];
|
||||
// If found return iana name, else null
|
||||
return he ? he.iana[0] : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets the title from the event.
|
||||
* @param {object} event The event object to check.
|
||||
@@ -539,8 +262,8 @@ const CalendarFetcherUtils = {
|
||||
|
||||
/**
|
||||
* Determines if the user defined time filter should apply
|
||||
* @param {Date} now Date object using previously created object for consistency
|
||||
* @param {Moment} endDate Moment object representing the event end date
|
||||
* @param {moment.Moment} now Date object using previously created object for consistency
|
||||
* @param {moment.Moment} endDate Moment object representing the event end date
|
||||
* @param {string} filter The time to subtract from the end date to determine if an event should be shown
|
||||
* @returns {boolean} True if the event should be filtered out, false otherwise
|
||||
*/
|
||||
@@ -551,7 +274,7 @@ const CalendarFetcherUtils = {
|
||||
increment = until[1].slice(-1) === "s" ? until[1] : `${until[1]}s`, // Massage the data for moment js
|
||||
filterUntil = moment(endDate.format()).subtract(value, increment);
|
||||
|
||||
return now < filterUntil.toDate();
|
||||
return now < filterUntil;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -577,6 +300,106 @@ const CalendarFetcherUtils = {
|
||||
} else {
|
||||
return title.includes(filter);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Expands a recurring event into individual event instances.
|
||||
* @param {object} event The recurring event object
|
||||
* @param {moment.Moment} pastLocalMoment The past date limit
|
||||
* @param {moment.Moment} futureLocalMoment The future date limit
|
||||
* @param {number} durationMs The duration of the event in milliseconds
|
||||
* @returns {object[]} Array of event instances
|
||||
*/
|
||||
expandRecurringEvent (event, pastLocalMoment, futureLocalMoment, durationMs) {
|
||||
const moments = CalendarFetcherUtils.getMomentsFromRecurringEvent(event, pastLocalMoment, futureLocalMoment, durationMs);
|
||||
const instances = [];
|
||||
|
||||
for (const startMoment of moments) {
|
||||
let curEvent = event;
|
||||
let showRecurrence = true;
|
||||
let recurringEventStartMoment = startMoment.clone().tz(CalendarFetcherUtils.getLocalTimezone());
|
||||
let recurringEventEndMoment = recurringEventStartMoment.clone().add(durationMs, "ms");
|
||||
|
||||
const dateKey = recurringEventStartMoment.tz("UTC").format("YYYY-MM-DD");
|
||||
|
||||
// Check for overrides
|
||||
if (curEvent.recurrences !== undefined) {
|
||||
if (curEvent.recurrences[dateKey] !== undefined) {
|
||||
curEvent = curEvent.recurrences[dateKey];
|
||||
// Re-calculate start/end based on override
|
||||
const start = curEvent.start;
|
||||
const end = curEvent.end;
|
||||
const localTimezone = CalendarFetcherUtils.getLocalTimezone();
|
||||
|
||||
recurringEventStartMoment = (start.tz ? moment(start).tz(start.tz) : moment(start)).tz(localTimezone);
|
||||
recurringEventEndMoment = (end.tz ? moment(end).tz(end.tz) : moment(end)).tz(localTimezone);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for exceptions
|
||||
if (curEvent.exdate !== undefined) {
|
||||
if (curEvent.exdate[dateKey] !== undefined) {
|
||||
showRecurrence = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (recurringEventStartMoment.valueOf() === recurringEventEndMoment.valueOf()) {
|
||||
recurringEventEndMoment = recurringEventEndMoment.endOf("day");
|
||||
}
|
||||
|
||||
if (showRecurrence) {
|
||||
instances.push({
|
||||
event: curEvent,
|
||||
startMoment: recurringEventStartMoment,
|
||||
endMoment: recurringEventEndMoment,
|
||||
isRecurring: true
|
||||
});
|
||||
}
|
||||
}
|
||||
return instances;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if an event title matches a specific filter configuration.
|
||||
* @param {string} title The event title to check
|
||||
* @param {string|object} filterConfig The filter configuration (string or object)
|
||||
* @returns {object|null} Object with {until: string|null} if matched, null otherwise
|
||||
*/
|
||||
checkEventAgainstFilter (title, filterConfig) {
|
||||
let filter = filterConfig;
|
||||
let testTitle = title.toLowerCase();
|
||||
let until = null;
|
||||
let useRegex = false;
|
||||
let regexFlags = "g";
|
||||
|
||||
if (filter instanceof Object) {
|
||||
if (typeof filter.until !== "undefined") {
|
||||
until = filter.until;
|
||||
}
|
||||
|
||||
if (typeof filter.regex !== "undefined") {
|
||||
useRegex = filter.regex;
|
||||
}
|
||||
|
||||
if (filter.caseSensitive) {
|
||||
filter = filter.filterBy;
|
||||
testTitle = title;
|
||||
} else if (useRegex) {
|
||||
filter = filter.filterBy;
|
||||
testTitle = title;
|
||||
regexFlags += "i";
|
||||
} else {
|
||||
filter = filter.filterBy.toLowerCase();
|
||||
}
|
||||
} else {
|
||||
filter = filter.toLowerCase();
|
||||
}
|
||||
|
||||
if (CalendarFetcherUtils.titleFilterApplies(testTitle, filter, useRegex, regexFlags)) {
|
||||
return { until };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ const CalendarUtils = {
|
||||
* @param {string} title The title to transform.
|
||||
* @param {object} titleReplace object definition of parts to be replaced in the title
|
||||
* object definition:
|
||||
* search: {string,required} RegEx in format //x or simple string to be searched. For (birthday) year calcluation, the element matching the year must be in a RegEx group
|
||||
* 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
|
||||
* replace: {string,required} Replacement string, may contain match group references (latter is required for year calculation)
|
||||
* yearmatchgroup: {number,optional} match group for year element
|
||||
* @returns {string} The transformed title.
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
* use this script with `node debug.js` to test the fetcher without the need
|
||||
* of starting the MagicMirror² core. Adjust the values below to your desire.
|
||||
*/
|
||||
// Alias modules mentioned in package.js under _moduleAliases.
|
||||
require("module-alias/register");
|
||||
// Load internal alias resolver
|
||||
require("../../../js/alias-resolver");
|
||||
const Log = require("logger");
|
||||
|
||||
const CalendarFetcher = require("./calendarfetcher");
|
||||
|
||||
@@ -20,22 +21,20 @@ const auth = {
|
||||
pass: pass
|
||||
};
|
||||
|
||||
console.log("Create fetcher ...");
|
||||
Log.log("Create fetcher ...");
|
||||
|
||||
const fetcher = new CalendarFetcher(url, fetchInterval, [], maximumEntries, maximumNumberOfDays, auth);
|
||||
|
||||
fetcher.onReceive(function (fetcher) {
|
||||
console.log(fetcher.events());
|
||||
console.log("------------------------------------------------------------");
|
||||
Log.log(fetcher.events);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
fetcher.onError(function (fetcher, error) {
|
||||
console.log("Fetcher error:");
|
||||
console.log(error);
|
||||
Log.log("Fetcher error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
fetcher.startFetch();
|
||||
|
||||
console.log("Create fetcher done! ");
|
||||
Log.log("Create fetcher done! ");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const zlib = require("node:zlib");
|
||||
const NodeHelper = require("node_helper");
|
||||
const Log = require("logger");
|
||||
const CalendarFetcher = require("./calendarfetcher");
|
||||
@@ -16,11 +17,11 @@ module.exports = NodeHelper.create({
|
||||
} else if (notification === "FETCH_CALENDAR") {
|
||||
const key = payload.id + payload.url;
|
||||
if (typeof this.fetchers[key] === "undefined") {
|
||||
Log.error("Calendar Error. No fetcher exists with key: ", key);
|
||||
Log.error("No fetcher exists with key: ", key);
|
||||
this.sendSocketNotification("CALENDAR_ERROR", { error_type: "MODULE_ERROR_UNSPECIFIED" });
|
||||
return;
|
||||
}
|
||||
this.fetchers[key].startFetch();
|
||||
this.fetchers[key].fetchCalendar();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -41,7 +42,7 @@ module.exports = NodeHelper.create({
|
||||
try {
|
||||
new URL(url);
|
||||
} catch (error) {
|
||||
Log.error("Calendar Error. Malformed calendar url: ", url, error);
|
||||
Log.error("Malformed calendar url: ", url, error);
|
||||
this.sendSocketNotification("CALENDAR_ERROR", { error_type: "MODULE_ERROR_MALFORMED_URL" });
|
||||
return;
|
||||
}
|
||||
@@ -61,7 +62,7 @@ module.exports = NodeHelper.create({
|
||||
});
|
||||
|
||||
fetcher.onError((fetcher, error) => {
|
||||
Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url(), error);
|
||||
Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url, error);
|
||||
let error_type = NodeHelper.checkFetchError(error);
|
||||
this.sendSocketNotification("CALENDAR_ERROR", {
|
||||
id: identifier,
|
||||
@@ -70,13 +71,18 @@ module.exports = NodeHelper.create({
|
||||
});
|
||||
|
||||
this.fetchers[identifier + url] = fetcher;
|
||||
fetcher.fetchCalendar();
|
||||
} else {
|
||||
Log.log(`Use existing calendarfetcher for url: ${url}`);
|
||||
fetcher = this.fetchers[identifier + url];
|
||||
fetcher.broadcastEvents();
|
||||
// Check if calendar data is stale and needs refresh
|
||||
if (fetcher.shouldRefetch()) {
|
||||
Log.log(`Calendar data is stale, fetching fresh data for url: ${url}`);
|
||||
fetcher.fetchCalendar();
|
||||
} else {
|
||||
fetcher.broadcastEvents();
|
||||
}
|
||||
}
|
||||
|
||||
fetcher.startFetch();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -85,10 +91,12 @@ module.exports = NodeHelper.create({
|
||||
* @param {string} identifier the identifier of the calendar
|
||||
*/
|
||||
broadcastEvents (fetcher, identifier) {
|
||||
const checksum = zlib.crc32(Buffer.from(JSON.stringify(fetcher.events), "utf8"));
|
||||
this.sendSocketNotification("CALENDAR_EVENTS", {
|
||||
id: identifier,
|
||||
url: fetcher.url(),
|
||||
events: fetcher.events()
|
||||
url: fetcher.url,
|
||||
events: fetcher.events,
|
||||
checksum: checksum
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ Module.register("clock", {
|
||||
clockBold: false,
|
||||
showDate: true,
|
||||
showTime: true,
|
||||
showWeek: false,
|
||||
showWeek: false, // options: true, false, 'short'
|
||||
dateFormat: "dddd, LL",
|
||||
sendNotifications: false,
|
||||
|
||||
@@ -23,9 +23,9 @@ Module.register("clock", {
|
||||
analogFace: "simple", // options: 'none', 'simple', 'face-###' (where ### is 001 to 012 inclusive)
|
||||
analogPlacement: "bottom", // options: 'top', 'bottom', 'left', 'right'
|
||||
analogShowDate: "top", // OBSOLETE, can be replaced with analogPlacement and showTime, options: false, 'top', or 'bottom'
|
||||
secondsColor: "#888888",
|
||||
secondsColor: "#888888", // DEPRECATED, use CSS instead. Class "clock-second-digital" for digital clock, "clock-second" for analog clock.
|
||||
|
||||
showSunTimes: false,
|
||||
showSunTimes: false, // options: true, false, 'disableNextEvent'
|
||||
showMoonTimes: false, // options: false, 'times' (rise/set), 'percent' (lit percent), 'phase' (current phase), or 'both' (percent & phase)
|
||||
lat: 47.630539,
|
||||
lon: -122.344147
|
||||
@@ -36,7 +36,7 @@ Module.register("clock", {
|
||||
},
|
||||
// Define styles.
|
||||
getStyles () {
|
||||
return ["clock_styles.css"];
|
||||
return ["clock_styles.css", "font-awesome.css"];
|
||||
},
|
||||
// Define start sequence.
|
||||
start () {
|
||||
@@ -105,6 +105,8 @@ Module.register("clock", {
|
||||
*/
|
||||
const dateWrapper = document.createElement("div");
|
||||
const timeWrapper = document.createElement("div");
|
||||
const hoursWrapper = document.createElement("span");
|
||||
const minutesWrapper = document.createElement("span");
|
||||
const secondsWrapper = document.createElement("sup");
|
||||
const periodWrapper = document.createElement("span");
|
||||
const sunWrapper = document.createElement("div");
|
||||
@@ -114,39 +116,40 @@ Module.register("clock", {
|
||||
// Style Wrappers
|
||||
dateWrapper.className = "date normal medium";
|
||||
timeWrapper.className = "time bright large light";
|
||||
secondsWrapper.className = "seconds dimmed";
|
||||
hoursWrapper.className = "clock-hour-digital";
|
||||
minutesWrapper.className = "clock-minute-digital";
|
||||
secondsWrapper.className = "clock-second-digital dimmed";
|
||||
sunWrapper.className = "sun dimmed small";
|
||||
moonWrapper.className = "moon dimmed small";
|
||||
weekWrapper.className = "week dimmed medium";
|
||||
|
||||
// Set content of wrappers.
|
||||
// The moment().format("h") method has a bug on the Raspberry Pi.
|
||||
// So we need to generate the timestring manually.
|
||||
// See issue: https://github.com/MagicMirrorOrg/MagicMirror/issues/181
|
||||
let timeString;
|
||||
const now = moment();
|
||||
if (this.config.timezone) {
|
||||
now.tz(this.config.timezone);
|
||||
}
|
||||
|
||||
let hourSymbol = "HH";
|
||||
if (this.config.timeFormat !== 24) {
|
||||
hourSymbol = "h";
|
||||
}
|
||||
|
||||
if (this.config.clockBold) {
|
||||
timeString = now.format(`${hourSymbol}[<span class="bold">]mm[</span>]`);
|
||||
} else {
|
||||
timeString = now.format(`${hourSymbol}:mm`);
|
||||
}
|
||||
|
||||
if (this.config.showDate) {
|
||||
dateWrapper.innerHTML = now.format(this.config.dateFormat);
|
||||
digitalWrapper.appendChild(dateWrapper);
|
||||
}
|
||||
|
||||
if (this.config.displayType !== "analog" && this.config.showTime) {
|
||||
timeWrapper.innerHTML = timeString;
|
||||
let hourSymbol = "HH";
|
||||
if (this.config.timeFormat !== 24) {
|
||||
hourSymbol = "h";
|
||||
}
|
||||
|
||||
hoursWrapper.innerHTML = now.format(hourSymbol);
|
||||
minutesWrapper.innerHTML = now.format("mm");
|
||||
|
||||
timeWrapper.appendChild(hoursWrapper);
|
||||
if (this.config.clockBold) {
|
||||
minutesWrapper.classList.add("bold");
|
||||
} else {
|
||||
timeWrapper.innerHTML += ":";
|
||||
}
|
||||
timeWrapper.appendChild(minutesWrapper);
|
||||
secondsWrapper.innerHTML = now.format("ss");
|
||||
if (this.config.showPeriodUpper) {
|
||||
periodWrapper.innerHTML = now.format("A");
|
||||
@@ -168,21 +171,28 @@ Module.register("clock", {
|
||||
if (this.config.showSunTimes) {
|
||||
const sunTimes = SunCalc.getTimes(now, this.config.lat, this.config.lon);
|
||||
const isVisible = now.isBetween(sunTimes.sunrise, sunTimes.sunset);
|
||||
let nextEvent;
|
||||
if (now.isBefore(sunTimes.sunrise)) {
|
||||
nextEvent = sunTimes.sunrise;
|
||||
} else if (now.isBefore(sunTimes.sunset)) {
|
||||
nextEvent = sunTimes.sunset;
|
||||
} else {
|
||||
const tomorrowSunTimes = SunCalc.getTimes(now.clone().add(1, "day"), this.config.lat, this.config.lon);
|
||||
nextEvent = tomorrowSunTimes.sunrise;
|
||||
let sunWrapperInnerHTML = "";
|
||||
|
||||
if (this.config.showSunTimes !== "disableNextEvent") {
|
||||
let nextEvent;
|
||||
if (now.isBefore(sunTimes.sunrise)) {
|
||||
nextEvent = sunTimes.sunrise;
|
||||
} else if (now.isBefore(sunTimes.sunset)) {
|
||||
nextEvent = sunTimes.sunset;
|
||||
} else {
|
||||
const tomorrowSunTimes = SunCalc.getTimes(now.clone().add(1, "day"), this.config.lat, this.config.lon);
|
||||
nextEvent = tomorrowSunTimes.sunrise;
|
||||
}
|
||||
const untilNextEvent = moment.duration(moment(nextEvent).diff(now));
|
||||
const untilNextEventString = `${untilNextEvent.hours()}h ${untilNextEvent.minutes()}m`;
|
||||
|
||||
sunWrapperInnerHTML = `<span class="${isVisible ? "bright" : ""}"><i class="fas fa-sun" aria-hidden="true"></i> ${untilNextEventString}</span>`;
|
||||
}
|
||||
const untilNextEvent = moment.duration(moment(nextEvent).diff(now));
|
||||
const untilNextEventString = `${untilNextEvent.hours()}h ${untilNextEvent.minutes()}m`;
|
||||
sunWrapper.innerHTML
|
||||
= `<span class="${isVisible ? "bright" : ""}"><i class="fas fa-sun" aria-hidden="true"></i> ${untilNextEventString}</span>`
|
||||
+ `<span><i class="fas fa-arrow-up" aria-hidden="true"></i> ${formatTime(this.config, sunTimes.sunrise)}</span>`
|
||||
+ `<span><i class="fas fa-arrow-down" aria-hidden="true"></i> ${formatTime(this.config, sunTimes.sunset)}</span>`;
|
||||
|
||||
sunWrapperInnerHTML += `<span><i class="fas fa-arrow-up" aria-hidden="true"></i> ${formatTime(this.config, sunTimes.sunrise)}</span>`
|
||||
+ `<span><i class="fas fa-arrow-down" aria-hidden="true"></i> ${formatTime(this.config, sunTimes.sunset)}</span>`;
|
||||
|
||||
sunWrapper.innerHTML = sunWrapperInnerHTML;
|
||||
digitalWrapper.appendChild(sunWrapper);
|
||||
}
|
||||
|
||||
@@ -208,13 +218,18 @@ Module.register("clock", {
|
||||
|
||||
moonWrapper.innerHTML
|
||||
= `<span class="${isVisible ? "bright" : ""}">${image} ${showFraction ? illuminatedFractionString : ""}</span>`
|
||||
+ `<span><i class="fas fa-arrow-up" aria-hidden="true"></i> ${moonRise ? formatTime(this.config, moonRise) : "..."}</span>`
|
||||
+ `<span><i class="fas fa-arrow-down" aria-hidden="true"></i> ${moonSet ? formatTime(this.config, moonSet) : "..."}</span>`;
|
||||
+ `<span><i class="fas fa-arrow-up" aria-hidden="true"></i> ${moonRise ? formatTime(this.config, moonRise) : "..."}</span>`
|
||||
+ `<span><i class="fas fa-arrow-down" aria-hidden="true"></i> ${moonSet ? formatTime(this.config, moonSet) : "..."}</span>`;
|
||||
digitalWrapper.appendChild(moonWrapper);
|
||||
}
|
||||
|
||||
if (this.config.showWeek) {
|
||||
weekWrapper.innerHTML = this.translate("WEEK", { weekNumber: now.week() });
|
||||
if (this.config.showWeek === "short") {
|
||||
weekWrapper.innerHTML = this.translate("WEEK_SHORT", { weekNumber: now.week() });
|
||||
} else {
|
||||
weekWrapper.innerHTML = this.translate("WEEK", { weekNumber: now.week() });
|
||||
}
|
||||
|
||||
digitalWrapper.appendChild(weekWrapper);
|
||||
}
|
||||
|
||||
@@ -267,7 +282,7 @@ Module.register("clock", {
|
||||
clockSecond.id = "clock-second";
|
||||
clockSecond.style.transform = `rotate(${second}deg)`;
|
||||
clockSecond.className = "clock-second";
|
||||
clockSecond.style.backgroundColor = this.config.secondsColor;
|
||||
clockSecond.style.backgroundColor = this.config.secondsColor; /* DEPRECATED, to be removed in a future version , use CSS instead */
|
||||
clockFace.appendChild(clockSecond);
|
||||
}
|
||||
analogWrapper.appendChild(clockFace);
|
||||
|
||||
@@ -78,16 +78,41 @@
|
||||
left: 50%;
|
||||
margin: -38% -1px 0 0; /* numbers must match negative length & thickness */
|
||||
padding: 38% 1px 0 0; /* indicator length & thickness */
|
||||
background: var(--color-text);
|
||||
|
||||
/* background: #888888 !important; */
|
||||
|
||||
/* use this instead of secondsColor */
|
||||
|
||||
/* have to use !important, because the code explicitly sets the color currently */
|
||||
transform-origin: 50% 100%;
|
||||
}
|
||||
|
||||
.module.clock .digital {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.module.clock .sun,
|
||||
.module.clock .moon {
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.module.clock .sun > *,
|
||||
.module.clock .moon > * {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.module.clock .clock-hour-digital {
|
||||
color: var(--color-text-bright);
|
||||
}
|
||||
|
||||
.module.clock .clock-minute-digital {
|
||||
color: var(--color-text-bright);
|
||||
}
|
||||
|
||||
.module.clock .clock-second-digital {
|
||||
color: var(--color-text-dimmed);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ Module.register("compliments", {
|
||||
},
|
||||
updateInterval: 30000,
|
||||
remoteFile: null,
|
||||
remoteFileRefreshInterval: 0,
|
||||
fadeSpeed: 4000,
|
||||
morningStartTime: 3,
|
||||
morningEndTime: 12,
|
||||
@@ -20,6 +21,8 @@ Module.register("compliments", {
|
||||
random: true,
|
||||
specialDayUnique: false
|
||||
},
|
||||
compliments_new: null,
|
||||
refreshMinimumDelay: 15 * 60 * 1000, // 15 minutes
|
||||
lastIndexUsed: -1,
|
||||
// Set currentweather from module
|
||||
currentWeatherType: "",
|
||||
@@ -41,6 +44,22 @@ Module.register("compliments", {
|
||||
const response = await this.loadComplimentFile();
|
||||
this.config.compliments = JSON.parse(response);
|
||||
this.updateDom();
|
||||
if (this.config.remoteFileRefreshInterval !== 0) {
|
||||
if ((this.config.remoteFileRefreshInterval >= this.refreshMinimumDelay) || window.mmTestMode === "true") {
|
||||
setInterval(async () => {
|
||||
const response = await this.loadComplimentFile();
|
||||
if (response) {
|
||||
this.compliments_new = JSON.parse(response);
|
||||
}
|
||||
else {
|
||||
Log.error(`[compliments] ${this.name} remoteFile refresh failed`);
|
||||
}
|
||||
},
|
||||
this.config.remoteFileRefreshInterval);
|
||||
} else {
|
||||
Log.error(`[compliments] ${this.name} remoteFileRefreshInterval less than minimum`);
|
||||
}
|
||||
}
|
||||
}
|
||||
let minute_sync_delay = 1;
|
||||
// loop thru all the configured when events
|
||||
@@ -61,7 +80,7 @@ Module.register("compliments", {
|
||||
minute_sync_delay);
|
||||
},
|
||||
|
||||
// check to see if this entry could be a cron entry wich contains spaces
|
||||
// check to see if this entry could be a cron entry which contains spaces
|
||||
isCronEntry (entry) {
|
||||
return entry.includes(" ");
|
||||
},
|
||||
@@ -119,12 +138,17 @@ Module.register("compliments", {
|
||||
let compliments = [];
|
||||
|
||||
// Add time of day compliments
|
||||
if (hour >= this.config.morningStartTime && hour < this.config.morningEndTime && this.config.compliments.hasOwnProperty("morning")) {
|
||||
compliments = [...this.config.compliments.morning];
|
||||
} else if (hour >= this.config.afternoonStartTime && hour < this.config.afternoonEndTime && this.config.compliments.hasOwnProperty("afternoon")) {
|
||||
compliments = [...this.config.compliments.afternoon];
|
||||
} else if (this.config.compliments.hasOwnProperty("evening")) {
|
||||
compliments = [...this.config.compliments.evening];
|
||||
let timeOfDay;
|
||||
if (hour >= this.config.morningStartTime && hour < this.config.morningEndTime) {
|
||||
timeOfDay = "morning";
|
||||
} else if (hour >= this.config.afternoonStartTime && hour < this.config.afternoonEndTime) {
|
||||
timeOfDay = "afternoon";
|
||||
} else {
|
||||
timeOfDay = "evening";
|
||||
}
|
||||
|
||||
if (timeOfDay && this.config.compliments.hasOwnProperty(timeOfDay)) {
|
||||
compliments = [...this.config.compliments[timeOfDay]];
|
||||
}
|
||||
|
||||
// Add compliments based on weather
|
||||
@@ -158,7 +182,7 @@ Module.register("compliments", {
|
||||
// if so, use its notice entries
|
||||
Array.prototype.push.apply(date_compliments, this.config.compliments[entry]);
|
||||
}
|
||||
} else Log.error(`compliments cron syntax invalid=${JSON.stringify(entry)}`);
|
||||
} else Log.error(`[compliments] cron syntax invalid=${JSON.stringify(entry)}`);
|
||||
} else if (new RegExp(entry).test(date)) {
|
||||
Array.prototype.push.apply(date_compliments, this.config.compliments[entry]);
|
||||
}
|
||||
@@ -180,13 +204,36 @@ Module.register("compliments", {
|
||||
|
||||
/**
|
||||
* Retrieve a file from the local filesystem
|
||||
* @returns {Promise} Resolved when the file is loaded
|
||||
* @returns {Promise<string|null>} Resolved with file content or null on error
|
||||
*/
|
||||
async loadComplimentFile () {
|
||||
const isRemote = this.config.remoteFile.indexOf("http://") === 0 || this.config.remoteFile.indexOf("https://") === 0,
|
||||
url = isRemote ? this.config.remoteFile : this.file(this.config.remoteFile);
|
||||
const response = await fetch(url);
|
||||
return await response.text();
|
||||
const { remoteFile, remoteFileRefreshInterval } = this.config;
|
||||
const isRemote = remoteFile.startsWith("http://") || remoteFile.startsWith("https://");
|
||||
let url = isRemote ? remoteFile : this.file(remoteFile);
|
||||
|
||||
try {
|
||||
// Validate URL
|
||||
const urlObj = new URL(url);
|
||||
// Add cache-busting parameter to remote URLs to prevent cached responses
|
||||
if (isRemote && remoteFileRefreshInterval !== 0) {
|
||||
urlObj.searchParams.set("dummy", Date.now());
|
||||
}
|
||||
url = urlObj.toString();
|
||||
} catch {
|
||||
Log.warn(`[compliments] Invalid URL: ${url}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
Log.error(`[compliments] HTTP error: ${response.status} ${response.statusText}`);
|
||||
return null;
|
||||
}
|
||||
return await response.text();
|
||||
} catch (error) {
|
||||
Log.info("[compliments] fetch failed:", error.message);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -236,6 +283,27 @@ Module.register("compliments", {
|
||||
compliment.lastElementChild.remove();
|
||||
wrapper.appendChild(compliment);
|
||||
}
|
||||
// if a new set of compliments was loaded from the refresh task
|
||||
// we do this here to make sure no other function is using the compliments list
|
||||
if (this.compliments_new) {
|
||||
// use them
|
||||
if (JSON.stringify(this.config.compliments) !== JSON.stringify(this.compliments_new)) {
|
||||
// only reset if the contents changes
|
||||
this.config.compliments = this.compliments_new;
|
||||
// reset the index
|
||||
this.lastIndexUsed = -1;
|
||||
}
|
||||
// clear new file list so we don't waste cycles comparing between refreshes
|
||||
this.compliments_new = null;
|
||||
}
|
||||
// only in test mode
|
||||
if (window.mmTestMode === "true") {
|
||||
// check for (undocumented) remoteFile2 to test new file load
|
||||
if (this.config.remoteFile2 !== null && this.config.remoteFileRefreshInterval !== 0) {
|
||||
// switch the file so that next time it will be loaded from a changed file
|
||||
this.config.remoteFile = this.config.remoteFile2;
|
||||
}
|
||||
}
|
||||
return wrapper;
|
||||
},
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!--
|
||||
Use ` | safe` to allow html tages within the text string.
|
||||
Use ` | safe` to allow html tags within the text string.
|
||||
https://mozilla.github.io/nunjucks/templating.html#autoescaping
|
||||
-->
|
||||
<div>{{ text | safe }}</div>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<div>
|
||||
<iframe class="newsfeed-fullarticle" src="{{ url }}"></iframe>
|
||||
<iframe class="newsfeed-fullarticle" src="{{ url }}"></iframe>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ Module.register("newsfeed", {
|
||||
|
||||
getUrlPrefix (item) {
|
||||
if (item.useCorsProxy) {
|
||||
return `${location.protocol}//${location.host}/cors?url=`;
|
||||
return `${location.protocol}//${location.host}${config.basePath}cors?url=`;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
@@ -57,7 +57,7 @@ Module.register("newsfeed", {
|
||||
// Define required translations.
|
||||
getTranslations () {
|
||||
// The translations for the default modules are defined in the core translation files.
|
||||
// Therefor we can just return false. Otherwise we should have returned a dictionary.
|
||||
// Therefore we can just return false. Otherwise we should have returned a dictionary.
|
||||
// If you're trying to build your own module including translations, check out the documentation.
|
||||
return false;
|
||||
},
|
||||
@@ -177,6 +177,19 @@ Module.register("newsfeed", {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Gets a feed property by name
|
||||
* @param {object} feed A feed object.
|
||||
* @param {string} property The name of the property.
|
||||
* @returns {string} The value of the specified property for the feed.
|
||||
*/
|
||||
getFeedProperty (feed, property) {
|
||||
let res = this.config[property];
|
||||
const f = this.config.feeds.find((feedItem) => feedItem.url === feed);
|
||||
if (f && f[property]) res = f[property];
|
||||
return res;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate an ordered list of items for this configured module.
|
||||
* @param {object} feeds An object with feeds returned by the node helper.
|
||||
@@ -188,7 +201,7 @@ Module.register("newsfeed", {
|
||||
if (this.subscribedToFeed(feed)) {
|
||||
for (let item of feedItems) {
|
||||
item.sourceTitle = this.titleForFeed(feed);
|
||||
if (!(this.config.ignoreOldItems && Date.now() - new Date(item.pubdate) > this.config.ignoreOlderThan)) {
|
||||
if (!(this.getFeedProperty(feed, "ignoreOldItems") && Date.now() - new Date(item.pubdate) > this.getFeedProperty(feed, "ignoreOlderThan"))) {
|
||||
newsItems.push(item);
|
||||
}
|
||||
}
|
||||
@@ -362,7 +375,7 @@ Module.register("newsfeed", {
|
||||
this.activeItem = 0;
|
||||
}
|
||||
this.resetDescrOrFullArticleAndTimer();
|
||||
Log.debug(`${this.name} - going from article #${before} to #${this.activeItem} (of ${this.newsItems.length})`);
|
||||
Log.debug(`[newsfeed] going from article #${before} to #${this.activeItem} (of ${this.newsItems.length})`);
|
||||
this.updateDom(100);
|
||||
} else if (notification === "ARTICLE_PREVIOUS") {
|
||||
this.activeItem--;
|
||||
@@ -370,7 +383,7 @@ Module.register("newsfeed", {
|
||||
this.activeItem = this.newsItems.length - 1;
|
||||
}
|
||||
this.resetDescrOrFullArticleAndTimer();
|
||||
Log.debug(`${this.name} - going from article #${before} to #${this.activeItem} (of ${this.newsItems.length})`);
|
||||
Log.debug(`[newsfeed] going from article #${before} to #${this.activeItem} (of ${this.newsItems.length})`);
|
||||
this.updateDom(100);
|
||||
}
|
||||
// if "more details" is received the first time: show article summary, on second time show full article
|
||||
@@ -379,8 +392,8 @@ Module.register("newsfeed", {
|
||||
if (this.config.showFullArticle === true) {
|
||||
this.scrollPosition += this.config.scrollLength;
|
||||
window.scrollTo(0, this.scrollPosition);
|
||||
Log.debug(`${this.name} - scrolling down`);
|
||||
Log.debug(`${this.name} - ARTICLE_MORE_DETAILS, scroll position: ${this.config.scrollLength}`);
|
||||
Log.debug("[newsfeed] scrolling down");
|
||||
Log.debug(`[newsfeed] ARTICLE_MORE_DETAILS, scroll position: ${this.config.scrollLength}`);
|
||||
} else {
|
||||
this.showFullArticle();
|
||||
}
|
||||
@@ -388,12 +401,12 @@ Module.register("newsfeed", {
|
||||
if (this.config.showFullArticle === true) {
|
||||
this.scrollPosition -= this.config.scrollLength;
|
||||
window.scrollTo(0, this.scrollPosition);
|
||||
Log.debug(`${this.name} - scrolling up`);
|
||||
Log.debug(`${this.name} - ARTICLE_SCROLL_UP, scroll position: ${this.config.scrollLength}`);
|
||||
Log.debug("[newsfeed] scrolling up");
|
||||
Log.debug(`[newsfeed] ARTICLE_SCROLL_UP, scroll position: ${this.config.scrollLength}`);
|
||||
}
|
||||
} else if (notification === "ARTICLE_LESS_DETAILS") {
|
||||
this.resetDescrOrFullArticleAndTimer();
|
||||
Log.debug(`${this.name} - showing only article titles again`);
|
||||
Log.debug("[newsfeed] showing only article titles again");
|
||||
this.updateDom(100);
|
||||
} else if (notification === "ARTICLE_TOGGLE_FULL") {
|
||||
if (this.config.showFullArticle) {
|
||||
@@ -422,7 +435,7 @@ Module.register("newsfeed", {
|
||||
}
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
Log.debug(`${this.name} - showing ${this.isShowingDescription ? "article description" : "full article"}`);
|
||||
Log.debug(`[newsfeed] showing ${this.isShowingDescription ? "article description" : "full article"}`);
|
||||
this.updateDom(100);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,89 +1,89 @@
|
||||
{% macro escapeText(text, dangerouslyDisableAutoEscaping=false) %}
|
||||
{% if dangerouslyDisableAutoEscaping -%}
|
||||
{{ text | safe }}
|
||||
{%- else -%}
|
||||
{{ text }}
|
||||
{%- endif %}
|
||||
{% if dangerouslyDisableAutoEscaping -%}
|
||||
{{ text | safe }}
|
||||
{%- else -%}
|
||||
{{ text }}
|
||||
{%- endif %}
|
||||
{% endmacro %}
|
||||
{% macro escapeTitle(title, url, dangerouslyDisableAutoEscaping=false, showTitleAsUrl=false) %}
|
||||
{% if dangerouslyDisableAutoEscaping %}
|
||||
{% if showTitleAsUrl %}
|
||||
<a href="{{ url }}"
|
||||
style="text-decoration:none;
|
||||
{% if dangerouslyDisableAutoEscaping %}
|
||||
{% if showTitleAsUrl %}
|
||||
<a
|
||||
href="{{ url }}"
|
||||
style="text-decoration:none;
|
||||
color:#ffffff"
|
||||
target="_blank">{{ title | safe }}</a>
|
||||
{% else %}
|
||||
{{ title | safe }}
|
||||
{% endif %}
|
||||
target="_blank"
|
||||
>{{ title | safe }}</a
|
||||
>
|
||||
{% else %}
|
||||
{% if showTitleAsUrl %}
|
||||
<a href="{{ url }}"
|
||||
style="text-decoration:none;
|
||||
color:#ffffff"
|
||||
target="_blank">{{ title }}</a>
|
||||
{% else %}
|
||||
{{ title }}
|
||||
{% endif %}
|
||||
{{ title | safe }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if showTitleAsUrl %}
|
||||
<a
|
||||
href="{{ url }}"
|
||||
style="text-decoration:none;
|
||||
color:#ffffff"
|
||||
target="_blank"
|
||||
>{{ title }}</a
|
||||
>
|
||||
{% else %}
|
||||
{{ title }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
{% if loaded %}
|
||||
{% if config.showAsList %}
|
||||
<ul class="newsfeed-list">
|
||||
{% for item in items %}
|
||||
<li>
|
||||
{% if (config.showSourceTitle and item.sourceTitle) or config.showPublishDate %}
|
||||
<div class="newsfeed-source light small dimmed">
|
||||
{% if item.sourceTitle and config.showSourceTitle %}
|
||||
{{ item.sourceTitle }}{% if config.showPublishDate %}, {% else %}:{% endif %}
|
||||
{% endif %}
|
||||
{% if config.showPublishDate %}{{ item.publishDate }}:{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="newsfeed-title bright medium light{{ ' no-wrap' if not config.wrapTitle }}">
|
||||
{{ escapeTitle(item.title, item.url, config.dangerouslyDisableAutoEscaping, config.showTitleAsUrl) }}
|
||||
</div>
|
||||
{% if config.showDescription %}
|
||||
<div class="newsfeed-desc small light{{ ' no-wrap' if not config.wrapDescription }}">
|
||||
{% if config.truncDescription %}
|
||||
{{ escapeText(item.description | truncate(config.lengthDescription) , config.dangerouslyDisableAutoEscaping) }}
|
||||
{% else %}
|
||||
{{ escapeText(item.description, config.dangerouslyDisableAutoEscaping) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div>
|
||||
{% if (config.showSourceTitle and sourceTitle) or config.showPublishDate %}
|
||||
<div class="newsfeed-source light small dimmed">
|
||||
{% if sourceTitle and config.showSourceTitle %}
|
||||
{{ escapeText(sourceTitle, config.dangerouslyDisableAutoEscaping) }}{% if config.showPublishDate %}, {% else %}:{% endif %}
|
||||
{% endif %}
|
||||
{% if config.showPublishDate %}{{ publishDate }}:{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="newsfeed-title bright medium light{{ ' no-wrap' if not config.wrapTitle }}">
|
||||
{{ escapeTitle(title, url, config.dangerouslyDisableAutoEscaping, config.showTitleAsUrl) }}
|
||||
{% if config.showAsList %}
|
||||
<ul class="newsfeed-list">
|
||||
{% for item in items %}
|
||||
<li>
|
||||
{% if (config.showSourceTitle and item.sourceTitle) or config.showPublishDate %}
|
||||
<div class="newsfeed-source light small dimmed">
|
||||
{% if item.sourceTitle and config.showSourceTitle %}
|
||||
{{ item.sourceTitle }}{% if config.showPublishDate %}, {% else %}:{% endif %}
|
||||
{% endif %}
|
||||
{% if config.showPublishDate %}{{ item.publishDate }}:{% endif %}
|
||||
</div>
|
||||
{% if config.showDescription %}
|
||||
<div class="newsfeed-desc small light{{ ' no-wrap' if not config.wrapDescription }}">
|
||||
{% if config.truncDescription %}
|
||||
{{ escapeText(description | truncate(config.lengthDescription) , config.dangerouslyDisableAutoEscaping) }}
|
||||
{% else %}
|
||||
{{ escapeText(description, config.dangerouslyDisableAutoEscaping) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="newsfeed-title bright medium light{{ ' no-wrap' if not config.wrapTitle }}">{{ escapeTitle(item.title, item.url, config.dangerouslyDisableAutoEscaping, config.showTitleAsUrl) }}</div>
|
||||
{% if config.showDescription %}
|
||||
<div class="newsfeed-desc small light{{ ' no-wrap' if not config.wrapDescription }}">
|
||||
{% if config.truncDescription %}
|
||||
{{ escapeText(item.description | truncate(config.lengthDescription) , config.dangerouslyDisableAutoEscaping) }}
|
||||
{% else %}
|
||||
{{ escapeText(item.description, config.dangerouslyDisableAutoEscaping) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div>
|
||||
{% if (config.showSourceTitle and sourceTitle) or config.showPublishDate %}
|
||||
<div class="newsfeed-source light small dimmed">
|
||||
{% if sourceTitle and config.showSourceTitle %}
|
||||
{{ escapeText(sourceTitle, config.dangerouslyDisableAutoEscaping) }}{% if config.showPublishDate %}, {% else %}:{% endif %}
|
||||
{% endif %}
|
||||
{% if config.showPublishDate %}{{ publishDate }}:{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elseif empty %}
|
||||
<div class="small dimmed">{{ "NEWSFEED_NO_ITEMS" | translate | safe }}</div>
|
||||
{% elseif error %}
|
||||
<div class="small dimmed">
|
||||
{{ "MODULE_CONFIG_ERROR" | translate({MODULE_NAME: "Newsfeed", ERROR: error}) | safe }}
|
||||
{% endif %}
|
||||
<div class="newsfeed-title bright medium light{{ ' no-wrap' if not config.wrapTitle }}">{{ escapeTitle(title, url, config.dangerouslyDisableAutoEscaping, config.showTitleAsUrl) }}</div>
|
||||
{% if config.showDescription %}
|
||||
<div class="newsfeed-desc small light{{ ' no-wrap' if not config.wrapDescription }}">
|
||||
{% if config.truncDescription %}
|
||||
{{ escapeText(description | truncate(config.lengthDescription) , config.dangerouslyDisableAutoEscaping) }}
|
||||
{% else %}
|
||||
{{ escapeText(description, config.dangerouslyDisableAutoEscaping) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elseif empty %}
|
||||
<div class="small dimmed">{{ "NEWSFEED_NO_ITEMS" | translate | safe }}</div>
|
||||
{% elseif error %}
|
||||
<div class="small dimmed">{{ "MODULE_CONFIG_ERROR" | translate({MODULE_NAME: "Newsfeed", ERROR: error}) | safe }}</div>
|
||||
{% else %}
|
||||
<div class="small dimmed">{{ "LOADING" | translate | safe }}</div>
|
||||
<div class="small dimmed">{{ "LOADING" | translate | safe }}</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -5,6 +5,8 @@ const iconv = require("iconv-lite");
|
||||
const { htmlToText } = require("html-to-text");
|
||||
const Log = require("logger");
|
||||
const NodeHelper = require("node_helper");
|
||||
const { getUserAgent } = require("#server_functions");
|
||||
const { scheduleTimer } = require("#module_functions");
|
||||
|
||||
/**
|
||||
* Responsible for requesting an update on the set interval and broadcasting the data.
|
||||
@@ -65,8 +67,7 @@ const NewsfeedFetcher = function (url, reloadInterval, encoding, logFeedWarnings
|
||||
hash: crypto.createHash("sha256").update(`${pubdate} :: ${title} :: ${url}`).digest("hex")
|
||||
});
|
||||
} else if (logFeedWarnings) {
|
||||
Log.warn("Can't parse feed item:");
|
||||
Log.warn(item);
|
||||
Log.warn("Can't parse feed item:", item);
|
||||
Log.warn(`Title: ${title}`);
|
||||
Log.warn(`Description: ${description}`);
|
||||
Log.warn(`Pubdate: ${pubdate}`);
|
||||
@@ -79,12 +80,12 @@ const NewsfeedFetcher = function (url, reloadInterval, encoding, logFeedWarnings
|
||||
|
||||
parser.on("error", (error) => {
|
||||
fetchFailedCallback(this, error);
|
||||
scheduleTimer();
|
||||
scheduleTimer(reloadTimer, reloadIntervalMS, fetchNews);
|
||||
});
|
||||
|
||||
//"end" event is not broadcast if the feed is empty but "finish" is used for both
|
||||
parser.on("finish", () => {
|
||||
scheduleTimer();
|
||||
scheduleTimer(reloadTimer, reloadIntervalMS, fetchNews);
|
||||
});
|
||||
|
||||
parser.on("ttl", (minutes) => {
|
||||
@@ -93,16 +94,15 @@ const NewsfeedFetcher = function (url, reloadInterval, encoding, logFeedWarnings
|
||||
const ttlms = Math.min(minutes * 60 * 1000, 86400000);
|
||||
if (ttlms > reloadIntervalMS) {
|
||||
reloadIntervalMS = ttlms;
|
||||
Log.info(`Newsfeed-Fetcher: reloadInterval set to ttl=${reloadIntervalMS} for url ${url}`);
|
||||
Log.info(`reloadInterval set to ttl=${reloadIntervalMS} for url ${url}`);
|
||||
}
|
||||
} catch (error) {
|
||||
Log.warn(`Newsfeed-Fetcher: feed ttl is no valid integer=${minutes} for url ${url}`);
|
||||
Log.warn(`feed ttl is no valid integer=${minutes} for url ${url}`);
|
||||
}
|
||||
});
|
||||
|
||||
const nodeVersion = Number(process.version.match(/^v(\d+\.\d+)/)[1]);
|
||||
const headers = {
|
||||
"User-Agent": `Mozilla/5.0 (Node.js ${nodeVersion}) MagicMirror/${global.version}`,
|
||||
"User-Agent": getUserAgent(),
|
||||
"Cache-Control": "max-age=0, no-cache, no-store, must-revalidate",
|
||||
Pragma: "no-cache"
|
||||
};
|
||||
@@ -120,20 +120,10 @@ const NewsfeedFetcher = function (url, reloadInterval, encoding, logFeedWarnings
|
||||
})
|
||||
.catch((error) => {
|
||||
fetchFailedCallback(this, error);
|
||||
scheduleTimer();
|
||||
scheduleTimer(reloadTimer, reloadIntervalMS, fetchNews);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Schedule the timer for the next update.
|
||||
*/
|
||||
const scheduleTimer = function () {
|
||||
clearTimeout(reloadTimer);
|
||||
reloadTimer = setTimeout(function () {
|
||||
fetchNews();
|
||||
}, reloadIntervalMS);
|
||||
};
|
||||
|
||||
/* public methods */
|
||||
|
||||
/**
|
||||
@@ -158,10 +148,10 @@ const NewsfeedFetcher = function (url, reloadInterval, encoding, logFeedWarnings
|
||||
*/
|
||||
this.broadcastItems = function () {
|
||||
if (items.length <= 0) {
|
||||
Log.info("Newsfeed-Fetcher: No items to broadcast yet.");
|
||||
Log.info("No items to broadcast yet.");
|
||||
return;
|
||||
}
|
||||
Log.info(`Newsfeed-Fetcher: Broadcasting ${items.length} items.`);
|
||||
Log.info(`Broadcasting ${items.length} items.`);
|
||||
itemsReceivedCallback(this);
|
||||
};
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ module.exports = NodeHelper.create({
|
||||
try {
|
||||
new URL(url);
|
||||
} catch (error) {
|
||||
Log.error("Newsfeed Error. Malformed newsfeed url: ", url, error);
|
||||
Log.error("Error: Malformed newsfeed url: ", url, error);
|
||||
this.sendSocketNotification("NEWSFEED_ERROR", { error_type: "MODULE_ERROR_MALFORMED_URL" });
|
||||
return;
|
||||
}
|
||||
@@ -47,7 +47,7 @@ module.exports = NodeHelper.create({
|
||||
});
|
||||
|
||||
fetcher.onError((fetcher, error) => {
|
||||
Log.error("Newsfeed Error. Could not fetch newsfeed: ", url, error);
|
||||
Log.error("Error: Could not fetch newsfeed: ", url, error);
|
||||
let error_type = NodeHelper.checkFetchError(error);
|
||||
this.sendSocketNotification("NEWSFEED_ERROR", {
|
||||
error_type
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
<div class="small bright">
|
||||
{{ "MODULE_CONFIG_CHANGED" | translate({MODULE_NAME: "Newsfeed"}) | safe }}
|
||||
</div>
|
||||
<div class="small bright">{{ "MODULE_CONFIG_CHANGED" | translate({MODULE_NAME: "Newsfeed"}) | safe }}</div>
|
||||
|
||||
@@ -4,8 +4,6 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const Log = require("logger");
|
||||
|
||||
const BASE_DIR = path.normalize(`${__dirname}/../../../`);
|
||||
|
||||
class GitHelper {
|
||||
constructor () {
|
||||
this.gitRepos = [];
|
||||
@@ -35,10 +33,10 @@ class GitHelper {
|
||||
}
|
||||
|
||||
async add (moduleName) {
|
||||
let moduleFolder = BASE_DIR;
|
||||
let moduleFolder = `${global.root_path}`;
|
||||
|
||||
if (moduleName !== "MagicMirror") {
|
||||
moduleFolder = `${moduleFolder}modules/${moduleName}`;
|
||||
moduleFolder = `${moduleFolder}/modules/${moduleName}`;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -185,7 +183,10 @@ class GitHelper {
|
||||
this.gitResultList.push(gitInfo);
|
||||
}
|
||||
} catch (e) {
|
||||
Log.error(`Failed to retrieve repo info for ${repo.module}: ${e}`);
|
||||
// Only log errors in non-test environments to keep test output clean
|
||||
if (process.env.mmTestMode !== "true") {
|
||||
Log.error(`Failed to retrieve repo info for ${repo.module}: ${e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const NodeHelper = require("node_helper");
|
||||
const defaultModules = require("../defaultmodules");
|
||||
|
||||
const defaultModules = require(`${global.root_path}/modules/default/defaultmodules`);
|
||||
const GitHelper = require("./git_helper");
|
||||
const UpdateHelper = require("./update_helper");
|
||||
|
||||
@@ -14,8 +17,23 @@ module.exports = NodeHelper.create({
|
||||
gitHelper: new GitHelper(),
|
||||
updateHelper: null,
|
||||
|
||||
getModules (modules) {
|
||||
if (this.config.useModulesFromConfig) {
|
||||
return modules;
|
||||
} else {
|
||||
// get modules from modules-directory
|
||||
const moduleDir = path.normalize(`${global.root_path}/modules`);
|
||||
const getDirectories = (source) => {
|
||||
return fs.readdirSync(source, { withFileTypes: true })
|
||||
.filter((dirent) => dirent.isDirectory() && dirent.name !== "default")
|
||||
.map((dirent) => dirent.name);
|
||||
};
|
||||
return getDirectories(moduleDir);
|
||||
}
|
||||
},
|
||||
|
||||
async configureModules (modules) {
|
||||
for (const moduleName of modules) {
|
||||
for (const moduleName of this.getModules(modules)) {
|
||||
if (!this.ignoreUpdateChecking(moduleName)) {
|
||||
await this.gitHelper.add(moduleName);
|
||||
}
|
||||
|
||||
@@ -47,11 +47,11 @@ class Updater {
|
||||
this.autoRestart = config.updateAutorestart;
|
||||
this.moduleList = {};
|
||||
this.updating = false;
|
||||
this.usePM2 = false;
|
||||
this.PM2 = null;
|
||||
this.usePM2 = false; // don't use pm2 by default
|
||||
this.PM2Id = null; // pm2 process number
|
||||
this.version = global.version;
|
||||
this.root_path = global.root_path;
|
||||
Log.info("updatenotification: Updater Class Loaded!");
|
||||
Log.info("Updater Class Loaded!");
|
||||
}
|
||||
|
||||
// [main command] parse if module update is needed
|
||||
@@ -81,7 +81,7 @@ class Updater {
|
||||
|
||||
await Promise.all(parser);
|
||||
let updater = Object.values(this.moduleList);
|
||||
Log.debug("updatenotification Update Result:", updater);
|
||||
Log.debug("Update Result:", updater);
|
||||
return updater;
|
||||
}
|
||||
|
||||
@@ -107,24 +107,24 @@ class Updater {
|
||||
if (module.updateCommand) {
|
||||
Command = module.updateCommand;
|
||||
} else {
|
||||
Log.warn(`updatenotification: Update of ${module.name} is not supported.`);
|
||||
Log.warn(`Update of ${module.name} is not supported.`);
|
||||
return Result;
|
||||
}
|
||||
Log.info(`updatenotification: Updating ${module.name}...`);
|
||||
Log.info(`Updating ${module.name}...`);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
Exec(Command, { cwd: modulePath, timeout: this.timeout }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
Log.error(`updatenotification: exec error: ${error}`);
|
||||
Log.error(`exec error: ${error}`);
|
||||
Result.error = true;
|
||||
} else {
|
||||
Log.info(`updatenotification: Update logs of ${module.name}: ${stdout}`);
|
||||
Log.info(`Update logs of ${module.name}: ${stdout}`);
|
||||
Result.updated = true;
|
||||
if (this.autoRestart) {
|
||||
Log.info("updatenotification: Update done");
|
||||
Log.info("Update done");
|
||||
setTimeout(() => this.restart(), 3000);
|
||||
} else {
|
||||
Log.info("updatenotification: Update done, don't forget to restart MagicMirror!");
|
||||
Log.info("Update done, don't forget to restart MagicMirror!");
|
||||
Result.needRestart = true;
|
||||
}
|
||||
}
|
||||
@@ -133,71 +133,78 @@ class Updater {
|
||||
});
|
||||
}
|
||||
|
||||
// restart rules (pm2 or npm start)
|
||||
// restart rules (pm2 or node --run start)
|
||||
restart () {
|
||||
if (this.usePM2) this.pm2Restart();
|
||||
else this.npmRestart();
|
||||
else this.nodeRestart();
|
||||
}
|
||||
|
||||
// restart MagicMiror with "pm2"
|
||||
// restart MagicMirror with "pm2": use PM2Id for restart it
|
||||
pm2Restart () {
|
||||
Log.info("updatenotification: PM2 will restarting MagicMirror...");
|
||||
Log.info("[PM2] restarting MagicMirror...");
|
||||
const pm2 = require("pm2");
|
||||
pm2.restart(this.PM2, (err, proc) => {
|
||||
pm2.restart(this.PM2Id, (err, proc) => {
|
||||
if (err) {
|
||||
Log.error("updatenotification:[PM2] restart Error", err);
|
||||
Log.error("[PM2] restart Error", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// restart MagicMiror with "npm start"
|
||||
npmRestart () {
|
||||
Log.info("updatenotification: Restarting MagicMirror...");
|
||||
// restart MagicMirror with "node --run start"
|
||||
nodeRestart () {
|
||||
Log.info("Restarting MagicMirror...");
|
||||
const out = process.stdout;
|
||||
const err = process.stderr;
|
||||
const subprocess = Spawn("npm start", { cwd: this.root_path, shell: true, detached: true, stdio: ["ignore", out, err] });
|
||||
subprocess.unref();
|
||||
const subprocess = Spawn("node --run start", { cwd: this.root_path, shell: true, detached: true, stdio: ["ignore", out, err] });
|
||||
subprocess.unref(); // detach the newly launched process from the master process
|
||||
process.exit();
|
||||
}
|
||||
|
||||
// Check using pm2
|
||||
check_PM2_Process () {
|
||||
Log.info("updatenotification: Checking PM2 using...");
|
||||
Log.info("Checking PM2 using...");
|
||||
return new Promise((resolve) => {
|
||||
if (fs.existsSync("/.dockerenv")) {
|
||||
Log.info("updatenotification: Running in docker container, not using PM2 ...");
|
||||
this.usePM2 = false;
|
||||
Log.info("[PM2] Running in docker container, not using PM2 ...");
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.unique_id === undefined) {
|
||||
Log.info("[PM2] You are not using pm2");
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.debug(`[PM2] Search for pm2 id: ${process.env.pm_id} -- name: ${process.env.name} -- unique_id: ${process.env.unique_id}`);
|
||||
|
||||
const pm2 = require("pm2");
|
||||
pm2.connect((err) => {
|
||||
if (err) {
|
||||
Log.error("updatenotification: [PM2]", err);
|
||||
this.usePM2 = false;
|
||||
Log.error("[PM2]", err);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
pm2.list((err, list) => {
|
||||
if (err) {
|
||||
Log.error("updatenotification: [PM2] Can't get process List!");
|
||||
this.usePM2 = false;
|
||||
Log.error("[PM2] Can't get process List!");
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
list.forEach((pm) => {
|
||||
if (pm.pm2_env.version === this.version && pm.pm2_env.status === "online" && pm.pm2_env.pm_cwd.includes(`${this.root_path}/`)) {
|
||||
this.PM2 = pm.name;
|
||||
Log.debug(`[PM2] found pm2 process id: ${pm.pm_id} -- name: ${pm.name} -- unique_id: ${pm.pm2_env.unique_id}`);
|
||||
if (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) {
|
||||
this.PM2Id = pm.pm_id;
|
||||
this.usePM2 = true;
|
||||
Log.info("updatenotification: [PM2] You are using pm2 with", this.PM2);
|
||||
Log.info(`[PM2] You are using pm2 with id: ${this.PM2Id} (${pm.name})`);
|
||||
resolve(true);
|
||||
} else {
|
||||
Log.debug(`[PM2] pm2 process id: ${pm.pm_id} don't match...`);
|
||||
}
|
||||
});
|
||||
pm2.disconnect();
|
||||
if (!this.PM2) {
|
||||
Log.info("updatenotification: [PM2] You are not using pm2");
|
||||
this.usePM2 = false;
|
||||
if (!this.usePM2) {
|
||||
Log.info("[PM2] You are not using pm2");
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,7 +6,8 @@ Module.register("updatenotification", {
|
||||
sendUpdatesNotifications: false,
|
||||
updates: [],
|
||||
updateTimeout: 2 * 60 * 1000, // max update duration
|
||||
updateAutorestart: false // autoRestart MM when update done ?
|
||||
updateAutorestart: false, // autoRestart MM when update done ?
|
||||
useModulesFromConfig: true // if `false` iterate over modules directory
|
||||
},
|
||||
|
||||
suspended: false,
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
{% if not suspended %}
|
||||
{% if needRestart %}
|
||||
<div class="small bright">
|
||||
<i class="fas fa-rotate"></i>
|
||||
<span>
|
||||
{% set restartTextLabel = "UPDATE_NOTIFICATION_NEED-RESTART" %}
|
||||
{{ restartTextLabel | translate() | safe }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for name, status in moduleList %}
|
||||
<div class="small bright">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<span>
|
||||
{% set mainTextLabel = "UPDATE_NOTIFICATION" if name === "MagicMirror" else "UPDATE_NOTIFICATION_MODULE" %}
|
||||
{{ mainTextLabel | translate({MODULE_NAME: name}) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="xsmall dimmed">
|
||||
{% set subTextLabel = "UPDATE_INFO_SINGLE" if status.behind === 1 else "UPDATE_INFO_MULTIPLE" %}
|
||||
{{ subTextLabel | translate({COMMIT_COUNT: status.behind, BRANCH_NAME: status.current}) | diffLink(status) | safe }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for name, status in updatesList %}
|
||||
<div class="small bright">
|
||||
{% if status.done %}
|
||||
<i class="fas fa-check" style="color: lightgreen;"></i>
|
||||
<span>
|
||||
{% set updateTextLabel = "UPDATE_NOTIFICATION_DONE" %}
|
||||
{{ updateTextLabel | translate({MODULE_NAME: name}) | safe }}
|
||||
</span>
|
||||
{% else %}
|
||||
<i class="fas fa-xmark" style="color: red;"></i>
|
||||
<span>
|
||||
{% set updateTextLabel = "UPDATE_NOTIFICATION_ERROR" %}
|
||||
{{ updateTextLabel | translate({MODULE_NAME: name}) | safe }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if needRestart %}
|
||||
<div class="small bright">
|
||||
<i class="fas fa-rotate"></i>
|
||||
<span>
|
||||
{% set restartTextLabel = "UPDATE_NOTIFICATION_NEED-RESTART" %}
|
||||
{{ restartTextLabel | translate() | safe }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% for name, status in moduleList %}
|
||||
<div class="small bright">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<span>
|
||||
{% set mainTextLabel = "UPDATE_NOTIFICATION" if name === "MagicMirror" else "UPDATE_NOTIFICATION_MODULE" %}
|
||||
{{ mainTextLabel | translate({MODULE_NAME: name}) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="xsmall dimmed">
|
||||
{% set subTextLabel = "UPDATE_INFO_SINGLE" if status.behind === 1 else "UPDATE_INFO_MULTIPLE" %}
|
||||
{{ subTextLabel | translate({COMMIT_COUNT: status.behind, BRANCH_NAME: status.current}) | diffLink(status) | safe }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for name, status in updatesList %}
|
||||
<div class="small bright">
|
||||
{% if status.done %}
|
||||
<i class="fas fa-check" style="color: LightGreen;"></i>
|
||||
<span>
|
||||
{% set updateTextLabel = "UPDATE_NOTIFICATION_DONE" %}
|
||||
{{ updateTextLabel | translate({MODULE_NAME: name}) | safe }}
|
||||
</span>
|
||||
{% else %}
|
||||
<i class="fas fa-xmark" style="color: red;"></i>
|
||||
<span>
|
||||
{% set updateTextLabel = "UPDATE_NOTIFICATION_ERROR" %}
|
||||
{{ updateTextLabel | translate({MODULE_NAME: name}) | safe }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
@@ -1,34 +1,45 @@
|
||||
/**
|
||||
* A function to make HTTP requests via the server to avoid CORS-errors.
|
||||
* @param {string} url the url to fetch from
|
||||
* @param {string} type what contenttype to expect in the response, can be "json" or "xml"
|
||||
* @param {string} type what content-type to expect in the response, can be "json" or "xml"
|
||||
* @param {boolean} useCorsProxy A flag to indicate
|
||||
* @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send
|
||||
* @param {Array.<string>} expectedResponseHeaders the expected HTTP headers to receive
|
||||
* @param {string} basePath The base path, default is "/"
|
||||
* @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).
|
||||
*/
|
||||
async function performWebRequest (url, type = "json", useCorsProxy = false, requestHeaders = undefined, expectedResponseHeaders = undefined) {
|
||||
async function performWebRequest (url, type = "json", useCorsProxy = false, requestHeaders = undefined, expectedResponseHeaders = undefined, basePath = "/") {
|
||||
const request = {};
|
||||
let requestUrl;
|
||||
if (useCorsProxy) {
|
||||
requestUrl = getCorsUrl(url, requestHeaders, expectedResponseHeaders);
|
||||
requestUrl = getCorsUrl(url, requestHeaders, expectedResponseHeaders, basePath);
|
||||
} else {
|
||||
requestUrl = url;
|
||||
request.headers = getHeadersToSend(requestHeaders);
|
||||
}
|
||||
const response = await fetch(requestUrl, request);
|
||||
const data = await response.text();
|
||||
|
||||
if (type === "xml") {
|
||||
return new DOMParser().parseFromString(data, "text/html");
|
||||
} else {
|
||||
if (!data || !data.length > 0) return undefined;
|
||||
try {
|
||||
const response = await fetch(requestUrl, request);
|
||||
if (response.ok) {
|
||||
const data = await response.text();
|
||||
|
||||
const dataResponse = JSON.parse(data);
|
||||
if (!dataResponse.headers) {
|
||||
dataResponse.headers = getHeadersFromResponse(expectedResponseHeaders, response);
|
||||
if (type === "xml") {
|
||||
return new DOMParser().parseFromString(data, "text/html");
|
||||
} else {
|
||||
if (!data || !data.length > 0) return undefined;
|
||||
|
||||
const dataResponse = JSON.parse(data);
|
||||
if (!dataResponse.headers) {
|
||||
dataResponse.headers = getHeadersFromResponse(expectedResponseHeaders, response);
|
||||
}
|
||||
return dataResponse;
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Response status: ${response.status}`);
|
||||
}
|
||||
return dataResponse;
|
||||
} catch (error) {
|
||||
Log.error(`Error fetching data from ${url}: ${error}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,13 +48,14 @@ async function performWebRequest (url, type = "json", useCorsProxy = false, requ
|
||||
* @param {string} url the url to fetch from
|
||||
* @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send
|
||||
* @param {Array.<string>} expectedResponseHeaders the expected HTTP headers to receive
|
||||
* @param {string} basePath The base path, default is "/"
|
||||
* @returns {string} to be used as URL when calling CORS-method on server.
|
||||
*/
|
||||
const getCorsUrl = function (url, requestHeaders, expectedResponseHeaders) {
|
||||
const getCorsUrl = function (url, requestHeaders, expectedResponseHeaders, basePath = "/") {
|
||||
if (!url || url.length < 1) {
|
||||
throw new Error(`Invalid URL: ${url}`);
|
||||
} else {
|
||||
let corsUrl = `${location.protocol}//${location.host}/cors?`;
|
||||
let corsUrl = `${location.protocol}//${location.host}${basePath}cors?`;
|
||||
|
||||
const requestHeaderString = getRequestHeaderString(requestHeaders);
|
||||
if (requestHeaderString) corsUrl = `${corsUrl}sendheaders=${requestHeaderString}`;
|
||||
|
||||
@@ -1,101 +1,97 @@
|
||||
{% macro humidity() %}
|
||||
{% if current.humidity %}
|
||||
<span class="humidity"><span>{{ current.humidity | decimalSymbol }}</span><sup> <i class="wi wi-humidity humidity-icon"></i></sup></span>
|
||||
{% endif %}
|
||||
{% if current.humidity %}
|
||||
<span class="humidity"
|
||||
><span>{{ current.humidity | decimalSymbol }}</span><sup> <i class="wi wi-humidity humidity-icon"></i></sup
|
||||
></span>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
{% if current %}
|
||||
{% if not config.onlyTemp %}
|
||||
<div class="normal medium">
|
||||
<span class="wi wi-strong-wind dimmed"></span>
|
||||
<span>
|
||||
{{ current.windSpeed | unit("wind") | round }}
|
||||
{% if config.showWindDirection %}
|
||||
<sup>
|
||||
{% if config.showWindDirectionAsArrow %}
|
||||
<i class="fas fa-long-arrow-alt-down" style="transform:rotate({{ current.windFromDirection }}deg)"></i>
|
||||
{% else %}
|
||||
{{ current.cardinalWindDirection() | translate }}
|
||||
{% endif %}
|
||||
|
||||
</sup>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if config.showHumidity === "wind" %}
|
||||
{{ humidity() }}
|
||||
{% if not config.onlyTemp %}
|
||||
<div class="normal medium">
|
||||
<span class="wi wi-strong-wind dimmed"></span>
|
||||
<span>
|
||||
{{ current.windSpeed | unit("wind") | round }}
|
||||
{% if config.showWindDirection %}
|
||||
<sup>
|
||||
{% if config.showWindDirectionAsArrow %}
|
||||
<i class="fas fa-long-arrow-alt-down" style="transform:rotate({{ current.windFromDirection }}deg)"></i>
|
||||
{% else %}
|
||||
{{ current.cardinalWindDirection() | translate }}
|
||||
{% endif %}
|
||||
{% if config.showSun %}
|
||||
<span class="wi dimmed wi-{{ current.nextSunAction() }}"></span>
|
||||
<span>
|
||||
{% if current.nextSunAction() === "sunset" %}
|
||||
{{ current.sunset | formatTime }}
|
||||
{% else %}
|
||||
{{ current.sunrise | formatTime }}
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if config.showUVIndex %}
|
||||
<td class="align-right bright uv-index">
|
||||
<div class="wi dimmed wi-hot"></div>
|
||||
{{ current.uv_index }}
|
||||
</td>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="large">
|
||||
{% if config.showIndoorTemperature and indoor.temperature or config.showIndoorHumidity and indoor.humidity %}
|
||||
<span class="medium fas fa-home"></span>
|
||||
<span style="display: inline-block">
|
||||
{% if config.showIndoorTemperature and indoor.temperature %}
|
||||
<sup class="small" style="position: relative; display: block; text-align: left;">
|
||||
<span>
|
||||
{{ indoor.temperature | roundValue | unit("temperature") | decimalSymbol }}
|
||||
</span>
|
||||
</sup>
|
||||
{% endif %}
|
||||
{% if config.showIndoorHumidity and indoor.humidity %}
|
||||
<sub class="small" style="position: relative; display: block; text-align: left;">
|
||||
<span>
|
||||
{{ indoor.humidity | roundValue | unit("humidity") | decimalSymbol }}
|
||||
</span>
|
||||
</sub>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="light wi weathericon wi-{{ current.weatherType }}"></span>
|
||||
<span class="light bright">{{ current.temperature | roundValue | unit("temperature") | decimalSymbol }}</span>
|
||||
{% if config.showHumidity === "temp" %}
|
||||
<span class="medium bright">{{ humidity() }}</span>
|
||||
|
||||
</sup>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if config.showHumidity === "wind" %}
|
||||
{{ humidity() }}
|
||||
{% endif %}
|
||||
{% if config.showSun %}
|
||||
<span class="wi dimmed wi-{{ current.nextSunAction() }}"></span>
|
||||
<span>
|
||||
{% if current.nextSunAction() === "sunset" %}
|
||||
{{ current.sunset | formatTime }}
|
||||
{% else %}
|
||||
{{ current.sunrise | formatTime }}
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if config.showUVIndex %}
|
||||
<td class="align-right bright uv-index">
|
||||
<div class="wi dimmed wi-hot"></div>
|
||||
{{ current.uv_index }}
|
||||
</td>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if (config.showFeelsLike or config.showPrecipitationAmount or config.showPrecipitationProbability) and not config.onlyTemp %}
|
||||
<div class="normal medium feelslike">
|
||||
{% if config.showFeelsLike %}
|
||||
<span class="dimmed">
|
||||
{% if config.showHumidity === "feelslike" %}
|
||||
{{ humidity() }}
|
||||
{% endif %}
|
||||
{{ "FEELS" | translate({DEGREE: current.feelsLike() | roundValue | unit("temperature") | decimalSymbol }) }}
|
||||
</span>
|
||||
<br />
|
||||
{% endif %}
|
||||
{% if config.showPrecipitationAmount and current.precipitationAmount %}
|
||||
<span class="dimmed">
|
||||
<span class="precipitationLeadText">{{ "PRECIP_AMOUNT" | translate }}</span> {{ current.precipitationAmount | unit("precip", current.precipitationUnits) }}
|
||||
</span>
|
||||
<br />
|
||||
{% endif %}
|
||||
{% if config.showPrecipitationProbability and current.precipitationProbability %}
|
||||
<span class="dimmed">
|
||||
<span class="precipitationLeadText">{{ "PRECIP_POP" | translate }}</span> {{ current.precipitationProbability }}%
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex large type-temp">
|
||||
{% if config.showIndoorTemperature and indoor.temperature or config.showIndoorHumidity and indoor.humidity %}
|
||||
<span class="medium fas fa-home"></span>
|
||||
<span style="display: inline-block">
|
||||
{% if config.showIndoorTemperature and indoor.temperature %}
|
||||
<sup class="small" style="position: relative; display: block; text-align: left;">
|
||||
<span> {{ indoor.temperature | roundValue | unit("temperature") | decimalSymbol }} </span>
|
||||
</sup>
|
||||
{% endif %}
|
||||
{% if config.showIndoorHumidity and indoor.humidity %}
|
||||
<sub class="small" style="position: relative; display: block; text-align: left;">
|
||||
<span> {{ indoor.humidity | roundValue | unit("humidity") | decimalSymbol }} </span>
|
||||
</sub>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if config.showHumidity === "below" %}
|
||||
<span class="medium dimmed">{{ humidity() }}</span>
|
||||
{% if current.weatherType %}
|
||||
<span class="light wi weathericon wi-{{ current.weatherType }}"></span>
|
||||
{% endif %}
|
||||
<span class="light bright">{{ current.temperature | roundValue | unit("temperature") | decimalSymbol }}</span>
|
||||
{% if config.showHumidity === "temp" %}
|
||||
<span class="medium bright">{{ humidity() }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if (config.showFeelsLike or config.showPrecipitationAmount or config.showPrecipitationProbability) and not config.onlyTemp %}
|
||||
<div class="normal medium feelslike">
|
||||
{% if config.showFeelsLike %}
|
||||
<span class="dimmed">
|
||||
{% if config.showHumidity === "feelslike" %}
|
||||
{{ humidity() }}
|
||||
{% endif %}
|
||||
{{ "FEELS" | translate({DEGREE: current.feelsLike() | roundValue | unit("temperature") | decimalSymbol }) }}
|
||||
</span>
|
||||
<br />
|
||||
{% endif %}
|
||||
{% if config.showPrecipitationAmount and current.precipitationAmount %}
|
||||
<span class="dimmed"> <span class="precipitationLeadText">{{ "PRECIP_AMOUNT" | translate }}</span> {{ current.precipitationAmount | unit("precip", current.precipitationUnits) }} </span>
|
||||
<br />
|
||||
{% endif %}
|
||||
{% if config.showPrecipitationProbability and current.precipitationProbability %}
|
||||
<span class="dimmed"> <span class="precipitationLeadText">{{ "PRECIP_POP" | translate }}</span> {{ current.precipitationProbability }}% </span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if config.showHumidity === "below" %}
|
||||
<span class="medium dimmed">{{ humidity() }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="dimmed light small">{{ "LOADING" | translate }}</div>
|
||||
<div class="dimmed light small">{{ "LOADING" | translate }}</div>
|
||||
{% endif %}
|
||||
<!-- Uncomment the line below to see the contents of the `current` object. -->
|
||||
<!-- <div style="word-wrap:break-word" class="xsmall dimmed">{{current | dump}}</div> -->
|
||||
<!-- <div style="word-wrap:break-word" class="xsmall dimmed">{{ current | dump }}</div> -->
|
||||
|
||||
@@ -1,52 +1,46 @@
|
||||
{% if forecast %}
|
||||
{% set numSteps = forecast | calcNumSteps %}
|
||||
{% set currentStep = 0 %}
|
||||
<table class="{{ config.tableClass }}">
|
||||
{% if config.ignoreToday %}
|
||||
{% set forecast = forecast.splice(1) %}
|
||||
{% set numSteps = forecast | calcNumSteps %}
|
||||
{% set currentStep = 0 %}
|
||||
<table class="{{ config.tableClass }}">
|
||||
{% if config.ignoreToday %}
|
||||
{% set forecast = forecast.splice(1) %}
|
||||
{% endif %}
|
||||
{% set forecast = forecast.slice(0, numSteps) %}
|
||||
{% for f in forecast %}
|
||||
<tr
|
||||
{% if config.colored %}class="colored"{% endif %}
|
||||
{% if config.fade %}style="opacity: {{ currentStep | opacity(numSteps) }};"{% endif %}
|
||||
>
|
||||
{% if (currentStep == 0) and config.ignoreToday == false and config.absoluteDates == false %}
|
||||
<td class="day">{{ "TODAY" | translate }}</td>
|
||||
{% elif (currentStep == 1) and config.ignoreToday == false and config.absoluteDates == false %}
|
||||
<td class="day">{{ "TOMORROW" | translate }}</td>
|
||||
{% else %}
|
||||
<td class="day">{{ f.date.format(config.forecastDateFormat) }}</td>
|
||||
{% endif %}
|
||||
{% set forecast = forecast.slice(0, numSteps) %}
|
||||
{% for f in forecast %}
|
||||
<tr {% if config.colored %}class="colored"{% endif %}
|
||||
{% if config.fade %}style="opacity: {{ currentStep | opacity(numSteps) }};"{% endif %}>
|
||||
{% if (currentStep == 0) and config.ignoreToday == false and config.absoluteDates == false %}
|
||||
<td class="day">{{ "TODAY" | translate }}</td>
|
||||
{% elif (currentStep == 1) and config.ignoreToday == false and config.absoluteDates == false %}
|
||||
<td class="day">{{ "TOMORROW" | translate }}</td>
|
||||
{% else %}
|
||||
<td class="day">{{ f.date.format("ddd") }}</td>
|
||||
{% endif %}
|
||||
<td class="bright weather-icon">
|
||||
<span class="wi weathericon wi-{{ f.weatherType }}"></span>
|
||||
</td>
|
||||
<td class="align-right bright max-temp">
|
||||
{{ f.maxTemperature | roundValue | unit("temperature") | decimalSymbol }}
|
||||
</td>
|
||||
<td class="align-right min-temp">
|
||||
{{ f.minTemperature | roundValue | unit("temperature") | decimalSymbol }}
|
||||
</td>
|
||||
{% if config.showPrecipitationAmount %}
|
||||
<td class="align-right bright precipitation-amount">
|
||||
{{ f.precipitationAmount | unit("precip", f.precipitationUnits) }}
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if config.showPrecipitationProbability %}
|
||||
<td class="align-right bright precipitation-prob">
|
||||
{{ f.precipitationProbability | unit('precip', '%') }}
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if config.showUVIndex %}
|
||||
<td class="align-right dimmed uv-index">
|
||||
{{ f.uv_index }}
|
||||
<span class="wi dimmed weathericon wi-hot"></span>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% set currentStep = currentStep + 1 %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
<td class="bright weather-icon">
|
||||
<span class="wi weathericon wi-{{ f.weatherType }}"></span>
|
||||
</td>
|
||||
<td class="align-right bright max-temp">{{ f.maxTemperature | roundValue | unit("temperature") | decimalSymbol }}</td>
|
||||
<td class="align-right min-temp">{{ f.minTemperature | roundValue | unit("temperature") | decimalSymbol }}</td>
|
||||
{% if config.showPrecipitationAmount %}
|
||||
<td class="align-right bright precipitation-amount">{{ f.precipitationAmount | unit("precip", f.precipitationUnits) }}</td>
|
||||
{% endif %}
|
||||
{% if config.showPrecipitationProbability %}
|
||||
<td class="align-right bright precipitation-prob">{{ f.precipitationProbability | unit('precip', '%') }}</td>
|
||||
{% endif %}
|
||||
{% if config.showUVIndex %}
|
||||
<td class="align-right dimmed uv-index">
|
||||
{{ f.uv_index }}
|
||||
<span class="wi dimmed weathericon wi-hot"></span>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% set currentStep = currentStep + 1 %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="dimmed light small">{{ "LOADING" | translate }}</div>
|
||||
<div class="dimmed light small">{{ "LOADING" | translate }}</div>
|
||||
{% endif %}
|
||||
<!-- Uncomment the line below to see the contents of the `forecast` object. -->
|
||||
<!-- <div style="word-wrap:break-word" class="xsmall dimmed">{{forecast | dump}}</div> -->
|
||||
<!-- <div style="word-wrap:break-word" class="xsmall dimmed">{{ forecast | dump }}</div> -->
|
||||
|
||||
@@ -1,42 +1,48 @@
|
||||
{% if hourly %}
|
||||
{% set numSteps = hourly | calcNumEntries %}
|
||||
{% set currentStep = 0 %}
|
||||
<table class="{{ config.tableClass }}">
|
||||
{% set hours = hourly.slice(0, numSteps) %}
|
||||
{% for hour in hours %}
|
||||
<tr {% if config.colored %}class="colored"{% endif %}
|
||||
{% if config.fade %}style="opacity: {{ currentStep | opacity(numSteps) }};"{% endif %}>
|
||||
<td class="day">{{ hour.date | formatTime }}</td>
|
||||
<td class="bright weather-icon">
|
||||
<span class="wi weathericon wi-{{ hour.weatherType }}"></span>
|
||||
</td>
|
||||
<td class="align-right bright">
|
||||
{{ hour.temperature | roundValue | unit("temperature") }}
|
||||
</td>
|
||||
{% if config.showUVIndex %}
|
||||
<td class="align-right bright uv-index">
|
||||
{% if hour.uv_index!=0 %}
|
||||
{{ hour.uv_index }}
|
||||
<span class="wi weathericon wi-hot"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if config.showPrecipitationAmount %}
|
||||
<td class="align-right bright precipitation-amount">
|
||||
{{ hour.precipitationAmount | unit("precip", hour.precipitationUnits) }}
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if config.showPrecipitationProbability %}
|
||||
<td class="align-right bright precipitation-prob">
|
||||
{{ hour.precipitationProbability | unit('precip', '%') }}
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% set currentStep = currentStep + 1 %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% set numSteps = hourly | calcNumEntries %}
|
||||
{% set currentStep = 0 %}
|
||||
<table class="{{ config.tableClass }}">
|
||||
{% set hours = hourly.slice(0, numSteps) %}
|
||||
{% for hour in hours %}
|
||||
<tr
|
||||
{% if config.colored %}class="colored"{% endif %}
|
||||
{% if config.fade %}style="opacity: {{ currentStep | opacity(numSteps) }};"{% endif %}
|
||||
>
|
||||
<td class="day">{{ hour.date | formatTime }}</td>
|
||||
<td class="bright weather-icon">
|
||||
<span class="wi weathericon wi-{{ hour.weatherType }}"></span>
|
||||
</td>
|
||||
<td class="align-right bright">{{ hour.temperature | roundValue | unit("temperature") }}</td>
|
||||
{% if config.showUVIndex %}
|
||||
<td class="align-right bright uv-index">
|
||||
{% if hour.uv_index!=0 %}
|
||||
{{ hour.uv_index }}
|
||||
<span class="wi weathericon wi-hot"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if config.showHumidity != "none" %}
|
||||
<td class="align-left bright humidity-hourly">
|
||||
{{ hour.humidity }}
|
||||
<span class="wi wi-humidity humidity-icon"></span>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if config.showPrecipitationAmount %}
|
||||
{% if (not config.hideZeroes or hour.precipitationAmount>0) %}
|
||||
<td class="align-right bright precipitation-amount">{{ hour.precipitationAmount | unit("precip", hour.precipitationUnits) }}</td>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if config.showPrecipitationProbability %}
|
||||
{% if (not config.hideZeroes or hour.precipitationAmount>0) %}
|
||||
<td class="align-right bright precipitation-prob">{{ hour.precipitationProbability | unit('precip', '%') }}</td>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% set currentStep = currentStep + 1 %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="dimmed light small">{{ "LOADING" | translate }}</div>
|
||||
<div class="dimmed light small">{{ "LOADING" | translate }}</div>
|
||||
{% endif %}
|
||||
<!-- Uncomment the line below to see the contents of the `hourly` object. -->
|
||||
<!-- <div style="word-wrap:break-word" class="xsmall dimmed">{{hourly | dump}}</div> -->
|
||||
<!-- <div style="word-wrap:break-word" class="xsmall dimmed">{{ hourly | dump }}</div> -->
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
* with locations you can search under column B (English Names), with the corresponding siteCode under
|
||||
* column A (Codes) and provCode under column C (Province).
|
||||
*
|
||||
* Acknowledgement: Some logic and code for parsing Environment Canada web pages is based on material from MMM-EnvCanada
|
||||
*
|
||||
* License to use Environment Canada (EC) data is detailed here:
|
||||
* https://eccc-msc.github.io/open-data/licence/readme_en/
|
||||
*/
|
||||
@@ -49,13 +51,16 @@ WeatherProvider.register("envcanada", {
|
||||
this.todayTempCacheMax = 0;
|
||||
this.todayCached = false;
|
||||
this.cacheCurrentTemp = 999;
|
||||
this.lastCityPageCurrent = " ";
|
||||
this.lastCityPageForecast = " ";
|
||||
this.lastCityPageHourly = " ";
|
||||
},
|
||||
|
||||
/*
|
||||
* Called when the weather provider is started
|
||||
*/
|
||||
start () {
|
||||
Log.info(`Weather provider: ${this.providerName} started.`);
|
||||
Log.info(`[weatherprovider.envcanada] ${this.providerName} started.`);
|
||||
this.setFetchedLocation(this.config.location);
|
||||
},
|
||||
|
||||
@@ -63,69 +68,158 @@ WeatherProvider.register("envcanada", {
|
||||
* Override the fetchCurrentWeather method to query EC and construct a Current weather object
|
||||
*/
|
||||
fetchCurrentWeather () {
|
||||
this.fetchData(this.getUrl(), "xml")
|
||||
.then((data) => {
|
||||
if (!data) {
|
||||
// Did not receive usable new data.
|
||||
return;
|
||||
}
|
||||
const currentWeather = this.generateWeatherObjectFromCurrentWeather(data);
|
||||
|
||||
this.setCurrentWeather(currentWeather);
|
||||
})
|
||||
.catch(function (request) {
|
||||
Log.error("Could not load EnvCanada site data ... ", request);
|
||||
})
|
||||
.finally(() => this.updateAvailable());
|
||||
this.fetchCommon("Current");
|
||||
},
|
||||
|
||||
/*
|
||||
* Override the fetchWeatherForecast method to query EC and construct Forecast weather objects
|
||||
* Override the fetchWeatherForecast method to query EC and construct Forecast/Daily weather objects
|
||||
*/
|
||||
fetchWeatherForecast () {
|
||||
this.fetchData(this.getUrl(), "xml")
|
||||
.then((data) => {
|
||||
if (!data) {
|
||||
// Did not receive usable new data.
|
||||
return;
|
||||
}
|
||||
const forecastWeather = this.generateWeatherObjectsFromForecast(data);
|
||||
|
||||
this.setWeatherForecast(forecastWeather);
|
||||
})
|
||||
.catch(function (request) {
|
||||
Log.error("Could not load EnvCanada forecast data ... ", request);
|
||||
})
|
||||
.finally(() => this.updateAvailable());
|
||||
this.fetchCommon("Forecast");
|
||||
|
||||
},
|
||||
|
||||
/*
|
||||
* Override the fetchWeatherHourly method to query EC and construct Forecast weather objects
|
||||
* Override the fetchWeatherHourly method to query EC and construct Hourly weather objects
|
||||
*/
|
||||
fetchWeatherHourly () {
|
||||
this.fetchData(this.getUrl(), "xml")
|
||||
.then((data) => {
|
||||
if (!data) {
|
||||
// Did not receive usable new data.
|
||||
return;
|
||||
}
|
||||
const hourlyWeather = this.generateWeatherObjectsFromHourly(data);
|
||||
|
||||
this.setWeatherHourly(hourlyWeather);
|
||||
})
|
||||
.catch(function (request) {
|
||||
Log.error("Could not load EnvCanada hourly data ... ", request);
|
||||
})
|
||||
.finally(() => this.updateAvailable());
|
||||
this.fetchCommon("Hourly");
|
||||
},
|
||||
|
||||
/*
|
||||
* Build the EC URL based on the Site Code and Province Code specified in the config params. Note that the
|
||||
* URL defaults to the English version simply because there is no language dependency in the data
|
||||
* being accessed. This is only pertinent when using the EC data elements that contain a textual forecast.
|
||||
* Because the process to fetch weather data is virtually the same for Current, Forecast/Daily, and Hourly weather,
|
||||
* a common module is used to access the EC weather data. The only customization (based on the caller of this routine)
|
||||
* is how the data will be parsed to satisfy the Weather module config in Config.js
|
||||
*
|
||||
* Accessing EC weather data is accomplished in 2 steps:
|
||||
*
|
||||
* 1. Query the MSC Datamart Index page, which returns a list of all the filenames for all the cities that have
|
||||
* weather data currently available.
|
||||
*
|
||||
* 2. With the city filename identified, build the appropriate URL and get the weather data (XML document) for the
|
||||
* city specified in the Weather module Config information
|
||||
*/
|
||||
fetchCommon (target) {
|
||||
const forecastURL = this.getUrl(); // Get the appropriate URL for the MSC Datamart Index page
|
||||
|
||||
Log.debug(`[weatherprovider.envcanada] ${target} Index url: ${forecastURL}`);
|
||||
|
||||
this.fetchData(forecastURL, "xml") // Query the Index page URL
|
||||
.then((indexData) => {
|
||||
if (!indexData) {
|
||||
// Did not receive usable new data.
|
||||
Log.info(`[weatherprovider.envcanada] ${target} - did not receive usable index data`);
|
||||
this.updateAvailable(); // If there were issues, update anyways to reset timer
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* With the Index page read, we must locate the filename/link for the specified city (aka Sitecode).
|
||||
* This is done by building the city filename and searching for it on the Index page. Once found,
|
||||
* extract the full filename (a unique name that includes dat/time, filename, etc.) and then add it
|
||||
* to the Index page URL to create the proper URL pointing to the city's weather data. Finally, read the
|
||||
* URL to pull in the city's XML document so that weather data can be parsed and displayed.
|
||||
*/
|
||||
|
||||
let forecastFile = "";
|
||||
let forecastFileURL = "";
|
||||
const fileSuffix = `${this.config.siteCode}_en.xml`; // Build city filename
|
||||
const nextFile = indexData.body.innerHTML.split(fileSuffix); // Find filename on Index page
|
||||
|
||||
if (nextFile.length > 1) { // Parse out the full unique file city filename
|
||||
// Find the last occurrence
|
||||
forecastFile = nextFile[nextFile.length - 2].slice(-41) + fileSuffix;
|
||||
forecastFileURL = forecastURL + forecastFile; // Create full URL to the city's weather data
|
||||
}
|
||||
|
||||
Log.debug(`[weatherprovider.envcanada] ${target} Citypage url: ${forecastFileURL}`);
|
||||
|
||||
/*
|
||||
* If the Citypage filename has not changed since the last Weather refresh, the forecast has not changed and
|
||||
* and therefore we can skip reading the Citypage URL.
|
||||
*/
|
||||
|
||||
if (target === "Current" && this.lastCityPageCurrent === forecastFileURL) {
|
||||
Log.debug(`[weatherprovider.envcanada] ${target} - Newest Citypage has already been seen - skipping!`);
|
||||
this.updateAvailable(); // Update anyways to reset refresh timer
|
||||
return;
|
||||
}
|
||||
|
||||
if (target === "Forecast" && this.lastCityPageForecast === forecastFileURL) {
|
||||
Log.debug(`[weatherprovider.envcanada] ${target} - Newest Citypage has already been seen - skipping!`);
|
||||
this.updateAvailable(); // Update anyways to reset refresh timer
|
||||
return;
|
||||
}
|
||||
|
||||
if (target === "Hourly" && this.lastCityPageHourly === forecastFileURL) {
|
||||
Log.debug(`[weatherprovider.envcanada] ${target} - Newest Citypage has already been seen - skipping!`);
|
||||
this.updateAvailable(); // Update anyways to reset refresh timer
|
||||
return;
|
||||
}
|
||||
|
||||
this.fetchData(forecastFileURL, "xml") // Read city's URL to get weather data
|
||||
.then((cityData) => {
|
||||
if (!cityData) {
|
||||
// Did not receive usable new data.
|
||||
Log.info(`[weatherprovider.envcanada] ${target} - did not receive usable citypage data`);
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* With the city's weather data read, parse the resulting XML document for the appropriate weather data
|
||||
* elements to create a weather object. Next, set Weather modules details from that object.
|
||||
*/
|
||||
Log.debug(`[weatherprovider.envcanada] ${target} - Citypage has been read and will be processed for updates`);
|
||||
|
||||
if (target === "Current") {
|
||||
const currentWeather = this.generateWeatherObjectFromCurrentWeather(cityData);
|
||||
this.setCurrentWeather(currentWeather);
|
||||
this.lastCityPageCurrent = forecastFileURL;
|
||||
}
|
||||
|
||||
if (target === "Forecast") {
|
||||
const forecastWeather = this.generateWeatherObjectsFromForecast(cityData);
|
||||
this.setWeatherForecast(forecastWeather);
|
||||
this.lastCityPageForecast = forecastFileURL;
|
||||
}
|
||||
|
||||
if (target === "Hourly") {
|
||||
const hourlyWeather = this.generateWeatherObjectsFromHourly(cityData);
|
||||
this.setWeatherHourly(hourlyWeather);
|
||||
this.lastCityPageHourly = forecastFileURL;
|
||||
}
|
||||
})
|
||||
.catch(function (cityRequest) {
|
||||
Log.info(`[weatherprovider.envcanada] ${target} - could not load citypage data from: ${forecastFileURL}`);
|
||||
})
|
||||
.finally(() => this.updateAvailable()); // Update no matter what to reset weather refresh timer
|
||||
})
|
||||
.catch(function (indexRequest) {
|
||||
Log.error(`[weatherprovider.envcanada] ${target} - could not load index data ... `, indexRequest);
|
||||
this.updateAvailable(); // If there were issues, update anyways to reset timer
|
||||
});
|
||||
},
|
||||
|
||||
/*
|
||||
* Build the EC Index page URL based on current GMT hour. The Index page will provide a list of links for each city
|
||||
* that will, in turn, provide actual weather data. The URL is comprised of 3 parts:
|
||||
*
|
||||
* Fixed value + Prov code specified in Weather module Config.js + current hour as GMT
|
||||
*/
|
||||
getUrl () {
|
||||
return `https://dd.weather.gc.ca/citypage_weather/xml/${this.config.provCode}/${this.config.siteCode}_e.xml`;
|
||||
let forecastURL = `https://dd.weather.gc.ca/today/citypage_weather/${this.config.provCode}`;
|
||||
const hour = this.getCurrentHourGMT();
|
||||
forecastURL += `/${hour}/`;
|
||||
return forecastURL;
|
||||
},
|
||||
|
||||
/*
|
||||
* Get current hour-of-day in GMT context
|
||||
*/
|
||||
getCurrentHourGMT () {
|
||||
const now = new Date();
|
||||
return now.toISOString().substring(11, 13); // "HH" in GMT
|
||||
},
|
||||
|
||||
/*
|
||||
@@ -150,7 +244,11 @@ WeatherProvider.register("envcanada", {
|
||||
currentWeather.temperature = this.cacheCurrentTemp;
|
||||
}
|
||||
|
||||
currentWeather.windSpeed = WeatherUtils.convertWindToMs(ECdoc.querySelector("siteData currentConditions wind speed").textContent);
|
||||
if (ECdoc.querySelector("siteData currentConditions wind speed").textContent === "calm") {
|
||||
currentWeather.windSpeed = "0";
|
||||
} else {
|
||||
currentWeather.windSpeed = WeatherUtils.convertWindToMs(ECdoc.querySelector("siteData currentConditions wind speed").textContent);
|
||||
}
|
||||
|
||||
currentWeather.windFromDirection = ECdoc.querySelector("siteData currentConditions wind bearing").textContent;
|
||||
|
||||
@@ -214,7 +312,7 @@ WeatherProvider.register("envcanada", {
|
||||
/*
|
||||
* The EC forecast is held in a 12-element array - Elements 0 to 11 - with each day encompassing
|
||||
* 2 elements. the first element for a day details the Today (daytime) forecast while the second
|
||||
* element details the Tonight (nightime) forecast. Element 0 is always for the current day.
|
||||
* element details the Tonight (nighttime) forecast. Element 0 is always for the current day.
|
||||
*
|
||||
* However... the forecast is somewhat 'rolling'.
|
||||
*
|
||||
@@ -225,9 +323,9 @@ WeatherProvider.register("envcanada", {
|
||||
*
|
||||
* But, if the EC forecast is queried in late afternoon, the Current Today forecast will be rolled
|
||||
* off and Element 0 will contain Current Tonight. From there, the next 5 days will be contained in
|
||||
* Elements 1/2, 3/4, 5/6, 7/8, and 9/10. As well, Elelement 11 will contain a forecast for a 6th day,
|
||||
* Elements 1/2, 3/4, 5/6, 7/8, and 9/10. As well, Element 11 will contain a forecast for a 6th day,
|
||||
* but only for the Today portion (not Tonight). This module will create a 6-day forecast using
|
||||
* Elements 0 to 11, and will ignore the additional Todat forecast in Element 11.
|
||||
* Elements 0 to 11, and will ignore the additional Today forecast in Element 11.
|
||||
*
|
||||
* We need to determine if Element 0 is showing the forecast for Current Today or Current Tonight.
|
||||
* This is required to understand how Min and Max temperature will be determined, and to understand
|
||||
@@ -333,7 +431,7 @@ WeatherProvider.register("envcanada", {
|
||||
|
||||
/*
|
||||
* The EC hourly forecast is held in a 24-element array - Elements 0 to 23 - with Element 0 holding
|
||||
* the forecast for the next 'on the hour' timeslot. This means the array is a rolling 24 hours.
|
||||
* the forecast for the next 'on the hour' time slot. This means the array is a rolling 24 hours.
|
||||
*/
|
||||
const hourGroup = ECdoc.querySelectorAll("siteData hourlyForecastGroup hourlyForecast");
|
||||
|
||||
@@ -366,7 +464,7 @@ WeatherProvider.register("envcanada", {
|
||||
},
|
||||
|
||||
/*
|
||||
* Determine Min and Max temp based on a supplied Forecast Element index and a boolen that denotes if
|
||||
* Determine Min and Max temp based on a supplied Forecast Element index and a boolean that denotes if
|
||||
* the next Forecast element should be considered - i.e. look at Today *and* Tonight vs.Tonight-only
|
||||
*/
|
||||
setMinMaxTemps (weather, foreGroup, today, fullDay, currentTemp) {
|
||||
@@ -436,17 +534,17 @@ WeatherProvider.register("envcanada", {
|
||||
* then it will be displayed ONLY if no POP is present.
|
||||
*
|
||||
* POP Logic: By default, we want to show the POP for 'daytime' since we are presuming that is what
|
||||
* people are more interested in seeing. While EC provides a separate POP for daytime and nightime portions
|
||||
* people are more interested in seeing. While EC provides a separate POP for daytime and nighttime portions
|
||||
* of each day, the weather module does not really allow for that view of a daily forecast. There we will
|
||||
* ignore any nightime portion. There is an exception however! For the Current day, the EC data will only show
|
||||
* the nightime forecast after a certain point in the afternoon. As such, we will be showing the nightime POP
|
||||
* ignore any nighttime portion. There is an exception however! For the Current day, the EC data will only show
|
||||
* the nighttime forecast after a certain point in the afternoon. As such, we will be showing the nighttime POP
|
||||
* (if one exists) in that specific scenario.
|
||||
*
|
||||
* Accumulation Logic: Similar to POP, we want to show accumulation for 'daytime' since we presume that is what
|
||||
* people are interested in seeing. While EC provides a separate accumulation for daytime and nightime portions
|
||||
* people are interested in seeing. While EC provides a separate accumulation for daytime and nighttime portions
|
||||
* of each day, the weather module does not really allow for that view of a daily forecast. There we will
|
||||
* ignore any nightime portion. There is an exception however! For the Current day, the EC data will only show
|
||||
* the nightime forecast after a certain point in that specific scenario.
|
||||
* ignore any nighttime portion. There is an exception however! For the Current day, the EC data will only show
|
||||
* the nighttime forecast after a certain point in that specific scenario.
|
||||
*/
|
||||
setPrecipitation (weather, foreGroup, today) {
|
||||
if (foreGroup[today].querySelector("precipitation accumulation")) {
|
||||
|
||||
@@ -13,7 +13,7 @@ WeatherProvider.register("openmeteo", {
|
||||
|
||||
/*
|
||||
* Set the name of the provider.
|
||||
* Not strictly required, but helps for debugging.
|
||||
* Not strictly required but helps for debugging.
|
||||
*/
|
||||
providerName: "Open-Meteo",
|
||||
|
||||
@@ -155,7 +155,7 @@ WeatherProvider.register("openmeteo", {
|
||||
this.setCurrentWeather(currentWeather);
|
||||
})
|
||||
.catch(function (request) {
|
||||
Log.error("Could not load data ... ", request);
|
||||
Log.error("[weatherprovider.openmeteo] Could not load data ... ", request);
|
||||
})
|
||||
.finally(() => this.updateAvailable());
|
||||
},
|
||||
@@ -173,7 +173,7 @@ WeatherProvider.register("openmeteo", {
|
||||
this.setWeatherForecast(dailyForecast);
|
||||
})
|
||||
.catch(function (request) {
|
||||
Log.error("Could not load data ... ", request);
|
||||
Log.error("[weatherprovider.openmeteo] Could not load data ... ", request);
|
||||
})
|
||||
.finally(() => this.updateAvailable());
|
||||
},
|
||||
@@ -191,7 +191,7 @@ WeatherProvider.register("openmeteo", {
|
||||
this.setWeatherHourly(hourlyForecast);
|
||||
})
|
||||
.catch(function (request) {
|
||||
Log.error("Could not load data ... ", request);
|
||||
Log.error("[weatherprovider.openmeteo] Could not load data ... ", request);
|
||||
})
|
||||
.finally(() => this.updateAvailable());
|
||||
},
|
||||
@@ -217,7 +217,7 @@ WeatherProvider.register("openmeteo", {
|
||||
this.config.maxEntries = Math.max(1, Math.min(this.config.maxEntries, maxEntriesLimit));
|
||||
|
||||
if (!this.config.type) {
|
||||
Log.error("type not configured and could not resolve it");
|
||||
Log.error("[weatherprovider.openmeteo] type not configured and could not resolve it");
|
||||
}
|
||||
|
||||
this.fetchLocation();
|
||||
@@ -244,17 +244,17 @@ WeatherProvider.register("openmeteo", {
|
||||
.add(Math.max(0, Math.min(7, this.config.maxNumberOfDays)), "days")
|
||||
.endOf("day");
|
||||
|
||||
params["start_date"] = startDate.format("YYYY-MM-DD");
|
||||
params.start_date = startDate.format("YYYY-MM-DD");
|
||||
|
||||
switch (this.config.type) {
|
||||
case "hourly":
|
||||
case "daily":
|
||||
case "forecast":
|
||||
params["end_date"] = endDate.format("YYYY-MM-DD");
|
||||
params.end_date = endDate.format("YYYY-MM-DD");
|
||||
break;
|
||||
case "current":
|
||||
params["current_weather"] = true;
|
||||
params["end_date"] = params["start_date"];
|
||||
params.current_weather = true;
|
||||
params.end_date = params.start_date;
|
||||
break;
|
||||
default:
|
||||
// Failsafe
|
||||
@@ -262,7 +262,7 @@ WeatherProvider.register("openmeteo", {
|
||||
}
|
||||
|
||||
return Object.keys(params)
|
||||
.filter((key) => (params[key] ? true : false))
|
||||
.filter((key) => (!!params[key]))
|
||||
.map((key) => {
|
||||
switch (key) {
|
||||
case "hourly":
|
||||
@@ -280,13 +280,24 @@ WeatherProvider.register("openmeteo", {
|
||||
return `${this.config.apiBase}/forecast?${this.getQueryParameters()}`;
|
||||
},
|
||||
|
||||
// fix daylight-saving-time differences
|
||||
checkDST (dt) {
|
||||
const uxdt = moment.unix(dt);
|
||||
const nowDST = moment().isDST();
|
||||
if (nowDST === moment(uxdt).isDST()) {
|
||||
return uxdt;
|
||||
} else {
|
||||
return uxdt.add(nowDST ? +1 : -1, "hour");
|
||||
}
|
||||
},
|
||||
|
||||
// Transpose hourly and daily data matrices
|
||||
transposeDataMatrix (data) {
|
||||
return data.time.map((_, index) => Object.keys(data).reduce((row, key) => {
|
||||
return {
|
||||
...row,
|
||||
// Parse time values as momentjs instances
|
||||
[key]: ["time", "sunrise", "sunset"].includes(key) ? moment.unix(data[key][index]) : data[key][index]
|
||||
// Parse time values as moment.js instances
|
||||
[key]: ["time", "sunrise", "sunset"].includes(key) ? this.checkDST(data[key][index]) : data[key][index]
|
||||
};
|
||||
}, {}));
|
||||
},
|
||||
@@ -340,7 +351,7 @@ WeatherProvider.register("openmeteo", {
|
||||
this.fetchedLocationName = `${data.city}, ${data.principalSubdivisionCode}`;
|
||||
})
|
||||
.catch((request) => {
|
||||
Log.error("Could not load data ... ", request);
|
||||
Log.error("[weatherprovider.openmeteo] Could not load data ... ", request);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -348,7 +359,7 @@ WeatherProvider.register("openmeteo", {
|
||||
generateWeatherDayFromCurrentWeather (weather) {
|
||||
|
||||
/**
|
||||
* Since some units comes from API response "splitted" into daily, hourly and current_weather
|
||||
* Since some units come from API response "splitted" into daily, hourly and current_weather
|
||||
* every time you request it, you have to ensure to get the data from the right place every time.
|
||||
* For the current weather case, the response have the following structure (after transposing):
|
||||
* ```
|
||||
@@ -381,6 +392,7 @@ WeatherProvider.register("openmeteo", {
|
||||
currentWeather.maxTemperature = parseFloat(weather.daily[0].temperature_2m_max);
|
||||
currentWeather.weatherType = this.convertWeatherType(weather.current_weather.weathercode, currentWeather.isDayTime());
|
||||
currentWeather.humidity = parseFloat(weather.hourly[h].relativehumidity_2m);
|
||||
currentWeather.feelsLikeTemp = parseFloat(weather.hourly[h].apparent_temperature);
|
||||
currentWeather.rain = parseFloat(weather.hourly[h].rain);
|
||||
currentWeather.snow = parseFloat(weather.hourly[h].snowfall * 10);
|
||||
currentWeather.precipitationAmount = parseFloat(weather.hourly[h].precipitation);
|
||||
@@ -470,7 +482,7 @@ WeatherProvider.register("openmeteo", {
|
||||
61: "rain-slight-intensity",
|
||||
63: "rain-moderate-intensity",
|
||||
65: "rain-heavy-intensity",
|
||||
66: "freezing-rain-light-heavy-intensity",
|
||||
66: "freezing-rain-light-intensity",
|
||||
67: "freezing-rain-heavy-intensity",
|
||||
71: "snow-fall-slight-intensity",
|
||||
73: "snow-fall-moderate-intensity",
|
||||
|
||||
@@ -17,10 +17,13 @@ WeatherProvider.register("openweathermap", {
|
||||
defaults: {
|
||||
apiVersion: "3.0",
|
||||
apiBase: "https://api.openweathermap.org/data/",
|
||||
weatherEndpoint: "", // can be "onecall", "forecast" or "weather" (for current)
|
||||
// weatherEndpoint is "/onecall" since API 3.0
|
||||
// "/onecall", "/forecast" or "/weather" only for pro customers
|
||||
weatherEndpoint: "/onecall",
|
||||
locationID: false,
|
||||
location: false,
|
||||
lat: 0, // the onecall endpoint needs lat / lon values, it doesn't support the locationId
|
||||
// the /onecall endpoint needs lat / lon values, it doesn't support the locationId
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
apiKey: ""
|
||||
},
|
||||
@@ -39,7 +42,7 @@ WeatherProvider.register("openweathermap", {
|
||||
this.setCurrentWeather(currentWeather);
|
||||
})
|
||||
.catch(function (request) {
|
||||
Log.error("Could not load data ... ", request);
|
||||
Log.error("[weatherprovider.openweathermap] Could not load data ... ", request);
|
||||
})
|
||||
.finally(() => this.updateAvailable());
|
||||
},
|
||||
@@ -61,7 +64,7 @@ WeatherProvider.register("openweathermap", {
|
||||
this.setFetchedLocation(location);
|
||||
})
|
||||
.catch(function (request) {
|
||||
Log.error("Could not load data ... ", request);
|
||||
Log.error("[weatherprovider.openweathermap] Could not load data ... ", request);
|
||||
})
|
||||
.finally(() => this.updateAvailable());
|
||||
},
|
||||
@@ -85,35 +88,11 @@ WeatherProvider.register("openweathermap", {
|
||||
this.setWeatherHourly(weatherData.hours);
|
||||
})
|
||||
.catch(function (request) {
|
||||
Log.error("Could not load data ... ", request);
|
||||
Log.error("[weatherprovider.openweathermap] Could not load data ... ", request);
|
||||
})
|
||||
.finally(() => this.updateAvailable());
|
||||
},
|
||||
|
||||
/**
|
||||
* Overrides method for setting config to check if endpoint is correct for hourly
|
||||
* @param {object} config The configuration object
|
||||
*/
|
||||
setConfig (config) {
|
||||
this.config = config;
|
||||
if (!this.config.weatherEndpoint) {
|
||||
switch (this.config.type) {
|
||||
case "hourly":
|
||||
this.config.weatherEndpoint = "/onecall";
|
||||
break;
|
||||
case "daily":
|
||||
case "forecast":
|
||||
this.config.weatherEndpoint = "/forecast";
|
||||
break;
|
||||
case "current":
|
||||
this.config.weatherEndpoint = "/weather";
|
||||
break;
|
||||
default:
|
||||
Log.error("weatherEndpoint not configured and could not resolve it based on type");
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** OpenWeatherMap Specific Methods - These are not part of the default provider methods */
|
||||
/*
|
||||
* Gets the complete url for the request
|
||||
@@ -306,12 +285,12 @@ WeatherProvider.register("openweathermap", {
|
||||
current.weatherType = this.convertWeatherType(data.current.weather[0].icon);
|
||||
current.humidity = data.current.humidity;
|
||||
current.uv_index = data.current.uvi;
|
||||
if (data.current.hasOwnProperty("rain") && !isNaN(data.current["rain"]["1h"])) {
|
||||
current.rain = data.current["rain"]["1h"];
|
||||
if (data.current.hasOwnProperty("rain") && !isNaN(data.current.rain["1h"])) {
|
||||
current.rain = data.current.rain["1h"];
|
||||
precip = true;
|
||||
}
|
||||
if (data.current.hasOwnProperty("snow") && !isNaN(data.current["snow"]["1h"])) {
|
||||
current.snow = data.current["snow"]["1h"];
|
||||
if (data.current.hasOwnProperty("snow") && !isNaN(data.current.snow["1h"])) {
|
||||
current.snow = data.current.snow["1h"];
|
||||
precip = true;
|
||||
}
|
||||
if (precip) {
|
||||
@@ -448,7 +427,7 @@ WeatherProvider.register("openweathermap", {
|
||||
} else if (this.firstEvent && this.firstEvent.location) {
|
||||
params += `q=${this.firstEvent.location}`;
|
||||
} else {
|
||||
// TODO hide doesnt exist!
|
||||
// TODO hide doesn't exist!
|
||||
this.hide(this.config.animationSpeed, { lockString: this.identifier });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ const OverrideWrapper = Class.extend({
|
||||
this.baseProvider.fetchWeatherForecast();
|
||||
},
|
||||
fetchWeatherHourly () {
|
||||
this.baseProvider.fetchEatherHourly();
|
||||
this.baseProvider.fetchWeatherHourly();
|
||||
},
|
||||
weatherForecast () {
|
||||
this.baseProvider.weatherForecast();
|
||||
@@ -84,7 +84,7 @@ const OverrideWrapper = Class.extend({
|
||||
},
|
||||
|
||||
/**
|
||||
* Override to combine the overrideWeatherObejct provided in the
|
||||
* Override to combine the overrideWeatherObject provided in the
|
||||
* notificationReceived method with the currentOverrideWeatherObject provided by the
|
||||
* api provider fetchData implementation.
|
||||
* @param {WeatherObject} currentWeatherObject - the api provider weather object
|
||||
|
||||
@@ -22,38 +22,36 @@ WeatherProvider.register("pirateweather", {
|
||||
lon: 0
|
||||
},
|
||||
|
||||
fetchCurrentWeather () {
|
||||
this.fetchData(this.getUrl())
|
||||
.then((data) => {
|
||||
if (!data || !data.currently || typeof data.currently.temperature === "undefined") {
|
||||
// No usable data?
|
||||
return;
|
||||
}
|
||||
async fetchCurrentWeather () {
|
||||
try {
|
||||
const data = await this.fetchData(this.getUrl());
|
||||
if (!data || !data.currently || typeof data.currently.temperature === "undefined") {
|
||||
throw new Error("No usable data received from Pirate Weather API.");
|
||||
}
|
||||
|
||||
const currentWeather = this.generateWeatherDayFromCurrentWeather(data);
|
||||
this.setCurrentWeather(currentWeather);
|
||||
})
|
||||
.catch(function (request) {
|
||||
Log.error("Could not load data ... ", request);
|
||||
})
|
||||
.finally(() => this.updateAvailable());
|
||||
const currentWeather = this.generateWeatherDayFromCurrentWeather(data);
|
||||
this.setCurrentWeather(currentWeather);
|
||||
} catch (error) {
|
||||
Log.error("Could not load data ... ", error);
|
||||
} finally {
|
||||
this.updateAvailable();
|
||||
}
|
||||
},
|
||||
|
||||
fetchWeatherForecast () {
|
||||
this.fetchData(this.getUrl())
|
||||
.then((data) => {
|
||||
if (!data || !data.daily || !data.daily.data.length) {
|
||||
// No usable data?
|
||||
return;
|
||||
}
|
||||
async fetchWeatherForecast () {
|
||||
try {
|
||||
const data = await this.fetchData(this.getUrl());
|
||||
if (!data || !data.daily || !data.daily.data.length) {
|
||||
throw new Error("No usable data received from Pirate Weather API.");
|
||||
}
|
||||
|
||||
const forecast = this.generateWeatherObjectsFromForecast(data.daily.data);
|
||||
this.setWeatherForecast(forecast);
|
||||
})
|
||||
.catch(function (request) {
|
||||
Log.error("Could not load data ... ", request);
|
||||
})
|
||||
.finally(() => this.updateAvailable());
|
||||
const forecast = this.generateWeatherObjectsFromForecast(data.daily.data);
|
||||
this.setWeatherForecast(forecast);
|
||||
} catch (error) {
|
||||
Log.error("Could not load data ... ", error);
|
||||
} finally {
|
||||
this.updateAvailable();
|
||||
}
|
||||
},
|
||||
|
||||
// Create a URL from the config and base URL.
|
||||
@@ -118,7 +116,7 @@ WeatherProvider.register("pirateweather", {
|
||||
rain: "rain",
|
||||
snow: "snow",
|
||||
sleet: "snow",
|
||||
wind: "wind",
|
||||
wind: "windy",
|
||||
fog: "fog",
|
||||
cloudy: "cloudy",
|
||||
"partly-cloudy-day": "day-cloudy",
|
||||
|
||||
@@ -28,7 +28,7 @@ WeatherProvider.register("smhi", {
|
||||
this.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`);
|
||||
this.setCurrentWeather(weatherObject);
|
||||
})
|
||||
.catch((error) => Log.error(`Could not load data: ${error.message}`))
|
||||
.catch((error) => Log.error(`[weatherprovider.smhi] Could not load data: ${error.message}`))
|
||||
.finally(() => this.updateAvailable());
|
||||
},
|
||||
|
||||
@@ -43,7 +43,7 @@ WeatherProvider.register("smhi", {
|
||||
this.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`);
|
||||
this.setWeatherForecast(weatherObjects);
|
||||
})
|
||||
.catch((error) => Log.error(`Could not load data: ${error.message}`))
|
||||
.catch((error) => Log.error(`[weatherprovider.smhi] Could not load data: ${error.message}`))
|
||||
.finally(() => this.updateAvailable());
|
||||
},
|
||||
|
||||
@@ -58,7 +58,7 @@ WeatherProvider.register("smhi", {
|
||||
this.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`);
|
||||
this.setWeatherHourly(weatherObjects);
|
||||
})
|
||||
.catch((error) => Log.error(`Could not load data: ${error.message}`))
|
||||
.catch((error) => Log.error(`[weatherprovider.smhi] Could not load data: ${error.message}`))
|
||||
.finally(() => this.updateAvailable());
|
||||
},
|
||||
|
||||
@@ -69,7 +69,7 @@ WeatherProvider.register("smhi", {
|
||||
setConfig (config) {
|
||||
this.config = config;
|
||||
if (!config.precipitationValue || ["pmin", "pmean", "pmedian", "pmax"].indexOf(config.precipitationValue) === -1) {
|
||||
Log.log(`invalid or not set: ${config.precipitationValue}`);
|
||||
Log.log(`[weatherprovider.smhi] invalid or not set: ${config.precipitationValue}`);
|
||||
config.precipitationValue = this.defaults.precipitationValue;
|
||||
}
|
||||
},
|
||||
@@ -141,7 +141,7 @@ WeatherProvider.register("smhi", {
|
||||
|
||||
/*
|
||||
* Determine the precipitation amount and category and update the
|
||||
* weatherObject with it, the valuetype to use can be configured or uses
|
||||
* weatherObject with it, the value type to use can be configured or uses
|
||||
* median as default.
|
||||
*/
|
||||
let precipitationValue = this.paramValue(weatherData, this.config.precipitationValue);
|
||||
@@ -173,7 +173,7 @@ WeatherProvider.register("smhi", {
|
||||
* @param {object[]} allWeatherData Array of weatherdata
|
||||
* @param {object} coordinates Coordinates of the locations of the weather
|
||||
* @param {string} groupBy The interval to use for grouping the data (day, hour)
|
||||
* @returns {WeatherObject[]} Array of weatherobjects
|
||||
* @returns {WeatherObject[]} Array of weather objects
|
||||
*/
|
||||
convertWeatherDataGroupedBy (allWeatherData, coordinates, groupBy = "day") {
|
||||
let currentWeather;
|
||||
@@ -254,7 +254,7 @@ WeatherProvider.register("smhi", {
|
||||
* Helper method to get a property from the returned data set.
|
||||
* @param {object} currentWeatherData Weatherdata to get from
|
||||
* @param {string} name The name of the property
|
||||
* @returns {*} The value of the property in the weatherdata
|
||||
* @returns {string} The value of the property in the weatherdata
|
||||
*/
|
||||
paramValue (currentWeatherData, name) {
|
||||
return currentWeatherData.parameters.filter((p) => p.name === name).flatMap((p) => p.values)[0];
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
/* global WeatherProvider, WeatherObject, WeatherUtils */
|
||||
|
||||
/*
|
||||
* This class is a provider for UK Met Office Datapoint,
|
||||
* see https://www.metoffice.gov.uk/
|
||||
*/
|
||||
WeatherProvider.register("ukmetoffice", {
|
||||
|
||||
/*
|
||||
* Set the name of the provider.
|
||||
* This isn't strictly necessary, since it will fallback to the provider identifier
|
||||
* But for debugging (and future alerts) it would be nice to have the real name.
|
||||
*/
|
||||
providerName: "UK Met Office",
|
||||
|
||||
// Set the default config properties that is specific to this provider
|
||||
defaults: {
|
||||
apiBase: "http://datapoint.metoffice.gov.uk/public/data/val/wxfcs/all/json/",
|
||||
locationID: false,
|
||||
apiKey: ""
|
||||
},
|
||||
|
||||
// Overwrite the fetchCurrentWeather method.
|
||||
fetchCurrentWeather () {
|
||||
this.fetchData(this.getUrl("3hourly"))
|
||||
.then((data) => {
|
||||
if (!data || !data.SiteRep || !data.SiteRep.DV || !data.SiteRep.DV.Location || !data.SiteRep.DV.Location.Period || data.SiteRep.DV.Location.Period.length === 0) {
|
||||
|
||||
/*
|
||||
* Did not receive usable new data.
|
||||
* Maybe this needs a better check?
|
||||
*/
|
||||
return;
|
||||
}
|
||||
|
||||
this.setFetchedLocation(`${data.SiteRep.DV.Location.name}, ${data.SiteRep.DV.Location.country}`);
|
||||
|
||||
const currentWeather = this.generateWeatherObjectFromCurrentWeather(data);
|
||||
this.setCurrentWeather(currentWeather);
|
||||
})
|
||||
.catch(function (request) {
|
||||
Log.error("Could not load data ... ", request);
|
||||
})
|
||||
.finally(() => this.updateAvailable());
|
||||
},
|
||||
|
||||
// Overwrite the fetchCurrentWeather method.
|
||||
fetchWeatherForecast () {
|
||||
this.fetchData(this.getUrl("daily"))
|
||||
.then((data) => {
|
||||
if (!data || !data.SiteRep || !data.SiteRep.DV || !data.SiteRep.DV.Location || !data.SiteRep.DV.Location.Period || data.SiteRep.DV.Location.Period.length === 0) {
|
||||
|
||||
/*
|
||||
* Did not receive usable new data.
|
||||
* Maybe this needs a better check?
|
||||
*/
|
||||
return;
|
||||
}
|
||||
|
||||
this.setFetchedLocation(`${data.SiteRep.DV.Location.name}, ${data.SiteRep.DV.Location.country}`);
|
||||
|
||||
const forecast = this.generateWeatherObjectsFromForecast(data);
|
||||
this.setWeatherForecast(forecast);
|
||||
})
|
||||
.catch(function (request) {
|
||||
Log.error("Could not load data ... ", request);
|
||||
})
|
||||
.finally(() => this.updateAvailable());
|
||||
},
|
||||
|
||||
/** UK Met Office Specific Methods - These are not part of the default provider methods */
|
||||
/*
|
||||
* Gets the complete url for the request
|
||||
*/
|
||||
getUrl (forecastType) {
|
||||
return this.config.apiBase + this.config.locationID + this.getParams(forecastType);
|
||||
},
|
||||
|
||||
/*
|
||||
* Generate a WeatherObject based on currentWeatherInformation
|
||||
*/
|
||||
generateWeatherObjectFromCurrentWeather (currentWeatherData) {
|
||||
const currentWeather = new WeatherObject();
|
||||
const location = currentWeatherData.SiteRep.DV.Location;
|
||||
|
||||
// data times are always UTC
|
||||
let nowUtc = moment.utc();
|
||||
let midnightUtc = nowUtc.clone().startOf("day");
|
||||
let timeInMins = nowUtc.diff(midnightUtc, "minutes");
|
||||
|
||||
// loop round each of the (5) periods, look for today (the first period may be yesterday)
|
||||
for (const period of location.Period) {
|
||||
const periodDate = moment.utc(period.value.substr(0, 10), "YYYY-MM-DD");
|
||||
|
||||
// ignore if period is before today
|
||||
if (periodDate.isSameOrAfter(moment.utc().startOf("day"))) {
|
||||
// check this is the period we want, after today the diff will be -ve
|
||||
if (moment().diff(periodDate, "minutes") > 0) {
|
||||
|
||||
/*
|
||||
* loop round the reports looking for the one we are in
|
||||
* $ value specifies the time in minutes-of-the-day: 0, 180, 360,...1260
|
||||
*/
|
||||
for (const rep of period.Rep) {
|
||||
const p = rep.$;
|
||||
if (timeInMins >= p && timeInMins - 180 < p) {
|
||||
// finally got the one we want, so populate weather object
|
||||
currentWeather.humidity = rep.H;
|
||||
currentWeather.temperature = rep.T;
|
||||
currentWeather.feelsLikeTemp = rep.F;
|
||||
currentWeather.precipitationProbability = parseInt(rep.Pp);
|
||||
currentWeather.windSpeed = WeatherUtils.convertWindToMetric(rep.S);
|
||||
currentWeather.windFromDirection = WeatherUtils.convertWindDirection(rep.D);
|
||||
currentWeather.weatherType = this.convertWeatherType(rep.W);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// determine the sunrise/sunset times - not supplied in UK Met Office data
|
||||
currentWeather.updateSunTime(location.lat, location.lon);
|
||||
|
||||
return currentWeather;
|
||||
},
|
||||
|
||||
/*
|
||||
* Generate WeatherObjects based on forecast information
|
||||
*/
|
||||
generateWeatherObjectsFromForecast (forecasts) {
|
||||
const days = [];
|
||||
|
||||
/*
|
||||
* loop round the (5) periods getting the data
|
||||
* for each period array, Day is [0], Night is [1]
|
||||
*/
|
||||
for (const period of forecasts.SiteRep.DV.Location.Period) {
|
||||
const weather = new WeatherObject();
|
||||
|
||||
// data times are always UTC
|
||||
const dateStr = period.value;
|
||||
let periodDate = moment.utc(dateStr.substr(0, 10), "YYYY-MM-DD");
|
||||
|
||||
// ignore if period is before today
|
||||
if (periodDate.isSameOrAfter(moment.utc().startOf("day"))) {
|
||||
// populate the weather object
|
||||
weather.date = moment.utc(dateStr.substr(0, 10), "YYYY-MM-DD");
|
||||
weather.minTemperature = period.Rep[1].Nm;
|
||||
weather.maxTemperature = period.Rep[0].Dm;
|
||||
weather.weatherType = this.convertWeatherType(period.Rep[0].W);
|
||||
weather.precipitationProbability = parseInt(period.Rep[0].PPd);
|
||||
|
||||
days.push(weather);
|
||||
}
|
||||
}
|
||||
|
||||
return days;
|
||||
},
|
||||
|
||||
/*
|
||||
* Convert the Met Office icons to a more usable name.
|
||||
*/
|
||||
convertWeatherType (weatherType) {
|
||||
const weatherTypes = {
|
||||
0: "night-clear",
|
||||
1: "day-sunny",
|
||||
2: "night-alt-cloudy",
|
||||
3: "day-cloudy",
|
||||
5: "fog",
|
||||
6: "fog",
|
||||
7: "cloudy",
|
||||
8: "cloud",
|
||||
9: "night-sprinkle",
|
||||
10: "day-sprinkle",
|
||||
11: "raindrops",
|
||||
12: "sprinkle",
|
||||
13: "night-alt-showers",
|
||||
14: "day-showers",
|
||||
15: "rain",
|
||||
16: "night-alt-sleet",
|
||||
17: "day-sleet",
|
||||
18: "sleet",
|
||||
19: "night-alt-hail",
|
||||
20: "day-hail",
|
||||
21: "hail",
|
||||
22: "night-alt-snow",
|
||||
23: "day-snow",
|
||||
24: "snow",
|
||||
25: "night-alt-snow",
|
||||
26: "day-snow",
|
||||
27: "snow",
|
||||
28: "night-alt-thunderstorm",
|
||||
29: "day-thunderstorm",
|
||||
30: "thunderstorm"
|
||||
};
|
||||
|
||||
return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Generates an url with api parameters based on the config.
|
||||
* @param {string} forecastType daily or 3hourly forecast
|
||||
* @returns {string} url
|
||||
*/
|
||||
getParams (forecastType) {
|
||||
let params = "?";
|
||||
params += `res=${forecastType}`;
|
||||
params += `&key=${this.config.apiKey}`;
|
||||
return params;
|
||||
}
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user