Compare commits

..

5 Commits

Author SHA1 Message Date
Kristjan ESPERANTO
b0c5924019 Release 2.33.0 (#3903) 2025-09-30 18:02:22 +02:00
Karsten Hassel
62b0f7f26e Release 2.32.0 (#3826)
## [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)

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Michael Teeuw <michael@xonaymedia.nl>
Co-authored-by: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ross Younger <crazyscot@gmail.com>
Co-authored-by: Veeck <github@veeck.de>
Co-authored-by: Bugsounet - Cédric <github@bugsounet.fr>
Co-authored-by: jkriegshauser <joshuakr@nvidia.com>
Co-authored-by: illimarkangur <116028111+illimarkangur@users.noreply.github.com>
Co-authored-by: sam detweiler <sdetweil@gmail.com>
Co-authored-by: vppencilsharpener <tim.pray@gmail.com>
Co-authored-by: veeck <michael.veeck@nebenan.de>
Co-authored-by: Paranoid93 <6515818+Paranoid93@users.noreply.github.com>
Co-authored-by: Brian O'Connor <btoconnor@users.noreply.github.com>
Co-authored-by: WallysWellies <59727507+WallysWellies@users.noreply.github.com>
Co-authored-by: Jason Stieber <jrstieber@gmail.com>
Co-authored-by: jargordon <50050429+jargordon@users.noreply.github.com>
Co-authored-by: Daniel <32464403+dkallen78@users.noreply.github.com>
Co-authored-by: Ryan Williams <65094007+ryan-d-williams@users.noreply.github.com>
Co-authored-by: Panagiotis Skias <panagiotis.skias@gmail.com>
Co-authored-by: Marc Landis <dirk.rettschlag@gmail.com>
Co-authored-by: HeikoGr <20295490+HeikoGr@users.noreply.github.com>
Co-authored-by: Pedro Lamas <pedrolamas@gmail.com>
Co-authored-by: veeck <gitkraken@veeck.de>
Co-authored-by: Magnus <34011212+MagMar94@users.noreply.github.com>
Co-authored-by: Ikko Eltociear Ashimine <eltociear@gmail.com>
Co-authored-by: DevIncomin <56730075+Developer-Incoming@users.noreply.github.com>
Co-authored-by: Nathan <n8nyoung@gmail.com>
Co-authored-by: mixasgr <mixasgr@users.noreply.github.com>
Co-authored-by: Savvas Adamtziloglou <savvas-gr@greeklug.gr>
Co-authored-by: Konstantinos <geraki@gmail.com>
Co-authored-by: OWL4C <124401812+OWL4C@users.noreply.github.com>
Co-authored-by: BugHaver <43462320+bughaver@users.noreply.github.com>
Co-authored-by: BugHaver <43462320+lsaadeh@users.noreply.github.com>
Co-authored-by: Koen Konst <koenspero@gmail.com>
Co-authored-by: Koen Konst <c.h.konst@avisi.nl>
2025-07-01 00:10:47 +02:00
Veeck
8e0b8468d3 Add Code of Conduct (#3763)
The project is lacking a Code of Conduct

---------

Co-authored-by: veeck <gitkraken@veeck.de>
2025-05-16 08:03:43 +02:00
Veeck
39a614e0de Release 2.31.0 (#3758)
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.
- 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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Michael Teeuw <michael@xonaymedia.nl>
Co-authored-by: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Karsten Hassel <hassel@gmx.de>
Co-authored-by: Ross Younger <crazyscot@gmail.com>
Co-authored-by: Bugsounet - Cédric <github@bugsounet.fr>
Co-authored-by: jkriegshauser <joshuakr@nvidia.com>
Co-authored-by: illimarkangur <116028111+illimarkangur@users.noreply.github.com>
Co-authored-by: sam detweiler <sdetweil@gmail.com>
Co-authored-by: vppencilsharpener <tim.pray@gmail.com>
Co-authored-by: Paranoid93 <6515818+Paranoid93@users.noreply.github.com>
Co-authored-by: Brian O'Connor <btoconnor@users.noreply.github.com>
Co-authored-by: WallysWellies <59727507+WallysWellies@users.noreply.github.com>
Co-authored-by: Jason Stieber <jrstieber@gmail.com>
Co-authored-by: jargordon <50050429+jargordon@users.noreply.github.com>
Co-authored-by: Daniel <32464403+dkallen78@users.noreply.github.com>
Co-authored-by: Ryan Williams <65094007+ryan-d-williams@users.noreply.github.com>
Co-authored-by: Panagiotis Skias <panagiotis.skias@gmail.com>
Co-authored-by: Marc Landis <dirk.rettschlag@gmail.com>
Co-authored-by: HeikoGr <20295490+HeikoGr@users.noreply.github.com>
Co-authored-by: Pedro Lamas <pedrolamas@gmail.com>
Co-authored-by: veeck <gitkraken@veeck.de>
Co-authored-by: Magnus <34011212+MagMar94@users.noreply.github.com>
Co-authored-by: Ikko Eltociear Ashimine <eltociear@gmail.com>
Co-authored-by: DevIncomin <56730075+Developer-Incoming@users.noreply.github.com>
Co-authored-by: Nathan <n8nyoung@gmail.com>
Co-authored-by: mixasgr <mixasgr@users.noreply.github.com>
Co-authored-by: Savvas Adamtziloglou <savvas-gr@greeklug.gr>
Co-authored-by: Konstantinos <geraki@gmail.com>
Co-authored-by: OWL4C <124401812+OWL4C@users.noreply.github.com>
2025-04-01 20:11:02 +02:00
Karsten Hassel
9c9a5359dd Use different issue templates (#3695)
This PR will introduce different issue templates for bug reports,
feature requests and so on on GitHub. There is still room for
fine-tuning, but it's reached a state to show it to you and get
feedback.

I think that this can lead to better bug reports.

You can see the result in my repo:
https://github.com/KristjanESPERANTO/MagicMirror/issues/new/choose

Feel free to create new issues for testing.

What do you think? Do we want to pursue this further?
# Conflicts:
#	CHANGELOG.md
2025-01-17 19:19:17 +01:00
184 changed files with 8416 additions and 5847 deletions

137
.github/CODE_OF_CONDUCT.md vendored Normal file
View 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

View File

@@ -8,59 +8,31 @@ We hold our code to standard, and these standards are documented below.
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 `npm run lint:prettier`.
To run prettier, use `node --run lint:prettier`.
### JavaScript: Run ESLint
We use [ESLint](https://eslint.org) to lint our JavaScript files. The 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. The configuration is in our `.stylelintrc.json` 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 `npm run markdownlint:css`.
To run markdownlint, use `node --run lint:markdown`.
## Testing
We use [Jest](https://jestjs.io) 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`.
## Submitting Issues
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 20 or later (recommended is 22).
**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.
So you can also run the specific tests with other commands, e.g. `node --run test:unit` or `npx jest tests/e2e/env_spec.js`.

View File

@@ -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 20 or later (recommended is 22).
**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
View 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.

View 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
View 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.

View 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.

View File

@@ -1,4 +1,4 @@
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:**
@@ -10,7 +10,7 @@ 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.

View File

@@ -18,23 +18,3 @@ updates:
- "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"

View File

@@ -18,42 +18,52 @@ jobs:
timeout-minutes: 15
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Use Node.js"
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 23
node-version: lts/*
cache: "npm"
- name: "Install dependencies"
run: |
npm run install-mm:dev
node --run install-mm:dev
- name: "Run linter tests"
run: |
npm run test:prettier
npm run test:js
npm run test:css
npm run test:markdown
node --run test:prettier
node --run test:js
node --run test:css
node --run test:markdown
test:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
matrix:
node-version: [20.18.1, 20.x, 22.x, 23.x]
node-version: [22.18.0, 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@v5
- name: "Use Node.js ${{ matrix.node-version }}"
uses: actions/setup-node@v4
uses: actions/setup-node@v5
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: "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
export WAYLAND_DISPLAY=wayland-0
node --run test

View File

@@ -13,6 +13,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: "Checkout code"
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Dependency Review"
uses: actions/dependency-review-action@v4

View File

@@ -8,21 +8,23 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.18.1, 20.x, 22.x, 23.x]
node-version: [22.18.0, 22.x, 24.x]
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: "Use Node.js ${{ matrix.node-version }}"
uses: actions/setup-node@v4
uses: actions/setup-node@v5
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
run: |
sudo apt-get update
sudo apt-get install gpiod libgpiod2 libgpiod-dev
- name: Install test library (node-libgpiod) to be rebuilded
run: npm install node-libgpiod
- name: Run electron-rebuild

View File

@@ -15,17 +15,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
ref: develop
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: "22"
node-version: lts/*
check-latest: true
cache: "npm"
- name: Install dependencies
run: |
npm run install-mm:dev
node --run install-mm:dev
- name: Run Spellcheck
run: npm run test:spellcheck
run: node --run test:spelling

View File

@@ -12,11 +12,11 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
- 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"
exempt-issue-labels: "pinned,security,under investigation,pr welcome,ready (coming with next release)"

8
.gitignore vendored
View File

@@ -10,8 +10,6 @@ coverage
.lock-wscript
build/Release
/node_modules/**/*
fonts/node_modules/**/*
vendor/node_modules/**/*
!/tests/node_modules/**/*
jspm_modules
.npm
@@ -67,6 +65,8 @@ Temporary Items
/css/*
!/css/custom.css.sample
!/css/main.css
!/css/roboto.css
!/css/font-awesome.css
# Ignore users config file but keep the sample.
/config/*
@@ -84,3 +84,7 @@ Temporary Items
# Ignore positions file (#3518)
js/positions.js
# Ignore lock files other than package-lock.json
pnpm-lock.yaml
yarn.lock

View File

@@ -1,7 +0,0 @@
{
"extends": ["stylelint-config-standard"],
"plugins": ["stylelint-prettier"],
"rules": {
"prettier/prettier": true
}
}

View File

@@ -7,6 +7,159 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
❤️ **Donate:** Enjoying MagicMirror²? [Please consider a donation!](https://magicmirror.builders/#donate) With your help we can continue to improve the MagicMirror².
## [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.
@@ -15,7 +168,7 @@ Thanks to: @xsorifc28, @HeikoGr, @bugsounet, @khassel, @KristjanESPERANTO, @reja
### Added
- [core] Add wayland and windows start options to `package.json` (#3594)
- [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)
@@ -24,7 +177,7 @@ Thanks to: @xsorifc28, @HeikoGr, @bugsounet, @khassel, @KristjanESPERANTO, @reja
- [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.
- [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)
@@ -103,7 +256,7 @@ 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
@@ -160,14 +313,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)
@@ -177,7 +330,7 @@ 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
@@ -322,7 +475,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
@@ -587,7 +740,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.
@@ -625,7 +778,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.
@@ -1421,7 +1574,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
@@ -1671,6 +1824,9 @@ It includes (but is not limited to) the following features:
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

View File

@@ -30,13 +30,16 @@ 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`
- [ ] check release link at the bottom of the file
- [ ] 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
- [ ] update `CHANGELOG.md`
- [ ] add all contributor names: `...`
- [ ] add min. node version: > ⚠️ This release needs nodejs version `v22.18.0` or higher
- [ ] check release link at the bottom of the file
- [ ] 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`
- [ ] 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`
@@ -53,7 +56,8 @@ Are done by
- [ ] update `package.json` and `package-lock.json` to reflect correct version number `2.xx.0-develop`
- [ ] draft new section in `CHANGELOG.md`
- [ ] create new release link at the bottom of the file
- [ ] 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

View File

@@ -1,6 +1,6 @@
# The MIT License (MIT)
Copyright © 2016-2024 Michael Teeuw
Copyright © 2016-2025 Michael Teeuw
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation

View File

@@ -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) {

View File

@@ -21,6 +21,7 @@
"browserwindow",
"bryanzzhu",
"btoconnor",
"bughaver",
"bugsounet",
"buxxi",
"byday",
@@ -44,6 +45,7 @@
"darksky",
"dateheader",
"dateheaders",
"dathbe",
"davide",
"DAYAFTERTOMORROW",
"DAYBEFOREYESTERDAY",
@@ -52,12 +54,16 @@
"dkallen",
"drivelist",
"DTEND",
"DTSTAMP",
"DTSTART",
"Duffman",
"earlman",
"easyas",
"eddiehung",
"Edgardos",
"Ekristoffe",
"elec",
"eltociear",
"envcanada",
"envsub",
"envsubst",
@@ -81,10 +87,12 @@
"fulldate",
"fullday",
"fullscreen",
"geraki",
"Gevoelstemperatuur",
"GHSA",
"ghsas",
"grenagit",
"Heiko",
"Hirschberger",
"hourlyweather",
"Hwind",
@@ -103,6 +111,7 @@
"jsonlint",
"jupadin",
"kaennchenstruggle",
"Kalenderwoche",
"kenzal",
"Keyport",
"khassel",
@@ -117,6 +126,7 @@
"krekos",
"Kristjan",
"krukle",
"labwc",
"Landis",
"larryare",
"letsencrypt",
@@ -132,12 +142,15 @@
"martingron",
"marvai",
"mastermerge",
"matchtype",
"maxentries",
"Meteo",
"michaelteeuw",
"michmich",
"Midori",
"mirontoli",
"MISSINGLANG",
"mixasgr",
"MMPM",
"modernizr",
"modulename",
@@ -164,6 +177,7 @@
"oraclesean",
"oscarb",
"philnagel",
"plebcity",
"Português",
"PRECIP",
"Problema",
@@ -178,9 +192,11 @@
"rohitdharavath",
"Rosso",
"rrule",
"savvadam",
"sdetweil",
"sendheaders",
"serveronly",
"sexualized",
"skpanagiotis",
"SMHI",
"Snille",
@@ -195,6 +211,7 @@
"sunaction",
"suncalc",
"suntimes",
"symboltest",
"systeminformation",
"tada",
"taglist",
@@ -219,6 +236,7 @@
"Weatherflow",
"weatherforecast",
"weathergov",
"weathericon",
"weathericons",
"weatherobject",
"weatherutils",
@@ -228,11 +246,12 @@
"xlarge",
"xrandr",
"xsmall",
"xsorifc",
"xwindows",
"xxxe",
"Ybbet",
"yearmatchgroup"
],
"ignorePaths": ["node_modules/**", "modules/**", "vendor/node_modules/**", "translations/**", "tests/mocks/**", "tests/e2e/modules/clock_es_spec.js", "fonts/roboto.css"],
"ignorePaths": ["node_modules/**", "modules/**", "translations/**", "tests/mocks/**", "tests/e2e/modules/clock_es_spec.js", "css/roboto.css"],
"dictionaries": ["node"]
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -1,14 +1,14 @@
import eslintPluginImport from "eslint-plugin-import";
import eslintPluginJest from "eslint-plugin-jest";
import eslintPluginJs from "@eslint/js";
import eslintPluginPackageJson from "eslint-plugin-package-json/configs/recommended";
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 jest from "eslint-plugin-jest";
import js from "@eslint/js";
import jsdocPlugin from "eslint-plugin-jsdoc";
import packageJson from "eslint-plugin-package-json";
import stylistic from "@stylistic/eslint-plugin";
const config = [
eslintPluginJs.configs.recommended,
eslintPluginImport.flatConfigs.recommended,
eslintPluginPackageJson,
export default defineConfig([
globalIgnores(["config/**", "modules/**/*", "!modules/default/**", "js/positions.js"]),
{
files: ["**/*.js"],
languageOptions: {
@@ -16,7 +16,6 @@ const config = [
globals: {
...globals.browser,
...globals.node,
...globals.jest,
Log: "readonly",
MM: "readonly",
Module: "readonly",
@@ -24,13 +23,9 @@ const config = [
moment: "readonly"
}
},
plugins: {
...eslintPluginStylistic.configs["all-flat"].plugins,
...eslintPluginJest.configs["flat/recommended"].plugins
},
plugins: {js, stylistic},
extends: [importX.recommended, jest.configs["flat/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",
@@ -58,9 +53,9 @@ const config = [
"dot-notation": "error",
eqeqeq: "error",
"id-length": "off",
"import/extensions": "error",
"import/newline-after-import": "error",
"import/order": "error",
"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",
@@ -85,11 +80,24 @@ const config = [
"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: {
@@ -99,23 +107,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/no-unresolved": "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",
"sort-keys": "error"
"one-var": ["error", "never"],
"sort-keys": "off"
}
},
{
@@ -123,10 +127,5 @@ const config = [
rules: {
"@stylistic/quotes": "off"
}
},
{
ignores: ["config/**", "modules/**/*", "!modules/default/**", "js/positions.js"]
}
];
export default config;
]);

View File

@@ -1,27 +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.1",
"@fontsource/roboto-condensed": "^5.1.1"
}
},
"node_modules/@fontsource/roboto": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.1.1.tgz",
"integrity": "sha512-XwVVXtERDQIM7HPUIbyDe0FP4SRovpjF7zMI8M7pbqFp3ahLJsJTd18h+E6pkar6UbV3btbwkKjYARr5M+SQow=="
},
"node_modules/@fontsource/roboto-condensed": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@fontsource/roboto-condensed/-/roboto-condensed-5.1.1.tgz",
"integrity": "sha512-0SYkGnWPsvyCI3TAqBYAglfVUqVu/fsdgsyl5u396oK8ZgyamWHdQMFHDqCWrb4H4hNiewJT1l2ShDCA/cu6Ug=="
}
}
}

View File

@@ -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.1",
"@fontsource/roboto-condensed": "^5.1.1"
}
}

View File

@@ -12,8 +12,8 @@
<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">
@@ -42,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>

View File

@@ -1,33 +1,37 @@
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"]
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: {
logger: "<rootDir>/js/logger.js"
},
{
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"
};
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",
testMatch: ["**/tests/e2e/**/*.[jt]s?(x)"],
modulePaths: ["<rootDir>/js/"],
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;

View File

@@ -6,26 +6,26 @@ 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 } = 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}/`, "");
@@ -77,7 +77,7 @@ function App () {
// 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");
@@ -126,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?");
@@ -153,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 CHANGELOG 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 CHANGELOG for more up-to-date ways of getting the same functionality.`);
}
}
}
}
@@ -169,15 +181,15 @@ function App () {
const elements = module.split("/");
const moduleName = elements[elements.length - 1];
const env = getEnvVarsAsObj();
let moduleFolder = path.resolve(`${__dirname}/../${env.modulesDir}`, module);
let moduleFolder = path.resolve(`${global.root_path}/${env.modulesDir}`, module);
if (defaultModules.includes(moduleName)) {
const defaultModuleFolder = path.resolve(`${__dirname}/../modules/default/`, module);
const defaultModuleFolder = path.resolve(`${global.root_path}/modules/default/`, module);
if (process.env.JEST_WORKER_ID === undefined) {
moduleFolder = defaultModuleFolder;
} else {
// running in Jest, allow defaultModules placed under moduleDir for testing
if (env.modulesDir === "modules") {
if (env.modulesDir === "modules" || env.modulesDir === "tests/mocks") {
moduleFolder = defaultModuleFolder;
}
}
@@ -186,7 +198,7 @@ function App () {
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}.`);
}
@@ -195,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}.`);
@@ -352,7 +364,7 @@ function App () {
}
} catch (error) {
Log.error(`Error when stopping node_helper for module ${nodeHelper.name}:`);
console.error(error);
Log.error(error);
}
}

View File

@@ -1,7 +1,7 @@
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");
@@ -35,7 +35,7 @@ function checkConfigFile () {
// Check permission
try {
fs.accessSync(configFileName, fs.F_OK);
fs.accessSync(configFileName, fs.constants.F_OK);
} catch (error) {
throw new Error(`${error}\nNo permission to access config file!`);
}
@@ -54,13 +54,14 @@ function checkConfigFile () {
globals: {
...globals.node
}
}
},
rules: { "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 :(";
@@ -72,6 +73,10 @@ function checkConfigFile () {
}
}
/**
*
* @param {string} configFileName - The path and filename of the configuration file to validate.
*/
function validateModulePositions (configFileName) {
Log.info("Checking modules structure configuration ...");
@@ -107,7 +112,7 @@ 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 :)"));
} else {
const module = validate.errors[0].instancePath.split("/")[2];
const position = validate.errors[0].instancePath.split("/")[3];

View File

@@ -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>"
}
},
{

View File

@@ -1,3 +1,4 @@
module.exports = {
configs: ["kioskmode"]
configs: ["kioskmode"],
clock: ["secondsColor"]
};

View File

@@ -112,7 +112,7 @@ 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

View File

@@ -108,7 +108,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 +218,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 +260,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.

View File

@@ -2,7 +2,7 @@
(function (root, factory) {
if (typeof exports === "object") {
if (process.env.JEST_WORKER_ID === undefined) {
const colors = require("ansis");
const { styleText } = require("node:util");
// add timestamps in front of log messages
require("console-stamp")(console, {
@@ -11,26 +11,35 @@
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;
}

View File

@@ -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,7 +262,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.
*/
const hideModule = function (module, speed, callback, options = {}) {
@@ -345,7 +347,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 +465,8 @@ const MM = (function () {
}
});
wrapper.style.display = showWrapper ? "block" : "none";
// move container definitions to main CSS
wrapper.className = showWrapper ? "container" : "container hidden";
});
};
@@ -549,7 +552,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) {
@@ -614,7 +617,7 @@ const MM = (function () {
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 +629,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 +688,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 +700,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) {

View File

@@ -68,7 +68,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;
@@ -140,7 +140,7 @@ 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) {
@@ -176,7 +176,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}`);
@@ -344,7 +344,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 +353,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 +362,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 +389,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) {

18
js/module_functions.js Normal file
View 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.JEST_WORKER_ID === undefined) {
// only set timer when not running in jest
let tmr = timer;
clearTimeout(tmr);
tmr = setTimeout(function () {
callback();
}, intervalMS);
}
};
module.exports = { scheduleTimer };

View File

@@ -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}`);

View File

@@ -6,9 +6,10 @@ 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 vendor = require(`${__dirname}/vendor`);
/**
* Server
@@ -41,7 +42,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,6 +55,29 @@ 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) {
@@ -72,8 +98,13 @@ function Server (config) {
app.use(helmet(config.httpHeaders));
app.use("/js", express.static(__dirname));
let directories = ["/config", "/css", "/fonts", "/modules", "/vendor", "/translations", "/tests/configs", "/tests/mocks"];
for (const directory of directories) {
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]}`);
}
const uniqDirs = [...new Set(directories)];
for (const directory of uniqDirs) {
app.use(directory, express.static(path.resolve(global.root_path + directory)));
}

View File

@@ -69,7 +69,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(",");
@@ -129,6 +129,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
@@ -155,4 +176,4 @@ function getEnvVars (req, res) {
res.send(obj);
}
module.exports = { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars, getEnvVarsAsObj };
module.exports = { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars, getEnvVarsAsObj, getUserAgent };

View File

@@ -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 () {};

View File

@@ -1,4 +1,3 @@
const execSync = require("node:child_process").execSync;
const path = require("node:path");
const rootPath = path.resolve(`${__dirname}/../`);
@@ -14,26 +13,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 systemDataString;
} catch (e) {
Log.error(e);
} catch (error) {
Log.error(error);
}
},
@@ -69,7 +76,7 @@ module.exports = {
fs.writeFileSync(discoveredPositionsJSFilename, `const modulePositions=${JSON.stringify(modulePositions)}`);
}
catch (error) {
console.error("unable to write js/positions.js with the discovered module positions\nmake the MagicMirror/js folder writeable by the user starting MagicMirror");
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

View File

@@ -25,6 +25,7 @@ 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",

View File

@@ -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;
}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -0,0 +1,4 @@
{
"sysTitle": "MagicMirror² Ειδοποίηση",
"welcome": "Καλώς ήρθατε, η εκκίνηση ήταν επιτυχής!"
}

View File

@@ -0,0 +1,4 @@
{
"sysTitle": "MagicMirror²-sciigo",
"welcome": "Bonvenon, lanĉo sukcesis!"
}

View File

@@ -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;
}

View File

@@ -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,
@@ -77,7 +77,7 @@ Module.register("calendar", {
// Define required scripts.
getScripts () {
return ["calendarutils.js", "moment.js"];
return ["calendarutils.js", "moment.js", "moment-timezone.js"];
},
// Define required translations.
@@ -168,8 +168,8 @@ Module.register("calendar", {
this.selfUpdate();
},
notificationReceived (notification, payload, sender) {
notificationReceived (notification, payload, sender) {
if (notification === "FETCH_CALENDAR") {
if (this.hasCalendarURL(payload.url)) {
this.sendSocketNotification(notification, { url: payload.url, id: this.identifier });
@@ -215,18 +215,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;
@@ -258,7 +248,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");
@@ -315,15 +307,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);
@@ -340,7 +329,7 @@ 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}`;
@@ -395,14 +384,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"))}`;
}
}
@@ -415,44 +404,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) {
// and has a duation
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));
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 && moment(event.startDate, "x").format("YYYYMMDD") !== moment(event.endDate, "x").format("YYYYMMDD")) {
if (this.config.showEnd && !this.config.showEndsOnlyWithDuration && !eventStartDateMoment.isSame(eventEndDateMoment, "d")) {
timeWrapper.innerHTML += "-";
timeWrapper.innerHTML += CalendarUtils.capFirst(moment(event.endDate, "x").format(this.config.fullDayEventDateFormat));
} else
if ((moment(event.startDate, "x").format("YYYYMMDD") !== moment(event.endDate, "x").format("YYYYMMDD")) && (moment(event.startDate, "x") < moment(now, "x"))) {
timeWrapper.innerHTML = CalendarUtils.capFirst(moment(now, "x").format(this.config.fullDayEventDateFormat));
}
} else if (this.config.getRelative > 0 && event.startDate < now) {
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
@@ -460,9 +448,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"));
}
@@ -470,15 +458,15 @@ 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) {
Log.debug("event not hidden and not fullday");
timeWrapper.innerHTML = `${CalendarUtils.capFirst(moment(event.startDate, "x").calendar(null, { sameElse: this.config.dateFormat }))}`;
timeWrapper.innerHTML = `${CalendarUtils.capFirst(eventStartDateMoment.calendar(null, { sameElse: this.config.dateFormat }))}`;
} else {
Log.debug("event full day or hidden");
timeWrapper.innerHTML = `${CalendarUtils.capFirst(
moment(event.startDate, "x").calendar(null, {
eventStartDateMoment.calendar(null, {
sameDay: this.config.showTimeToday ? "LT" : `[${this.translate("TODAY")}]`,
nextDay: `[${this.translate("TOMORROW")}]`,
nextWeek: "dddd",
@@ -488,7 +476,7 @@ Module.register("calendar", {
}
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") {
@@ -496,25 +484,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"));
}
}
Log.info("event fullday");
} else if (event.startDate - now < this.config.getRelative * ONE_HOUR) {
} else if (eventStartDateMoment.diff(now, "h") < this.config.getRelative) {
Log.info("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)
})
);
}
@@ -593,46 +581,45 @@ 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];
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)) {
@@ -641,47 +628,46 @@ Module.register("calendar", {
}
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) {
if (this.timestampToMoment(splitEvent.endDate).isAfter(now) && this.timestampToMoment(splitEvent.endDate).isSameOrBefore(future)) {
by_url_calevents.push(splitEvent);
}
}
@@ -689,12 +675,17 @@ Module.register("calendar", {
by_url_calevents.push(event);
}
}
by_url_calevents.sort(function (a, b) {
return a.startDate - b.startDate;
});
Log.debug(`pushing ${by_url_calevents.length} events to total with room for ${remainingEntries}`);
events = events.concat(by_url_calevents.slice(0, remainingEntries));
Log.debug(`events for calendar=${events.length}`);
if (limitNumberOfEntries) {
// sort entries before clipping
by_url_calevents.sort(function (a, b) {
return a.startDate - b.startDate;
});
Log.debug(`pushing ${by_url_calevents.length} events to total with room for ${remainingEntries}`);
events = events.concat(by_url_calevents.slice(0, remainingEntries));
Log.debug(`events for calendar=${events.length}`);
} else {
events = events.concat(by_url_calevents);
}
}
Log.info(`sorting events count=${events.length}`);
events.sort(function (a, b) {
@@ -709,30 +700,24 @@ 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;
}
@@ -891,7 +876,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 {property} The property
*/
getCalendarProperty (url, property, defaultValue) {
for (const calendar of this.config.calendars) {
@@ -907,7 +892,11 @@ 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);
if (p instanceof Array) p.push(className);
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];

View File

@@ -3,6 +3,8 @@ const ical = require("node-ical");
const Log = require("logger");
const NodeHelper = require("node_helper");
const CalendarFetcherUtils = require("./calendarfetcherutils");
const { getUserAgent } = require("#server_functions");
const { scheduleTimer } = require("#module_functions");
/**
*
@@ -29,10 +31,9 @@ const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEn
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}`
"User-Agent": getUserAgent()
};
if (selfSignedCert) {
@@ -65,28 +66,18 @@ const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEn
});
} catch (error) {
fetchFailedCallback(this, error);
scheduleTimer();
scheduleTimer(reloadTimer, reloadInterval, fetchCalendar);
return;
}
this.broadcastEvents();
scheduleTimer();
scheduleTimer(reloadTimer, reloadInterval, fetchCalendar);
})
.catch((error) => {
fetchFailedCallback(this, error);
scheduleTimer();
scheduleTimer(reloadTimer, reloadInterval, fetchCalendar);
});
};
/**
* Schedule the timer for the next update.
*/
const scheduleTimer = function () {
clearTimeout(reloadTimer);
reloadTimer = setTimeout(function () {
fetchCalendar();
}, reloadInterval);
};
/* public methods */
/**
@@ -106,7 +97,7 @@ const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEn
/**
* Sets the on success callback
* @param {Function} callback The on success callback.
* @param {eventsReceivedCallback} callback The on success callback.
*/
this.onReceive = function (callback) {
eventsReceivedCallback = callback;
@@ -114,7 +105,7 @@ const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEn
/**
* Sets the on error callback
* @param {Function} callback The on error callback.
* @param {fetchFailedCallback} callback The on error callback.
*/
this.onError = function (callback) {
fetchFailedCallback = callback;

View File

@@ -1,114 +1,130 @@
/**
* @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
* TODO This seems like an overly complicated way to exclude events based on the title.
* @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}`);
shouldEventBeExcluded (config, title) {
let result = {
excluded: false,
until: null
};
for (let f in config.excludedEvents) {
let filter = config.excludedEvents[f],
testTitle = title.toLowerCase(),
until = null,
useRegex = false,
regexFlags = "g";
// 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)
if (filter instanceof Object) {
if (typeof filter.until !== "undefined") {
until = filter.until;
}
}
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}`);
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 {
// 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();
filter = filter.toLowerCase();
}
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");
if (CalendarFetcherUtils.titleFilterApplies(testTitle, filter, useRegex, regexFlags)) {
if (until) {
result.until = until;
} else {
result.excluded = true;
}
break;
}
}
Log.debug(`adjustHours=${adjustHours}`);
return adjustHours;
return result;
},
/**
* 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;
// 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);
}
// subtract the max of the duration of this event or 1 day to find events in the past that are currently still running and should therefor be displayed.
const oneDayInMs = 24 * 60 * 60000;
let searchFromDate = pastLocalMoment.clone().subtract(Math.max(durationInMs, oneDayInMs), "milliseconds").toDate();
let searchToDate = futureLocalMoment.clone().add(1, "days").toDate();
Log.debug(`Search for recurring events between: ${searchFromDate} and ${searchToDate}`);
// if until is set, and its a full day event, force the time to midnight. rrule gets confused with non-00 offset
// looks like MS Outlook sets the until time incorrectly for fullday events
if ((rule.options.until !== undefined) && CalendarFetcherUtils.isFullDayEvent(event)) {
Log.debug("fixup rrule until");
rule.options.until = moment(rule.options.until).clone().startOf("day").add(1, "day")
.toDate();
}
Log.debug("fix rrule start=", rule.options.dtstart);
Log.debug("event before rrule.between=", JSON.stringify(event, null, 2), "exdates=", event.exdate);
Log.debug(`RRule: ${rule.toString()}`);
rule.options.tzid = null; // RRule gets *very* confused with timezones
let dates = rule.between(searchFromDate, searchToDate, true, () => {
return true;
});
Log.debug(`Title: ${event.summary}, with dates: \n\n${JSON.stringify(dates)}\n`);
// shouldn't need this anymore, as RRULE not passed junk
dates = dates.filter((d) => {
return JSON.stringify(d) !== "null";
});
// Dates are returned in UTC timezone but with localdatetime because tzid is null.
// So we map the date to a moment using the original timezone of the event.
return dates.map((d) => (event.start.tz ? moment.tz(d, "UTC").tz(event.start.tz, true) : moment.tz(d, "UTC").tz(CalendarFetcherUtils.getLocalTimezone(), true)));
},
/**
@@ -120,34 +136,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.
@@ -161,211 +176,47 @@ const CalendarFetcherUtils = {
if (event.type === "VEVENT") {
Log.debug(`Event:\n${JSON.stringify(event, null, 2)}`);
let startMoment = eventDate(event, "start");
let endMoment;
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 d1;
let d2;
// TODO This should be a seperate function.
if (event.rrule && typeof event.rrule !== "undefined" && !isFacebookBirthday) {
const rule = event.rrule;
// Recurring event.
let moments = CalendarFetcherUtils.getMomentsFromRecurringEvent(event, pastLocalMoment, futureLocalMoment, durationMs);
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.
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
}
const oneDayInMs = 24 * 60 * 60 * 1000;
d1 = new Date(new Date(pastLocal.valueOf() - oneDayInMs).getTime());
d2 = new Date(new Date(futureLocal.valueOf() + oneDayInMs).getTime());
Log.debug(`Search for recurring events between: ${d1} and ${d2}`);
event.start = rule.options.dtstart;
Log.debug("fix rrule start=", rule.options.dtstart);
Log.debug("event before rrule.between=", JSON.stringify(event, null, 2), "exdates=", event.exdate);
// fixup the exdate and recurrence date to local time too for post between() handling
CalendarFetcherUtils.fixEventtoLocal(event);
Log.debug(`RRule: ${rule.toString()}`);
rule.options.tzid = null; // RRule gets *very* confused with timezones
let dates = rule.between(d1, d2, true, () => { return true; });
Log.debug(`Title: ${event.summary}, with dates: \n\n${JSON.stringify(dates)}\n`);
// shouldn't need this anymore, as RRULE not passed junk
dates = dates.filter((d) => {
if (JSON.stringify(d) === "null") return false;
else return true;
});
// go thru all the rrule.between() dates and put back the tz offset removed so rrule.between would work
let datesLocal = [];
let offset = d1.getTimezoneOffset();
Log.debug("offset =", offset);
dates.forEach((d) => {
let dtext = d.toISOString().slice(0, -5);
Log.debug(" date text form without tz=", dtext);
let dLocal = new Date(d.valueOf() + (offset * 60000));
let offset2 = dLocal.getTimezoneOffset();
Log.debug("date after offset applied=", dLocal);
if (offset !== offset2) {
// woops, dst/std switch
let delta = offset - offset2;
Log.debug("offset delta=", delta);
dLocal = new Date(d.valueOf() + ((offset - delta) * 60000));
Log.debug("corrected normalized date=", dLocal);
} else Log.debug(" neutralized date=", dLocal);
datesLocal.push(dLocal);
});
dates = datesLocal;
// 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.
//
// i don't think we will ever see this anymore (oct 2024) due to code fixes for rrule.between()
//
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(d1, d2)) {
Log.debug("adding recurring event not found in between list =", d, " should not happen now using local dates oct 17,24");
dates.push(d);
}
}
}
// 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];
// Loop through the set of moment entries to see which recurrences should be added to our event list.
// TODO This should create an event per moment so we can change anything we want.
for (let m in moments) {
let curEvent = event;
let curDurationMs = durationMs;
let showRecurrence = true;
let recurringEventStartMoment = moments[m].tz(CalendarFetcherUtils.getLocalTimezone()).clone();
let recurringEventEndMoment = recurringEventStartMoment.clone().add(durationMs, "ms");
let startMoment = moment(date);
let dateKey = CalendarFetcherUtils.getDateKeyFromDate(date);
let dateKey = recurringEventStartMoment.tz("UTC").format("YYYY-MM-DD");
Log.debug("event date dateKey=", dateKey);
// For each date that we're checking, it's possible that there is a recurrence override for that one day.
@@ -375,12 +226,17 @@ const CalendarFetcherUtils = {
Log.debug("have a recurrence match for dateKey=", dateKey);
// We found an override, so for this recurrence, use a potentially different title, start date, and duration.
curEvent = curEvent.recurrences[dateKey];
curEvent.start = new Date(new Date(curEvent.start.valueOf()).getTime());
curEvent.end = new Date(new Date(curEvent.end.valueOf()).getTime());
startMoment = CalendarFetcherUtils.getAdjustedStartMoment(curEvent.start, event);
endMoment = CalendarFetcherUtils.getAdjustedStartMoment(curEvent.end, event);
date = curEvent.start;
curDurationMs = new Date(endMoment).valueOf() - startMoment.valueOf();
// Some event start/end dates don't have timezones
if (curEvent.start.tz) {
recurringEventStartMoment = moment(curEvent.start).tz(curEvent.start.tz).tz(CalendarFetcherUtils.getLocalTimezone());
} else {
recurringEventStartMoment = moment(curEvent.start).tz(CalendarFetcherUtils.getLocalTimezone());
}
if (curEvent.end.tz) {
recurringEventEndMoment = moment(curEvent.end).tz(curEvent.end.tz).tz(CalendarFetcherUtils.getLocalTimezone());
} else {
recurringEventEndMoment = moment(curEvent.end).tz(CalendarFetcherUtils.getLocalTimezone());
}
} else {
Log.debug("recurrence key ", dateKey, " doesn't match");
}
@@ -393,25 +249,20 @@ const CalendarFetcherUtils = {
showRecurrence = false;
}
}
Log.debug(`duration: ${curDurationMs}`);
startMoment = CalendarFetcherUtils.getAdjustedStartMoment(date, event);
endMoment = moment(startMoment.valueOf() + curDurationMs);
if (startMoment.valueOf() === endMoment.valueOf()) {
endMoment = endMoment.endOf("day");
if (recurringEventStartMoment.valueOf() === recurringEventEndMoment.valueOf()) {
recurringEventEndMoment = recurringEventEndMoment.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)) {
if (recurringEventEndMoment.isBefore(pastLocalMoment) || recurringEventStartMoment.isAfter(futureLocalMoment)) {
showRecurrence = false;
}
if (CalendarFetcherUtils.timeFilterApplies(now, endMoment, dateFilter)) {
if (CalendarFetcherUtils.timeFilterApplies(now, recurringEventEndMoment, eventFilterUntil)) {
showRecurrence = false;
}
@@ -419,8 +270,8 @@ const CalendarFetcherUtils = {
Log.debug(`saving event: ${recurrenceTitle}`);
newEvents.push({
title: recurrenceTitle,
startDate: startMoment.format("x"),
endDate: endMoment.format("x"),
startDate: recurringEventStartMoment.format("x"),
endDate: recurringEventEndMoment.format("x"),
fullDayEvent: CalendarFetcherUtils.isFullDayEvent(event),
recurringEvent: true,
class: event.class,
@@ -430,7 +281,7 @@ const CalendarFetcherUtils = {
description: description
});
} else {
Log.debug("not saving event ", recurrenceTitle, new Date(startMoment));
Log.debug("not saving event ", recurrenceTitle, eventStartMoment);
}
Log.debug(" ");
}
@@ -441,47 +292,41 @@ const CalendarFetcherUtils = {
// 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");
if (fullDayEvent && eventStartMoment.valueOf() === eventEndMoment.valueOf()) {
eventEndMoment = eventEndMoment.endOf("day");
}
if (config.includePastEvents) {
// Past event is too far in the past, so skip.
if (endMoment < pastLocalDate) {
if (eventEndMoment < pastLocalMoment) {
return;
}
} else {
// It's not a fullday event, and it is in the past, so skip.
if (!fullDayEvent && endMoment < now) {
if (!fullDayEvent && eventEndMoment < now) {
return;
}
// It's a fullday event, and it is before today, So skip.
if (fullDayEvent && endMoment <= todayLocal) {
if (fullDayEvent && eventEndMoment <= now.startOf("day")) {
return;
}
}
// It exceeds the maximumNumberOfDays limit, so skip.
if (startMoment > futureLocalDate) {
if (eventStartMoment > futureLocalMoment) {
return;
}
if (CalendarFetcherUtils.timeFilterApplies(now, endMoment, dateFilter)) {
if (CalendarFetcherUtils.timeFilterApplies(now, eventEndMoment, eventFilterUntil)) {
return;
}
// 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.
newEvents.push({
title: title,
startDate: startMoment.add(adjustHours, "hours").format("x"),
endDate: endMoment.add(adjustHours, "hours").format("x"),
startDate: eventStartMoment.format("x"),
endDate: eventEndMoment.format("x"),
fullDayEvent: fullDayEvent,
recurringEvent: false,
class: event.class,
@@ -501,212 +346,6 @@ const CalendarFetcherUtils = {
return newEvents;
},
/**
* fixup thew event fields that have dates to use local time
* BEFORE calling rrule.between
* @param the event being processed
* @returns nothing
*/
fixEventtoLocal (event) {
// if there are excluded dates, their date is incorrect and possibly key as well.
if (event.exdate !== undefined) {
Object.keys(event.exdate).forEach((dateKey) => {
// get the date
let exdate = event.exdate[dateKey];
Log.debug("exdate w key=", exdate);
//exdate=CalendarFetcherUtils.convertDateToLocalTime(exdate, event.end.tz)
exdate = new Date(new Date(exdate.valueOf() - ((120 * 60 * 1000))).getTime());
Log.debug("new exDate item=", exdate, " with old key=", dateKey);
let newkey = exdate.toISOString().slice(0, 10);
if (newkey !== dateKey) {
Log.debug("new exDate item=", exdate, ` key=${newkey}`);
event.exdate[newkey] = exdate;
//delete event.exdate[dateKey]
}
});
Log.debug("updated exdate list=", event.exdate);
}
if (event.recurrences) {
Object.keys(event.recurrences).forEach((dateKey) => {
let exdate = event.recurrences[dateKey];
//exdate=new Date(new Date(exdate.valueOf()-(60*60*1000)).getTime())
Log.debug("new recurrence item=", exdate, " with old key=", dateKey);
exdate.start = CalendarFetcherUtils.convertDateToLocalTime(exdate.start, exdate.start.tz);
exdate.end = CalendarFetcherUtils.convertDateToLocalTime(exdate.end, exdate.end.tz);
Log.debug("adjusted recurringEvent start=", exdate.start, " end=", exdate.end);
});
}
Log.debug("modified recurrences before rrule.between", event.recurrences);
},
/**
* convert a UTC date to local time
* BEFORE calling rrule.between
* @param date ti conert
* tz event is currently in
* @returns updated date object
*/
convertDateToLocalTime (date, tz) {
let delta_tz_offset = 0;
let now_offset = CalendarFetcherUtils.getTimezoneOffsetFromTimezone(moment.tz.guess());
let event_offset = CalendarFetcherUtils.getTimezoneOffsetFromTimezone(tz);
Log.debug("date to convert=", date);
if (Math.sign(now_offset) !== Math.sign(event_offset)) {
delta_tz_offset = Math.abs(now_offset) + Math.abs(event_offset);
} else {
// signs are the same
// if negative
if (Math.sign(now_offset) === -1) {
// la looking at chicago
if (now_offset < event_offset) { // 5 -7
delta_tz_offset = now_offset - event_offset;
}
else { //7 -5 , chicago looking at LA
delta_tz_offset = event_offset - now_offset;
}
}
else {
// berlin looking at sydney
if (now_offset < event_offset) { // 5 -7
delta_tz_offset = event_offset - now_offset;
Log.debug("less delta=", delta_tz_offset);
}
else { // 11 - 2, sydney looking at berlin
delta_tz_offset = -(now_offset - event_offset);
Log.debug("more delta=", delta_tz_offset);
}
}
}
const newdate = new Date(new Date(date.valueOf() + (delta_tz_offset * 60 * 1000)).getTime());
Log.debug("modified date =", newdate);
return newdate;
},
/**
* get the exdate/recurrence hash key from the date object
* BEFORE calling rrule.between
* @param the date of the event
* @returns string date key YYYY-MM-DD
*/
getDateKeyFromDate (date) {
// get our runtime timezone offset
const nowDiff = CalendarFetcherUtils.getTimezoneOffsetFromTimezone(moment.tz.guess());
let startday = date.getDate();
let adjustment = 0;
Log.debug(" day of month=", (`0${startday}`).slice(-2), " nowDiff=", nowDiff, ` start time=${date.toString().split(" ")[4].slice(0, 2)}`);
Log.debug("date string= ", date.toString());
Log.debug("date iso string ", date.toISOString());
// if the dates are different
if (date.toString().slice(8, 10) < date.toISOString().slice(8, 10)) {
startday = date.toString().slice(8, 10);
Log.debug("< ", startday);
} else { // tostring is more
if (date.toString().slice(8, 10) > date.toISOString().slice(8, 10)) {
startday = date.toISOString().slice(8, 10);
Log.debug("> ", startday);
}
}
return date.toISOString().substring(0, 8) + (`0${startday}`).slice(-2);
},
/**
* get the timezone offset from the timezone string
*
* @param the timezone string
* @returns the numerical offset
*/
getTimezoneOffsetFromTimezone (timeZone) {
const str = new Date().toLocaleString("en", { timeZone, timeZoneName: "longOffset" });
Log.debug("tz offset=", str);
const [_, h, m] = str.match(/([+-]\d+):(\d+)$/) || ["", "+00", "00"];
return h * 60 + (h > 0 ? +m : -m);
},
/**
* fixup the date start moment after rrule.between returns date array
*
* @param date object from rrule.between results
* the event object it came from
* @returns moment object
*/
getAdjustedStartMoment (date, event) {
let startMoment = moment(date);
Log.debug("startMoment pre=", startMoment);
// get our runtime timezone offset
const nowDiff = CalendarFetcherUtils.getTimezoneOffsetFromTimezone(moment.tz.guess()); // 10/18 16:49, 300
let eventDiff = CalendarFetcherUtils.getTimezoneOffsetFromTimezone(event.end.tz); // watch out, start tz is cleared to handle rrule 120 23:49
Log.debug("tz diff event=", eventDiff, " local=", nowDiff, " end event timezone=", event.end.tz);
// if the diffs are different (not same tz for processing as event)
if (nowDiff !== eventDiff) {
// if signs are different
if (Math.sign(nowDiff) !== Math.sign(eventDiff)) {
// its the accumulated total
Log.debug("diff signs, accumulate");
eventDiff = Math.abs(eventDiff) + Math.abs(nowDiff);
// sign of diff depends on where you are looking at which event.
// australia looking at US, add to get same time
Log.debug("new different event diff=", eventDiff);
if (Math.sign(nowDiff) === -1) {
eventDiff *= -1;
// US looking at australia event have to subtract
Log.debug("new diff, same sign, total event diff=", eventDiff);
}
}
else {
// signs are the same, all east of UTC or all west of UTC
// if the signs are negative (west of UTC)
Log.debug("signs are the same");
if (Math.sign(eventDiff) === -1) {
//if west, looking at more west
if (nowDiff < eventDiff) {
//-600 -420
eventDiff = -(eventDiff - (nowDiff - eventDiff)); //-180
Log.debug("now looking back east delta diff=", eventDiff);
}
else {
Log.debug("now looking more west");
eventDiff = Math.abs(eventDiff - nowDiff);
}
} else {
Log.debug("signs are both positive");
// signs are positive (east of UTC)
// berlin < sydney
if (nowDiff < eventDiff) {
// germany vs australia
eventDiff = -(eventDiff - nowDiff);
}
else {
// australia vs germany
//eventDiff = eventDiff; //- nowDiff
}
}
}
startMoment = moment.tz(new Date(date.valueOf() + (eventDiff * (60 * 1000))), event.end.tz);
} else {
Log.debug("same tz event and display");
eventDiff = 0;
startMoment = moment.tz(new Date(date.valueOf() - (eventDiff * (60 * 1000))), event.end.tz);
}
Log.debug("startMoment post=", startMoment);
return startMoment;
},
/**
* 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.
@@ -746,8 +385,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
*/
@@ -758,7 +397,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;

View File

@@ -5,6 +5,7 @@
*/
// Alias modules mentioned in package.js under _moduleAliases.
require("module-alias/register");
const Log = require("logger");
const CalendarFetcher = require("./calendarfetcher");
@@ -20,22 +21,22 @@ 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());
Log.log("------------------------------------------------------------");
process.exit(0);
});
fetcher.onError(function (fetcher, error) {
console.log("Fetcher error:");
console.log(error);
Log.log("Fetcher error:");
Log.log(error);
process.exit(1);
});
fetcher.startFetch();
console.log("Create fetcher done! ");
Log.log("Create fetcher done! ");

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -139,12 +139,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

View File

@@ -1,3 +1,3 @@
<div>
<iframe class="newsfeed-fullarticle" src="{{ url }}"></iframe>
<iframe class="newsfeed-fullarticle" src="{{ url }}"></iframe>
</div>

View File

@@ -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 {property} 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);
}
}

View File

@@ -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 %}

View File

@@ -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.
@@ -79,12 +81,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) => {
@@ -100,9 +102,8 @@ const NewsfeedFetcher = function (url, reloadInterval, encoding, logFeedWarnings
}
});
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 +121,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 */
/**

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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);
}

View File

@@ -133,10 +133,10 @@ 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": use PM2Id for restart it
@@ -150,12 +150,12 @@ class Updater {
});
}
// restart MagicMiror with "npm start"
npmRestart () {
// restart MagicMiror with "node --run start"
nodeRestart () {
Log.info("updatenotification: 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] });
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();
}

View File

@@ -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,

View File

@@ -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 %}

View File

@@ -5,7 +5,7 @@
* @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, default /
* @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, basePath = "/") {
@@ -38,7 +38,7 @@ 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, default /
* @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, basePath = "/") {

View File

@@ -1,101 +1,97 @@
{% macro humidity() %}
{% if current.humidity %}
<span class="humidity"><span>{{ current.humidity | decimalSymbol }}</span><sup>&nbsp;<i class="wi wi-humidity humidity-icon"></i></sup></span>
{% endif %}
{% if current.humidity %}
<span class="humidity"
><span>{{ current.humidity | decimalSymbol }}</span><sup>&nbsp;<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 %}
&nbsp;
</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>
&nbsp;
</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> -->

View File

@@ -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("ddd") }}</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> -->

View File

@@ -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> -->

View File

@@ -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,6 +51,9 @@ WeatherProvider.register("envcanada", {
this.todayTempCacheMax = 0;
this.todayCached = false;
this.cacheCurrentTemp = 999;
this.lastCityPageCurrent = " ";
this.lastCityPageForecast = " ";
this.lastCityPageHourly = " ";
},
/*
@@ -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 approriate URL for the MSC Datamart Index page
Log.debug(`[weather.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(`weather.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 unqiue 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(`[weather.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(`[weather.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(`[weather.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(`[weather.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(`weather.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(`[weather.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(`weather.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(`weather.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/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
},
/*
@@ -151,7 +245,6 @@ WeatherProvider.register("envcanada", {
}
currentWeather.windSpeed = WeatherUtils.convertWindToMs(ECdoc.querySelector("siteData currentConditions wind speed").textContent);
currentWeather.windFromDirection = ECdoc.querySelector("siteData currentConditions wind bearing").textContent;
currentWeather.humidity = ECdoc.querySelector("siteData currentConditions relativeHumidity").textContent;
@@ -214,7 +307,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,7 +318,7 @@ 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.
*
@@ -436,17 +529,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")) {

View File

@@ -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",
@@ -348,7 +348,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 +381,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 +471,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",

View File

@@ -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];

View File

@@ -25,14 +25,20 @@ WeatherProvider.register("weatherflow", {
const currentWeather = new WeatherObject();
currentWeather.date = moment();
// Other available values: air_density, brightness, delta_t, dew_point,
// pressure_trend (i.e. rising/falling), sea_level_pressure, wind gust, and more.
currentWeather.humidity = data.current_conditions.relative_humidity;
currentWeather.temperature = data.current_conditions.air_temperature;
currentWeather.feelsLikeTemp = data.current_conditions.feels_like;
currentWeather.windSpeed = WeatherUtils.convertWindToMs(data.current_conditions.wind_avg);
currentWeather.windFromDirection = data.current_conditions.wind_direction;
currentWeather.weatherType = data.forecast.daily[0].icon;
currentWeather.weatherType = this.convertWeatherType(data.current_conditions.icon);
currentWeather.uv_index = data.current_conditions.uv;
currentWeather.sunrise = moment.unix(data.forecast.daily[0].sunrise);
currentWeather.sunset = moment.unix(data.forecast.daily[0].sunset);
this.setCurrentWeather(currentWeather);
this.fetchedLocationName = data.location_name;
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
@@ -52,13 +58,27 @@ WeatherProvider.register("weatherflow", {
weather.minTemperature = forecast.air_temp_low;
weather.maxTemperature = forecast.air_temp_high;
weather.precipitationProbability = forecast.precip_probability;
weather.weatherType = forecast.icon;
weather.snow = 0;
weather.weatherType = this.convertWeatherType(forecast.icon);
// Must manually build UV and Precipitation from hourly
weather.precipitationAmount = 0.0; // This will sum up rain and snow
weather.precipitationUnits = "mm";
weather.uv_index = 0;
for (const hour of data.forecast.hourly) {
const hour_time = moment.unix(hour.time);
if (hour_time.day() === weather.date.day()) { // Iterate though until day is reached
// Get data from today
weather.uv_index = Math.max(weather.uv_index, hour.uv);
weather.precipitationAmount += (hour.precip ?? 0);
} else if (hour_time.diff(weather.date) >= 86400) {
break; // No more data to be found
}
}
days.push(weather);
}
this.setWeatherForecast(days);
this.fetchedLocationName = data.location_name;
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
@@ -66,6 +86,63 @@ WeatherProvider.register("weatherflow", {
.finally(() => this.updateAvailable());
},
fetchWeatherHourly () {
this.fetchData(this.getUrl())
.then((data) => {
const hours = [];
for (const hour of data.forecast.hourly) {
const weather = new WeatherObject();
weather.date = moment.unix(hour.time);
weather.temperature = hour.air_temperature;
weather.feelsLikeTemp = hour.feels_like;
weather.humidity = hour.relative_humidity;
weather.windSpeed = hour.wind_avg;
weather.windFromDirection = hour.wind_direction;
weather.weatherType = this.convertWeatherType(hour.icon);
weather.precipitationProbability = hour.precip_probability;
weather.precipitationAmount = hour.precip; // NOTE: precipitation type is available
weather.precipitationUnits = "mm"; // Hardcoded via request, TODO: Add conversion
weather.uv_index = hour.uv;
hours.push(weather);
if (hours.length >= 48) break; // 10 days of hours are available, best to trim down.
}
this.setWeatherHourly(hours);
this.fetchedLocationName = data.location_name;
})
.catch(function (request) {
Log.error("Could not load data ... ", request);
})
.finally(() => this.updateAvailable());
},
convertWeatherType (weatherType) {
const weatherTypes = {
"clear-day": "day-sunny",
"clear-night": "night-clear",
cloudy: "cloudy",
foggy: "fog",
"partly-cloudy-day": "day-cloudy",
"partly-cloudy-night": "night-alt-cloudy",
"possibly-rainy-day": "day-rain",
"possibly-rainy-night": "night-alt-rain",
"possibly-sleet-day": "day-sleet",
"possibly-sleet-night": "night-alt-sleet",
"possibly-snow-day": "day-snow",
"possibly-snow-night": "night-alt-snow",
"possibly-thunderstorm-day": "day-thunderstorm",
"possibly-thunderstorm-night": "night-alt-thunderstorm",
rainy: "rain",
sleet: "sleet",
snow: "snow",
thunderstorm: "thunderstorm",
windy: "strong-wind"
};
return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null;
},
// Create a URL from the config and base URL.
getUrl () {
return `${this.config.apiBase}better_forecast?station_id=${this.config.stationid}&units_temp=c&units_wind=kph&units_pressure=mb&units_precip=mm&units_distance=km&token=${this.config.token}`;

View File

@@ -218,7 +218,7 @@ WeatherProvider.register("weathergov", {
currentWeather.minTemperature = currentWeatherData.minTemperatureLast24Hours.value;
currentWeather.maxTemperature = currentWeatherData.maxTemperatureLast24Hours.value;
currentWeather.humidity = Math.round(currentWeatherData.relativeHumidity.value);
currentWeather.precipitationAmount = currentWeatherData.precipitationLastHour.value ? currentWeatherData.precipitationLastHour.value : currentWeatherData.precipitationLast3Hours.value;
currentWeather.precipitationAmount = currentWeatherData.precipitationLastHour?.value ?? currentWeatherData.precipitationLast3Hours?.value;
if (currentWeatherData.heatIndex.value !== null) {
currentWeather.feelsLikeTemp = currentWeatherData.heatIndex.value;
} else if (currentWeatherData.windChill.value !== null) {

View File

@@ -23,6 +23,10 @@ WeatherProvider.register("yr", {
Log.error("The Yr weather provider requires local storage.");
throw new Error("Local storage not available");
}
if (this.config.updateInterval < 600000) {
Log.warn("The Yr weather provider requires a minimum update interval of 10 minutes (600 000 ms). The configuration has been adjusted to meet this requirement.");
this.delegate.config.updateInterval = 600000;
}
Log.info(`Weather provider: ${this.providerName} started.`);
},
@@ -34,7 +38,7 @@ WeatherProvider.register("yr", {
})
.catch((error) => {
Log.error(error);
throw new Error(error);
this.updateAvailable();
});
},
@@ -119,7 +123,12 @@ WeatherProvider.register("yr", {
})
.catch((err) => {
Log.error(err);
reject("Unable to get weather data from Yr.");
if (weatherData) {
Log.warn("Using outdated cached weather data.");
resolve(weatherData);
} else {
reject("Unable to get weather data from Yr.");
}
})
.finally(() => {
localStorage.removeItem("yrIsFetchingWeatherData");
@@ -497,7 +506,7 @@ WeatherProvider.register("yr", {
})
.catch((error) => {
Log.error(error);
throw new Error(error);
this.updateAvailable();
});
},
@@ -530,7 +539,15 @@ WeatherProvider.register("yr", {
getHourlyForecastFrom (weatherData) {
const series = [];
const now = moment({
year: moment().year(),
month: moment().month(),
day: moment().date(),
hour: moment().hour()
});
for (const forecast of weatherData.properties.timeseries) {
if (now.isAfter(moment(forecast.time))) continue;
forecast.symbol = forecast.data.next_1_hours?.summary?.symbol_code;
forecast.precipitationAmount = forecast.data.next_1_hours?.details?.precipitation_amount;
forecast.precipitationProbability = forecast.data.next_1_hours?.details?.probability_of_precipitation;
@@ -600,7 +617,7 @@ WeatherProvider.register("yr", {
})
.catch((error) => {
Log.error(error);
throw new Error(error);
this.updateAvailable();
});
}
});

View File

@@ -1,9 +1,6 @@
.weather .weathericon,
.weather .fa-home {
font-size: 75%;
line-height: 65px;
display: inline-block;
transform: translate(0, -3px);
}
.weather .humidity-icon {
@@ -31,15 +28,12 @@
.weather .precipitation-amount,
.weather .precipitation-prob,
.weather .humidity-hourly,
.weather .uv-index {
padding-left: 20px;
padding-right: 0;
}
.weather tr .weathericon {
line-height: 25px;
}
.weather tr.colored .min-temp {
color: #bcddff;
}
@@ -47,3 +41,9 @@
.weather tr.colored .max-temp {
color: #ff8e99;
}
.weather .type-temp {
display: flex;
align-items: baseline;
gap: 10px;
}

View File

@@ -14,7 +14,8 @@ Module.register("weather", {
updateInterval: 10 * 60 * 1000, // every 10 minutes
animationSpeed: 1000,
showFeelsLike: true,
showHumidity: "none", // this is now a string; see current.njk
showHumidity: "none", // possible options for "current" weather are "none", "wind", "temp", "feelslike" or "below", for "hourly" weather "none" or "true"
hideZeroes: false, // hide zeroes (and empty columns) in hourly, currently only for precipitation
showIndoorHumidity: false,
showIndoorTemperature: false,
allowOverrideNotification: false,
@@ -162,11 +163,12 @@ Module.register("weather", {
// What to do when the weather provider has new information available?
updateAvailable () {
Log.log("New weather information available.");
this.updateDom(0);
// this value was changed from 0 to 300 to stabilize weather tests:
this.updateDom(300);
this.scheduleUpdate();
if (this.weatherProvider.currentWeather()) {
this.sendNotification("CURRENTWEATHER_TYPE", { type: this.weatherProvider.currentWeather().weatherType.replace("-", "_") });
this.sendNotification("CURRENTWEATHER_TYPE", { type: this.weatherProvider.currentWeather().weatherType?.replace("-", "_") });
}
const notificationPayload = {

View File

@@ -53,7 +53,7 @@ const WeatherUtils = {
/**
* Convert temp (from degrees C) into imperial or metric unit depending on
* your config
* @param {number} tempInC the temperature in celsius you want to convert
* @param {number} tempInC the temperature in Celsius you want to convert
* @param {string} unit can be 'imperial' or 'metric'
* @returns {number} the converted temperature
*/
@@ -61,6 +61,15 @@ const WeatherUtils = {
return unit === "imperial" ? tempInC * 1.8 + 32 : tempInC;
},
/**
* Convert temp (from degrees C) into metric unit
* @param {number} tempInF the temperature in Fahrenheit you want to convert
* @returns {number} the converted temperature
*/
convertTempToMetric (tempInF) {
return ((tempInF - 32) * 5) / 9;
},
/**
* Convert wind speed into another unit.
* @param {number} windInMS the windspeed in meter/sec you want to convert
@@ -118,27 +127,51 @@ const WeatherUtils = {
return kmh * 0.27777777777778;
},
/**
* Taken from https://community.home-assistant.io/t/calculating-apparent-feels-like-temperature/370834/18
* @param {number} temperature temperature in degrees Celsius
* @param {number} windSpeed wind speed in meter/second
* @param {number} humidity relative humidity in percent
* @returns {number} the feels like temperature in degrees Celsius
*/
calculateFeelsLike (temperature, windSpeed, humidity) {
const windInMph = this.convertWind(windSpeed, "imperial");
const tempInF = this.convertTemp(temperature, "imperial");
let feelsLike = tempInF;
if (windInMph > 3 && tempInF < 50) {
feelsLike = Math.round(35.74 + 0.6215 * tempInF - 35.75 * Math.pow(windInMph, 0.16) + 0.4275 * tempInF * Math.pow(windInMph, 0.16));
} else if (tempInF > 80 && humidity > 40) {
feelsLike
= -42.379
+ 2.04901523 * tempInF
+ 10.14333127 * humidity
- 0.22475541 * tempInF * humidity
- 6.83783 * Math.pow(10, -3) * tempInF * tempInF
- 5.481717 * Math.pow(10, -2) * humidity * humidity
+ 1.22874 * Math.pow(10, -3) * tempInF * tempInF * humidity
+ 8.5282 * Math.pow(10, -4) * tempInF * humidity * humidity
- 1.99 * Math.pow(10, -6) * tempInF * tempInF * humidity * humidity;
let HI;
let WC = tempInF;
// Calculate wind chill for certain conditions
if (tempInF <= 70 && windInMph >= 3) {
WC = 35.74 + (0.6215 * tempInF) - 35.75 * Math.pow(windInMph, 0.16) + ((0.4275 * tempInF) * Math.pow(windInMph, 0.16));
}
return ((feelsLike - 32) * 5) / 9;
// Steadman Heat Index Vorberechnung
const STEADMAN_HI = 0.5 * (tempInF + 61.0 + ((tempInF - 68.0) * 1.2) + (humidity * 0.094));
if (STEADMAN_HI >= 80) {
// Rothfusz-Komplex
const ROTHFUSZ_HI = -42.379 + 2.04901523 * tempInF + 10.14333127 * humidity - 0.22475541 * tempInF * humidity - 0.00683783 * tempInF * tempInF - 0.05481717 * humidity * humidity + 0.00122874 * tempInF * tempInF * humidity + 0.00085282 * tempInF * humidity * humidity - 0.00000199 * tempInF * tempInF * humidity * humidity;
HI = ROTHFUSZ_HI;
if (humidity < 13 && tempInF > 80 && tempInF < 112) {
const ADJUSTMENT = ((13 - humidity) / 4) * Math.pow(Math.abs(17 - (tempInF - 95)), 0.5) / 17; // sqrt Teil
HI = HI - ADJUSTMENT;
} else if (humidity > 85 && tempInF > 80 && tempInF < 87) {
const ADJUSTMENT = ((humidity - 85) / 10) * ((87 - tempInF) / 5);
HI = HI + ADJUSTMENT;
}
} else { HI = STEADMAN_HI; }
// Feuchte Lastberechnung FL
let FL;
if (tempInF < 50) { FL = WC; }
else if (tempInF >= 50 && tempInF < 70) { FL = ((70 - tempInF) / 20) * WC + ((tempInF - 50) / 20) * HI; }
else if (tempInF >= 70) { FL = HI; }
return this.convertTempToMetric(FL);
},
/**

7431
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "magicmirror",
"version": "2.30.0",
"version": "2.33.0",
"description": "The open source modular smart mirror platform.",
"keywords": [
"magic mirror",
@@ -22,91 +22,105 @@
"contributors": [
"https://github.com/MagicMirrorOrg/MagicMirror/graphs/contributors"
],
"type": "commonjs",
"imports": {
"#module_functions": {
"default": "./js/module_functions.js"
},
"#server_functions": {
"default": "./js/server_functions.js"
}
},
"main": "js/electron.js",
"scripts": {
"config:check": "node js/check_config.js",
"install-fonts": "echo \"Installing fonts ...\n\" && cd fonts && npm install --loglevel=error --no-audit --no-fund --no-update-notifier",
"postinstall": "git clean -df fonts vendor",
"install-mm": "npm install --no-audit --no-fund --no-update-notifier --only=prod --omit=dev",
"install-mm:dev": "npm install --no-audit --no-fund --no-update-notifier",
"install-vendor": "echo \"Installing vendor files ...\n\" && cd vendor && npm install --loglevel=error --no-audit --no-fund --no-update-notifier",
"lint:css": "stylelint 'css/main.css' 'fonts/*.css' 'modules/default/**/*.css' 'vendor/*.css' --config .stylelintrc.json --fix",
"lint:js": "eslint . --fix",
"lint:css": "stylelint 'css/main.css' 'css/roboto.css' 'css/font-awesome.css' 'modules/default/**/*.css' --fix",
"lint:js": "eslint --fix",
"lint:markdown": "markdownlint-cli2 . --fix",
"lint:prettier": "prettier . --write",
"postinstall": "npm run install-vendor && npm run install-fonts && echo \"MagicMirror² installation finished successfully! \n\"",
"prepare": "[ -f node_modules/.bin/husky ] && husky || echo no husky installed.",
"server": "node ./serveronly",
"start": "npm run start:x11",
"start:dev": "npm run start -- dev",
"start": "node --run start:x11",
"start:dev": "node --run start:x11 -- dev",
"start:wayland": "WAYLAND_DISPLAY=\"${WAYLAND_DISPLAY:=wayland-1}\" ./node_modules/.bin/electron js/electron.js --enable-features=UseOzonePlatform --ozone-platform=wayland",
"start:wayland:dev": "npm run start:wayland -- dev",
"start:wayland:dev": "node --run start:wayland -- dev",
"start:windows": ".\\node_modules\\.bin\\electron js\\electron.js",
"start:windows:dev": "npm run start:windows -- dev",
"start:windows:dev": "node --run start:windows -- dev",
"start:x11": "DISPLAY=\"${DISPLAY:=:0}\" ./node_modules/.bin/electron js/electron.js",
"start:x11:dev": "npm run start -- dev",
"start:x11:dev": "node --run start:x11 -- dev",
"test": "NODE_ENV=test jest -i --forceExit",
"test:calendar": "node ./modules/default/calendar/debug.js",
"test:coverage": "NODE_ENV=test jest --coverage -i --verbose false --forceExit",
"test:css": "stylelint 'css/main.css' 'fonts/*.css' 'modules/default/**/*.css' 'vendor/*.css' --config .stylelintrc.json",
"test:css": "stylelint 'css/main.css' 'css/roboto.css' 'css/font-awesome.css' 'modules/default/**/*.css'",
"test:e2e": "NODE_ENV=test jest --selectProjects e2e -i --forceExit",
"test:electron": "NODE_ENV=test jest --selectProjects electron -i --forceExit",
"test:js": "eslint .",
"test:js": "eslint",
"test:markdown": "markdownlint-cli2 .",
"test:prettier": "prettier . --check",
"test:spelling": "cspell . --gitignore",
"test:unit": "NODE_ENV=test jest --selectProjects unit"
},
"lint-staged": {
"*": "prettier --write",
"*": "prettier --ignore-unknown --write",
"*.js": "eslint --fix",
"*.css": "stylelint --fix"
},
"dependencies": {
"@fontsource/roboto": "^5.2.8",
"@fontsource/roboto-condensed": "^5.2.8",
"@fortawesome/fontawesome-free": "^7.0.1",
"ajv": "^8.17.1",
"ansis": "^3.5.2",
"animate.css": "^4.1.1",
"console-stamp": "^3.1.2",
"croner": "^9.1.0",
"envsub": "^4.1.0",
"eslint": "^9.17.0",
"express": "^4.21.2",
"eslint": "^9.36.0",
"express": "^5.1.0",
"express-ipfilter": "^1.3.2",
"feedme": "^2.0.2",
"helmet": "^8.0.0",
"helmet": "^8.1.0",
"html-to-text": "^9.0.5",
"iconv-lite": "^0.6.3",
"iconv-lite": "^0.7.0",
"module-alias": "^2.2.3",
"moment": "^2.30.1",
"node-ical": "^0.20.1",
"pm2": "^5.4.3",
"moment-timezone": "^0.6.0",
"node-ical": "^0.21.0",
"nunjucks": "^3.2.4",
"pm2": "^6.0.13",
"socket.io": "^4.8.1",
"suncalc": "^1.9.0",
"systeminformation": "^5.24.3",
"undici": "^7.2.0"
"systeminformation": "^5.27.10",
"undici": "^7.16.0",
"weathericons": "^2.1.0"
},
"devDependencies": {
"@stylistic/eslint-plugin": "^2.12.1",
"cspell": "^8.17.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jest": "^28.10.0",
"eslint-plugin-jsdoc": "^50.6.1",
"eslint-plugin-package-json": "^0.19.0",
"@stylistic/eslint-plugin": "^5.4.0",
"cspell": "^9.2.1",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-jest": "^29.0.1",
"eslint-plugin-jsdoc": "^60.1.1",
"eslint-plugin-package-json": "^0.56.3",
"express-basic-auth": "^1.2.1",
"husky": "^9.1.7",
"jest": "^29.7.0",
"jsdom": "^25.0.1",
"lint-staged": "^15.3.0",
"markdownlint-cli2": "^0.17.1",
"playwright": "^1.49.1",
"prettier": "^3.4.2",
"sinon": "^19.0.2",
"stylelint": "^16.12.0",
"stylelint-config-standard": "^36.0.1",
"stylelint-prettier": "^5.0.2"
"jest": "^30.1.3",
"jsdom": "^27.0.0",
"lint-staged": "^16.2.0",
"markdownlint-cli2": "^0.18.1",
"playwright": "^1.55.0",
"prettier": "^3.6.2",
"prettier-plugin-jinja-template": "^2.1.0",
"stylelint": "^16.24.0",
"stylelint-config-standard": "^39.0.0",
"stylelint-prettier": "^5.0.3"
},
"optionalDependencies": {
"electron": "^32.2.7"
"electron": "^38.1.2"
},
"engines": {
"node": ">=20.18.1 <21 || >=22"
"node": ">=22.18.0"
},
"_moduleAliases": {
"node_helper": "js/node_helper.js",

View File

@@ -1,10 +1,17 @@
const config = {
plugins: ["prettier-plugin-jinja-template"],
overrides: [
{
files: "*.md",
options: {
parser: "markdown"
}
},
{
files: ["*.njk"],
options: {
parser: "jinja-template"
}
}
],
trailingComma: "none"

View File

@@ -4,5 +4,5 @@ const Log = require("../js/logger");
app.start().then((config) => {
const bindAddress = config.address ? config.address : "localhost";
const httpType = config.useHttps ? "https" : "http";
Log.info(`\n>>> Ready to go! Please point your browser to: ${httpType}://${bindAddress}:${config.port} <<<`);
Log.info(`\n>>> Ready to go! Please point your browser to: ${httpType}://${bindAddress}:${global.mmPort || config.port} <<<`);
});

7
stylelint.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
extends: ["stylelint-config-standard", "stylelint-prettier/recommended"],
root: true,
rules: {}
};
export default config;

View File

@@ -1,16 +1,19 @@
exports.configFactory = (options) => {
return Object.assign(
{
electronOptions: {
webPreferences: {
nodeIntegration: true,
enableRemoteModule: true,
contextIsolation: false
}
},
if (typeof exports === "object") {
// running in nodejs (not in browser)
exports.configFactory = (options) => {
return Object.assign(
{
electronOptions: {
webPreferences: {
nodeIntegration: true,
enableRemoteModule: true,
contextIsolation: false
}
},
modules: []
},
options
);
};
modules: []
},
options
);
};
}

View File

@@ -0,0 +1,18 @@
let config = {
address: "0.0.0.0",
ipWhitelist: [],
modules: [
{
module: "alert",
config: {
display_time: 1000000,
welcome_message: false
}
}
]
};
/*************** DO NOT EDIT THE LINE BELOW ***************/
if (typeof module !== "undefined") {
module.exports = config;
}

View File

@@ -0,0 +1,18 @@
let config = {
address: "0.0.0.0",
ipWhitelist: [],
modules: [
{
module: "alert",
config: {
display_time: 1000000,
welcome_message: "Custom welcome message!"
}
}
]
};
/*************** DO NOT EDIT THE LINE BELOW ***************/
if (typeof module !== "undefined") {
module.exports = config;
}

View File

@@ -0,0 +1,33 @@
let config = {
address: "0.0.0.0",
ipWhitelist: [],
timeFormat: 24,
modules: [
{
module: "calendar",
position: "bottom_bar",
config: {
fade: false,
urgency: 0,
dateFormat: "Do.MMM, HH:mm",
fullDayEventDateFormat: "Do.MMM",
timeFormat: "absolute",
getRelative: 0,
maximumNumberOfDays: 28,
showEnd: true,
calendars: [
{
maximumEntries: 100,
url: "http://localhost:8080/tests/mocks/chicago-nyedge.ics"
}
]
}
}
]
};
/*************** DO NOT EDIT THE LINE BELOW ***************/
if (typeof module !== "undefined") {
module.exports = config;
}

View File

@@ -0,0 +1,39 @@
let config = {
address: "0.0.0.0",
ipWhitelist: [],
timeFormat: 12,
foreignModulesDir: "tests/mocks",
modules: [
{
module: "calendar",
position: "bottom_bar",
config: {
maximumEntries: 1,
calendars: [
{
fetchInterval: 10000, //7 * 24 * 60 * 60 * 1000,
symbol: ["calendar-check", "google"],
url: "http://localhost:8080/tests/mocks/12_events.ics"
}
]
}
},
{
module: "testNotification",
position: "bottom_bar",
config: {
debug: true,
match: {
matchtype: "count",
notificationID: "CALENDAR_EVENTS"
}
}
}
]
};
/*************** DO NOT EDIT THE LINE BELOW ***************/
if (typeof module !== "undefined") {
module.exports = config;
}

View File

@@ -0,0 +1,27 @@
let config = {
address: "0.0.0.0",
ipWhitelist: [],
timeFormat: 12,
modules: [
{
module: "calendar",
position: "bottom_bar",
config: {
hideDuplicates: false,
maximumEntries: 100,
calendars: [
{
maximumEntries: 100,
url: "http://localhost:8080/tests/mocks/fullday_until.ics"
}
]
}
}
]
};
/*************** DO NOT EDIT THE LINE BELOW ***************/
if (typeof module !== "undefined") {
module.exports = config;
}

View File

@@ -0,0 +1,28 @@
let config = {
address: "0.0.0.0",
ipWhitelist: [],
timeFormat: 12,
modules: [
{
module: "calendar",
position: "bottom_bar",
config: {
maximumEntries: 1,
calendars: [
{
fetchInterval: 7 * 24 * 60 * 60 * 1000,
symbol: ["calendar-check", "google"],
url: "https://ics.calendarlabs.com/76/mm3137/US_Holidays.ics"
}
]
}
}
]
};
/*************** DO NOT EDIT THE LINE BELOW ***************/
if (typeof module !== "undefined") {
module.exports = config;
}

View File

@@ -0,0 +1,20 @@
let config = {
address: "0.0.0.0",
ipWhitelist: [],
timeFormat: 12,
modules: [
{
module: "clock",
position: "middle_center",
config: {
showSunTimes: "disableNextEvent"
}
}
]
};
/*************** DO NOT EDIT THE LINE BELOW ***************/
if (typeof module !== "undefined") {
module.exports = config;
}

View File

@@ -0,0 +1,20 @@
let config = {
address: "0.0.0.0",
ipWhitelist: [],
timeFormat: 12,
modules: [
{
module: "clock",
position: "middle_center",
config: {
showWeek: "short"
}
}
]
};
/*************** DO NOT EDIT THE LINE BELOW ***************/
if (typeof module !== "undefined") {
module.exports = config;
}

View File

@@ -0,0 +1,21 @@
let config = {
address: "0.0.0.0",
ipWhitelist: [],
language: "de",
timeFormat: 12,
modules: [
{
module: "clock",
position: "middle_center",
config: {
showWeek: true
}
}
]
};
/*************** DO NOT EDIT THE LINE BELOW ***************/
if (typeof module !== "undefined") {
module.exports = config;
}

View File

@@ -0,0 +1,21 @@
let config = {
address: "0.0.0.0",
ipWhitelist: [],
language: "de",
timeFormat: 12,
modules: [
{
module: "clock",
position: "middle_center",
config: {
showWeek: "short"
}
}
]
};
/*************** DO NOT EDIT THE LINE BELOW ***************/
if (typeof module !== "undefined") {
module.exports = config;
}

View File

@@ -0,0 +1,21 @@
let config = {
address: "0.0.0.0",
ipWhitelist: [],
language: "es",
timeFormat: 12,
modules: [
{
module: "clock",
position: "middle_center",
config: {
showWeek: "short"
}
}
]
};
/*************** DO NOT EDIT THE LINE BELOW ***************/
if (typeof module !== "undefined") {
module.exports = config;
}

Some files were not shown because too many files have changed in this diff Show More