Compare commits

...

4 Commits

Author SHA1 Message Date
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
145 changed files with 7173 additions and 4761 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

@@ -22,24 +22,28 @@ jobs:
- name: "Use Node.js"
uses: actions/setup-node@v4
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.14.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
- name: "Use Node.js ${{ matrix.node-version }}"
@@ -48,12 +52,18 @@ jobs:
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

@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.18.1, 20.x, 22.x, 23.x]
node-version: [22.14.0, 22.x, 24.x]
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -18,11 +18,13 @@ jobs:
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

@@ -21,11 +21,11 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
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

@@ -19,4 +19,4 @@ jobs:
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,106 @@ 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.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 +115,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 +124,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 +203,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
@@ -167,7 +267,7 @@ For more info, please read the following post: [A New Chapter for MagicMirror: T
### 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)
@@ -1421,7 +1521,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 +1771,8 @@ 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.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.14.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

@@ -52,12 +52,16 @@
"dkallen",
"drivelist",
"DTEND",
"DTSTAMP",
"DTSTART",
"Duffman",
"earlman",
"easyas",
"eddiehung",
"Edgardos",
"Ekristoffe",
"elec",
"eltociear",
"envcanada",
"envsub",
"envsubst",
@@ -81,10 +85,12 @@
"fulldate",
"fullday",
"fullscreen",
"geraki",
"Gevoelstemperatuur",
"GHSA",
"ghsas",
"grenagit",
"Heiko",
"Hirschberger",
"hourlyweather",
"Hwind",
@@ -103,6 +109,7 @@
"jsonlint",
"jupadin",
"kaennchenstruggle",
"Kalenderwoche",
"kenzal",
"Keyport",
"khassel",
@@ -117,6 +124,7 @@
"krekos",
"Kristjan",
"krukle",
"labwc",
"Landis",
"larryare",
"letsencrypt",
@@ -132,12 +140,15 @@
"martingron",
"marvai",
"mastermerge",
"matchtype",
"maxentries",
"Meteo",
"michaelteeuw",
"michmich",
"Midori",
"mirontoli",
"MISSINGLANG",
"mixasgr",
"MMPM",
"modernizr",
"modulename",
@@ -178,9 +189,11 @@
"rohitdharavath",
"Rosso",
"rrule",
"savvadam",
"sdetweil",
"sendheaders",
"serveronly",
"sexualized",
"skpanagiotis",
"SMHI",
"Snille",
@@ -195,6 +208,7 @@
"sunaction",
"suncalc",
"suntimes",
"symboltest",
"systeminformation",
"tada",
"taglist",
@@ -228,11 +242,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,16 @@ sup {
border-spacing: 0;
border-collapse: separate;
}
/**
* Container Definitions.
*/
.region .container {
display: flex;
flex-direction: column;
}
.region .container.hidden {
display: none;
}

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 jsdoc 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, jsdoc, stylistic},
extends: [importX.recommended, jest.configs["flat/recommended"], "js/recommended", jsdoc.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,25 @@ 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",
"modules/default/calendar/debug.js",
"js/logger.js",
"tests/**/*.js"
],
rules: {"no-console": "error"}
},
{
files: ["**/package.json"],
plugins: {packageJson},
extends: ["packageJson/recommended"]
},
{
files: ["**/*.mjs"],
languageOptions: {
@@ -99,23 +108,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 +128,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

@@ -20,7 +20,6 @@ module.exports = async () => {
},
{
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"]

View File

@@ -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.`);
}
}
}
}
@@ -177,7 +189,7 @@ function App () {
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);
@@ -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";
});
};
@@ -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}`);

View File

@@ -6,10 +6,11 @@ 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 vendor = require(`${__dirname}/vendor`);
/**
* Server
* @param {object} config The MM config
@@ -72,8 +73,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

@@ -19,15 +19,16 @@ module.exports = {
let installedNodeVersion = execSync("node -v", { encoding: "utf-8" }).replace("v", "").replace(/(?:\r\n|\r|\n)/g, "");
const staticData = await si.get({
system: "manufacturer, model, raspberry, virtual",
system: "manufacturer, model, 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}`;
let systemDataString = `System information:
### SYSTEM: manufacturer: ${staticData.system.manufacturer}; model: ${staticData.system.model}; virtual: ${staticData.system.virtual}
### OS: platform: ${staticData.osInfo.platform}; distro: ${staticData.osInfo.distro}; release: ${staticData.osInfo.release}; arch: ${staticData.osInfo.arch}; kernel: ${staticData.versions.kernel}
### VERSIONS: electron: ${process.versions.electron}; used node: ${staticData.versions.node}; installed node: ${installedNodeVersion}; npm: ${staticData.versions.npm}; pm2: ${staticData.versions.pm2}
### OTHER: timeZone: ${Intl.DateTimeFormat().resolvedOptions().timeZone}; ELECTRON_ENABLE_GPU: ${process.env.ELECTRON_ENABLE_GPU}`
.replace(/\t/g, "");
Log.info(systemDataString);
// Return is currently only for jest
@@ -69,7 +70,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

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

View File

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

View File

@@ -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.
@@ -215,18 +215,9 @@ 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 +249,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");
@@ -340,7 +333,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 +388,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 +408,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 +452,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 +462,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 +480,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 +488,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 +585,46 @@ 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 today = now.clone().startOf("day");
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 +633,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 +680,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) {
@@ -711,16 +707,16 @@ Module.register("calendar", {
*/
if (this.config.limitDays > 0) {
let newEvents = [];
let lastDate = today.clone().subtract(1, "days").format("YYYYMMDD");
let lastDate = today.clone().subtract(1, "days");
let days = 0;
for (const ev of events) {
let eventDate = moment(ev.startDate, "x").format("YYYYMMDD");
let eventDate = this.timestampToMoment(ev.startDate);
/*
* if date of event is later than lastdate
* check if we already are showing max unique days
*/
if (eventDate > lastDate) {
if (eventDate.isAfter(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--;
@@ -907,7 +903,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

@@ -81,10 +81,13 @@ const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEn
* Schedule the timer for the next update.
*/
const scheduleTimer = function () {
clearTimeout(reloadTimer);
reloadTimer = setTimeout(function () {
fetchCalendar();
}, reloadInterval);
if (process.env.JEST_WORKER_ID === undefined) {
// only set timer when not running in jest
clearTimeout(reloadTimer);
reloadTimer = setTimeout(function () {
fetchCalendar();
}, reloadInterval);
}
};
/* public methods */

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 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 filter = {
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) {
filter.until = until;
} else {
filter.excluded = true;
}
break;
}
}
Log.debug(`adjustHours=${adjustHours}`);
return adjustHours;
return filter;
},
/**
* 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

@@ -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
@@ -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,7 +78,12 @@
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%;
}
@@ -91,3 +96,15 @@
.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

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

@@ -128,10 +128,13 @@ const NewsfeedFetcher = function (url, reloadInterval, encoding, logFeedWarnings
* Schedule the timer for the next update.
*/
const scheduleTimer = function () {
clearTimeout(reloadTimer);
reloadTimer = setTimeout(function () {
fetchNews();
}, reloadIntervalMS);
if (process.env.JEST_WORKER_ID === undefined) {
// only set timer when not running in jest
clearTimeout(reloadTimer);
reloadTimer = setTimeout(function () {
fetchNews();
}, reloadIntervalMS);
}
};
/* public methods */

View File

@@ -1,3 +1,5 @@
const fs = require("node:fs");
const path = require("node:path");
const NodeHelper = require("node_helper");
const defaultModules = require("../defaultmodules");
const GitHelper = require("./git_helper");
@@ -14,8 +16,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(`${__dirname}/../../`);
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

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

@@ -21,15 +21,25 @@
{% 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 %}
<td class="align-right bright precipitation-amount">
{{ hour.precipitationAmount | unit("precip", hour.precipitationUnits) }}
</td>
{% 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', '%') }}
{{ hour.precipitationProbability | unit('precip', '%') }}
</td>
{% endif %}
{% endif %}
</tr>
{% set currentStep = currentStep + 1 %}

View File

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

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

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

@@ -31,6 +31,7 @@
.weather .precipitation-amount,
.weather .precipitation-prob,
.weather .humidity-hourly,
.weather .uv-index {
padding-left: 20px;
padding-right: 0;

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,

View File

@@ -128,14 +128,14 @@ const WeatherUtils = {
} 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;
+ 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;
}
return ((feelsLike - 32) * 5) / 9;

6696
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.32.0",
"description": "The open source modular smart mirror platform.",
"keywords": [
"magic mirror",
@@ -22,35 +22,34 @@
"contributors": [
"https://github.com/MagicMirrorOrg/MagicMirror/graphs/contributors"
],
"type": "commonjs",
"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",
"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\"",
"postinstall": "git clean -df fonts vendor",
"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",
@@ -62,51 +61,58 @@
"*.css": "stylelint --fix"
},
"dependencies": {
"@fontsource/roboto": "^5.2.6",
"@fontsource/roboto-condensed": "^5.2.6",
"@fortawesome/fontawesome-free": "^6.7.2",
"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.30.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",
"module-alias": "^2.2.3",
"moment": "^2.30.1",
"moment-timezone": "^0.6.0",
"node-ical": "^0.20.1",
"pm2": "^5.4.3",
"nunjucks": "^3.2.4",
"pm2": "^6.0.8",
"socket.io": "^4.8.1",
"suncalc": "^1.9.0",
"systeminformation": "^5.24.3",
"undici": "^7.2.0"
"systeminformation": "^5.27.7",
"undici": "^7.11.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.1.0",
"cspell": "^9.1.2",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-jest": "^29.0.1",
"eslint-plugin-jsdoc": "^51.2.3",
"eslint-plugin-package-json": "^0.42.0",
"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.0.3",
"jsdom": "^26.1.0",
"lint-staged": "^16.1.2",
"markdownlint-cli2": "^0.18.1",
"playwright": "^1.53.1",
"prettier": "^3.6.2",
"sinon": "^21.0.0",
"stylelint": "^16.21.0",
"stylelint-config-standard": "^38.0.0",
"stylelint-prettier": "^5.0.3"
},
"optionalDependencies": {
"electron": "^32.2.7"
"electron": "^36.6.0"
},
"engines": {
"node": ">=20.18.1 <21 || >=22"
"node": ">=22.14.0"
},
"_moduleAliases": {
"node_helper": "js/node_helper.js",

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

View File

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

View File

@@ -23,18 +23,18 @@ describe("AnimateCSS integration Test", () => {
let styles = window.getComputedStyle(elem);
if (animationIn && animationIn !== "") {
expect(styles._values["animation-name"]).toBe(animationIn);
expect(styles._values.get("animation-name")).toBe(animationIn);
} else {
expect(styles._values["animation-name"]).toBeUndefined();
expect(styles._values.get("animation-name")).toBeUndefined();
}
if (animationOut && animationOut !== "") {
elem = await helpers.waitForElement(`.compliments.animate__animated.animate__${animationOut}`);
expect(elem).not.toBeNull();
styles = window.getComputedStyle(elem);
expect(styles._values["animation-name"]).toBe(animationOut);
expect(styles._values.get("animation-name")).toBe(animationOut);
} else {
expect(styles._values["animation-name"]).toBeUndefined();
expect(styles._values.get("animation-name")).toBeUndefined();
}
return true;
};

View File

@@ -3,7 +3,7 @@ const helpers = require("./helpers/global-setup");
describe("All font files from roboto.css should be downloadable", () => {
const fontFiles = [];
// Statements below filters out all 'url' lines in the CSS file
const fileContent = require("node:fs").readFileSync(`${__dirname}/../../fonts/roboto.css`, "utf8");
const fileContent = require("node:fs").readFileSync(`${__dirname}/../../css/roboto.css`, "utf8");
const regex = /\burl\(['"]([^'"]+)['"]\)/g;
let match = regex.exec(fileContent);
while (match !== null) {

View File

@@ -27,13 +27,21 @@ exports.startApplication = async (configFilename, exec) => {
process.env.MM_CONFIG_FILE = configFilename;
}
process.env.mmTestMode = "true";
process.setMaxListeners(0);
if (exec) exec;
global.app = require("../../../js/app");
return global.app.start();
};
exports.stopApplication = async () => {
exports.stopApplication = async (waitTime = 1000) => {
if (global.window) {
// no closing causes jest errors and memory leaks
global.window.close();
delete global.window;
// give above closing some extra time to finish
await new Promise((resolve) => setTimeout(resolve, waitTime));
}
if (!global.app) {
return Promise.resolve();
}

View File

@@ -1,29 +0,0 @@
/**
* Suppresses errors concerning web server already shut down.
* @param {string} err The error message.
*/
const mockError = (err) => {
if (
err.includes("ECONNREFUSED")
|| err.includes("ECONNRESET")
|| err.includes("socket hang up")
|| err.includes("exports is not defined")
|| err.includes("module is not defined")
|| err.includes("write EPIPE")
|| err.includes("AggregateError")
|| err.includes("ERR_SOCKET_CONNECTION_TIMEOUT")
) {
jest.fn();
} else {
console.dir(err);
}
};
global.console = {
log: jest.fn(),
dir: console.dir,
error: mockError,
warn: console.warn,
info: jest.fn(),
debug: console.debug
};

View File

@@ -119,7 +119,6 @@ describe("Calendar module", () => {
});
});
process.setMaxListeners(0);
for (let i = -12; i < 12; i++) {
describe("Recurring event per timezone", () => {
beforeAll(async () => {

View File

@@ -0,0 +1,31 @@
const helpers = require("../helpers/global-setup");
describe("Clock set to german language module", () => {
afterAll(async () => {
await helpers.stopApplication();
});
describe("with showWeek config enabled", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/de/clock_showWeek.js");
await helpers.getDocument();
});
it("shows week with correct format", async () => {
const weekRegex = /^[0-9]{1,2}. Kalenderwoche$/;
await expect(helpers.testMatch(".clock .week", weekRegex)).resolves.toBe(true);
});
});
describe("with showWeek short config enabled", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/de/clock_showWeek_short.js");
await helpers.getDocument();
});
it("shows week with correct format", async () => {
const weekRegex = /^[0-9]{1,2}KW$/;
await expect(helpers.testMatch(".clock .week", weekRegex)).resolves.toBe(true);
});
});
});

View File

@@ -62,4 +62,16 @@ describe("Clock set to spanish language module", () => {
await expect(helpers.testMatch(".clock .week", weekRegex)).resolves.toBe(true);
});
});
describe("with showWeek short config enabled", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/es/clock_showWeek_short.js");
await helpers.getDocument();
});
it("shows week with correct format", async () => {
const weekRegex = /^S[0-9]{1,2}$/;
await expect(helpers.testMatch(".clock .week", weekRegex)).resolves.toBe(true);
});
});
});

View File

@@ -38,6 +38,13 @@ describe("Clock module", () => {
const timeRegex = /^(?:1[0-2]|[1-9]):[0-5]\d[0-5]\d[ap]m$/;
await expect(helpers.testMatch(".clock .time", timeRegex)).resolves.toBe(true);
});
it("check for discreet elements of clock", async () => {
let elemClock = await helpers.waitForElement(".clock-hour-digital");
await expect(elemClock).not.toBeNull();
elemClock = await helpers.waitForElement(".clock-minute-digital");
await expect(elemClock).not.toBeNull();
});
});
describe("with showPeriodUpper config enabled", () => {
@@ -85,6 +92,9 @@ describe("Clock module", () => {
it("should show the sun times", async () => {
const elem = await helpers.waitForElement(".clock .digital .sun");
expect(elem).not.toBeNull();
const elem2 = await helpers.waitForElement(".clock .digital .sun .fas.fa-sun");
expect(elem2).not.toBeNull();
});
it("should show the moon times", async () => {
@@ -93,6 +103,21 @@ describe("Clock module", () => {
});
});
describe("with showSunNextEvent disabled", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/clock_showSunNoEvent.js");
await helpers.getDocument();
});
it("should show the sun times", async () => {
const elem = await helpers.waitForElement(".clock .digital .sun");
expect(elem).not.toBeNull();
const elem2 = document.querySelector(".clock .digital .sun .fas.fa-sun");
expect(elem2).toBeNull();
});
});
describe("with showWeek config enabled", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/clock_showWeek.js");
@@ -113,6 +138,26 @@ describe("Clock module", () => {
});
});
describe("with showWeek short config enabled", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/clock_showWeek_short.js");
await helpers.getDocument();
});
it("should show the week in the correct format", async () => {
const weekRegex = /^W[0-9]{1,2}$/;
await expect(helpers.testMatch(".clock .week", weekRegex)).resolves.toBe(true);
});
it("should show the week with the correct number of week of year", async () => {
const currentWeekNumber = moment().week();
const weekToShow = `W${currentWeekNumber}`;
const elem = await helpers.waitForElement(".clock .week");
expect(elem).not.toBeNull();
expect(elem.textContent).toBe(weekToShow);
});
});
describe("with analog clock face enabled", () => {
beforeAll(async () => {
await helpers.startApplication("tests/configs/modules/clock/clock_analog.js");
@@ -120,7 +165,7 @@ describe("Clock module", () => {
});
it("should show the analog clock face", async () => {
const elem = helpers.waitForElement(".clock-circle");
const elem = await helpers.waitForElement(".clock-circle");
expect(elem).not.toBeNull();
});
});
@@ -132,9 +177,9 @@ describe("Clock module", () => {
});
it("should show the analog clock face and the date", async () => {
const elemClock = helpers.waitForElement(".clock-circle");
const elemClock = await helpers.waitForElement(".clock-circle");
await expect(elemClock).not.toBeNull();
const elemDate = helpers.waitForElement(".clock .date");
const elemDate = await helpers.waitForElement(".clock .date");
await expect(elemDate).not.toBeNull();
});
});

View File

@@ -84,9 +84,7 @@ describe("Newsfeed module", () => {
describe("Newsfeed module located in config directory", () => {
beforeAll(() => {
const baseDir = `${__dirname}/../../..`;
if (!fs.existsSync(`${baseDir}/config/newsfeed`)) {
fs.cpSync(`${baseDir}/modules/default/newsfeed`, `${baseDir}/config/newsfeed`, { recursive: true });
}
fs.cpSync(`${baseDir}/modules/default/newsfeed`, `${baseDir}/config/newsfeed`, { recursive: true });
process.env.MM_MODULES_DIR = "config";
});

View File

@@ -6,7 +6,7 @@ const express = require("express");
const sinon = require("sinon");
const translations = require("../../translations/translations");
describe("Translations", () => {
describe("translations", () => {
let server;
beforeAll(() => {
@@ -26,8 +26,9 @@ describe("Translations", () => {
});
it("should have a translation file in the specified path", () => {
for (let language in translations) {
for (const language in translations) {
const file = fs.statSync(translations[language]);
expect(file.isFile()).toBe(true);
}
});
@@ -36,90 +37,91 @@ describe("Translations", () => {
let dom;
beforeEach(() => {
dom = new JSDOM(
`<script>var Translator = {}; var Log = {log: () => {}}; var config = {language: 'de'};</script>\
<script src="file://${path.join(__dirname, "..", "..", "js", "class.js")}"></script>\
<script src="file://${path.join(__dirname, "..", "..", "js", "module.js")}"></script>`,
{ runScripts: "dangerously", resources: "usable" }
);
// Create a new JSDOM instance for each test
dom = new JSDOM("", { runScripts: "dangerously", resources: "usable" });
// Mock the necessary global objects
dom.window.Log = { log: jest.fn(), error: jest.fn() };
dom.window.Translator = {};
dom.window.config = { language: "de" };
// Load class.js and module.js content directly
const classJs = fs.readFileSync(path.join(__dirname, "..", "..", "js", "class.js"), "utf-8");
const moduleJs = fs.readFileSync(path.join(__dirname, "..", "..", "js", "module.js"), "utf-8");
// Execute the scripts in the JSDOM context
dom.window.eval(classJs);
dom.window.eval(moduleJs);
});
it("should load translation file", () => {
return new Promise((done) => {
dom.window.onload = async () => {
const { Translator, Module, config } = dom.window;
config.language = "en";
Translator.load = sinon.stub().callsFake((_m, _f, _fb) => null);
Module.register("name", { getTranslations: () => translations });
const MMM = Module.create("name");
await MMM.loadTranslations();
expect(Translator.load.args).toHaveLength(1);
expect(Translator.load.calledWith(MMM, "translations/en.json", false)).toBe(true);
done();
};
it("should load translation file", async () => {
await new Promise((resolve) => {
dom.window.onload = resolve;
});
const { Translator, Module, config } = dom.window;
config.language = "en";
Translator.load = sinon.stub().callsFake((_m, _f, _fb) => null);
Module.register("name", { getTranslations: () => translations });
const MMM = Module.create("name");
await MMM.loadTranslations();
expect(Translator.load.args).toHaveLength(1);
expect(Translator.load.calledWith(MMM, "translations/en.json", false)).toBe(true);
});
it("should load translation + fallback file", () => {
return new Promise((done) => {
dom.window.onload = async () => {
const { Translator, Module } = dom.window;
Translator.load = sinon.stub().callsFake((_m, _f, _fb) => null);
Module.register("name", { getTranslations: () => translations });
const MMM = Module.create("name");
await MMM.loadTranslations();
expect(Translator.load.args).toHaveLength(2);
expect(Translator.load.calledWith(MMM, "translations/de.json", false)).toBe(true);
expect(Translator.load.calledWith(MMM, "translations/en.json", true)).toBe(true);
done();
};
it("should load translation + fallback file", async () => {
await new Promise((resolve) => {
dom.window.onload = resolve;
});
const { Translator, Module } = dom.window;
Translator.load = sinon.stub().callsFake((_m, _f, _fb) => null);
Module.register("name", { getTranslations: () => translations });
const MMM = Module.create("name");
await MMM.loadTranslations();
expect(Translator.load.args).toHaveLength(2);
expect(Translator.load.calledWith(MMM, "translations/de.json", false)).toBe(true);
expect(Translator.load.calledWith(MMM, "translations/en.json", true)).toBe(true);
});
it("should load translation fallback file", () => {
return new Promise((done) => {
dom.window.onload = async () => {
const { Translator, Module, config } = dom.window;
config.language = "--";
Translator.load = sinon.stub().callsFake((_m, _f, _fb) => null);
Module.register("name", { getTranslations: () => translations });
const MMM = Module.create("name");
await MMM.loadTranslations();
expect(Translator.load.args).toHaveLength(1);
expect(Translator.load.calledWith(MMM, "translations/en.json", true)).toBe(true);
done();
};
it("should load translation fallback file", async () => {
await new Promise((resolve) => {
dom.window.onload = resolve;
});
const { Translator, Module, config } = dom.window;
config.language = "--";
Translator.load = sinon.stub().callsFake((_m, _f, _fb) => null);
Module.register("name", { getTranslations: () => translations });
const MMM = Module.create("name");
await MMM.loadTranslations();
expect(Translator.load.args).toHaveLength(1);
expect(Translator.load.calledWith(MMM, "translations/en.json", true)).toBe(true);
});
it("should load no file", () => {
return new Promise((done) => {
dom.window.onload = async () => {
const { Translator, Module } = dom.window;
Translator.load = sinon.stub();
Module.register("name", {});
const MMM = Module.create("name");
await MMM.loadTranslations();
expect(Translator.load.callCount).toBe(0);
done();
};
it("should load no file", async () => {
await new Promise((resolve) => {
dom.window.onload = resolve;
});
const { Translator, Module } = dom.window;
Translator.load = sinon.stub();
Module.register("name", {});
const MMM = Module.create("name");
await MMM.loadTranslations();
expect(Translator.load.callCount).toBe(0);
});
});
@@ -130,101 +132,103 @@ describe("Translations", () => {
}
};
describe("Parsing language files through the Translator class", () => {
for (let language in translations) {
it(`should parse ${language}`, () => {
return new Promise((done) => {
const dom = new JSDOM(
`<script>var translations = ${JSON.stringify(translations)}; var Log = {log: () => {}};</script>\
<script src="file://${path.join(__dirname, "..", "..", "js", "translator.js")}">`,
{ runScripts: "dangerously", resources: "usable" }
);
dom.window.onload = async () => {
const { Translator } = dom.window;
const translatorJs = fs.readFileSync(path.join(__dirname, "..", "..", "js", "translator.js"), "utf-8");
await Translator.load(mmm, translations[language], false);
expect(typeof Translator.translations[mmm.name]).toBe("object");
expect(Object.keys(Translator.translations[mmm.name]).length).toBeGreaterThanOrEqual(1);
done();
};
describe("parsing language files through the Translator class", () => {
for (const language in translations) {
it(`should parse ${language}`, async () => {
const dom = new JSDOM("", { runScripts: "dangerously", resources: "usable" });
dom.window.Log = { log: jest.fn() };
dom.window.translations = translations;
dom.window.eval(translatorJs);
await new Promise((resolve) => {
dom.window.onload = resolve;
});
const { Translator } = dom.window;
await Translator.load(mmm, translations[language], false);
expect(typeof Translator.translations[mmm.name]).toBe("object");
expect(Object.keys(Translator.translations[mmm.name]).length).toBeGreaterThanOrEqual(1);
});
}
});
describe("Same keys", () => {
describe("same keys", () => {
let base;
let missing = [];
beforeAll(() => {
return new Promise((done) => {
const dom = new JSDOM(
`<script>var translations = ${JSON.stringify(translations)}; var Log = {log: () => {}};</script>\
<script src="file://${path.join(__dirname, "..", "..", "js", "translator.js")}">`,
{ runScripts: "dangerously", resources: "usable" }
);
// Some expressions are not easy to translate automatically. For the sake of a working test, we filter them out.
const COMMON_EXCEPTIONS = ["WEEK_SHORT"];
// Some languages don't have certain words, so we need to filter those language specific exceptions.
const LANGUAGE_EXCEPTIONS = {
ca: ["DAYBEFOREYESTERDAY"],
cv: ["DAYBEFOREYESTERDAY"],
cy: ["DAYBEFOREYESTERDAY"],
en: ["DAYAFTERTOMORROW", "DAYBEFOREYESTERDAY"],
fy: ["DAYBEFOREYESTERDAY"],
gl: ["DAYBEFOREYESTERDAY"],
hu: ["DAYBEFOREYESTERDAY"],
id: ["DAYBEFOREYESTERDAY"],
it: ["DAYBEFOREYESTERDAY"],
"pt-br": ["DAYAFTERTOMORROW"],
tr: ["DAYBEFOREYESTERDAY"]
};
// Function to initialize JSDOM and load translations
const initializeTranslationDOM = (language) => {
const dom = new JSDOM("", { runScripts: "dangerously", resources: "usable" });
dom.window.Log = { log: jest.fn() };
dom.window.translations = translations;
dom.window.eval(translatorJs);
return new Promise((resolve) => {
dom.window.onload = async () => {
const { Translator } = dom.window;
await Translator.load(mmm, translations.de, false);
base = Object.keys(Translator.translations[mmm.name]).sort();
done();
await Translator.load(mmm, translations[language], false);
resolve(Translator.translations[mmm.name]);
};
});
};
beforeAll(async () => {
// Using German as the base rather than English, since
// some words do not have a direct translation in English.
const germanTranslations = await initializeTranslationDOM("de");
base = Object.keys(germanTranslations).sort();
});
afterAll(() => {
console.log(missing);
});
// Using German as the base rather than English, since
// at least one translated word doesn't exist in English.
for (let language in translations) {
if (language === "de") {
continue;
}
for (const language in translations) {
if (language === "de") continue;
describe(`Translation keys of ${language}`, () => {
let keys;
beforeAll(() => {
return new Promise((done) => {
const dom = new JSDOM(
`<script>var translations = ${JSON.stringify(translations)}; var Log = {log: () => {}};</script>\
<script src="file://${path.join(__dirname, "..", "..", "js", "translator.js")}">`,
{ runScripts: "dangerously", resources: "usable" }
);
dom.window.onload = async () => {
const { Translator } = dom.window;
await Translator.load(mmm, translations[language], false);
keys = Object.keys(Translator.translations[mmm.name]).sort();
done();
};
});
beforeAll(async () => {
const languageTranslations = await initializeTranslationDOM(language);
keys = Object.keys(languageTranslations).sort();
});
it(`${language} keys should be in base`, () => {
it(`${language} should not contain keys that are not in base language`, () => {
keys.forEach((key) => {
expect(base.indexOf(key)).toBeGreaterThanOrEqual(0);
expect(base).toContain(key, `Translation key '${key}' in language '${language}' is not present in base language`);
});
});
it(`${language} should contain all base keys`, () => {
// TODO: when all translations are fixed, use
// expect(keys).toEqual(base);
// instead of the try-catch-block
it(`${language} should contain all base keys (excluding defined exceptions)`, () => {
let filteredBase = base.filter((key) => !COMMON_EXCEPTIONS.includes(key));
let filteredKeys = keys.filter((key) => !COMMON_EXCEPTIONS.includes(key));
try {
expect(keys).toEqual(base);
} catch (e) {
if (e.message.match(/expect.*toEqual/)) {
const diff = base.filter((key) => !keys.includes(key));
missing.push(`Missing Translations for language ${language}: ${diff}`);
} else {
throw e;
}
if (LANGUAGE_EXCEPTIONS[language]) {
const exceptions = LANGUAGE_EXCEPTIONS[language];
filteredBase = filteredBase.filter((key) => !exceptions.includes(key));
filteredKeys = filteredKeys.filter((key) => !exceptions.includes(key));
}
filteredBase.forEach((baseKey) => {
expect(filteredKeys).toContain(baseKey, `Translation key '${baseKey}' is missing in language '${language}'`);
});
});
});
}

View File

@@ -9,22 +9,14 @@ describe("Vendors", () => {
});
describe("Get list vendors", () => {
const vendors = require(`${__dirname}/../../vendor/vendor.js`);
const vendors = require(`${__dirname}/../../js/vendor.js`);
Object.keys(vendors).forEach((vendor) => {
it(`should return 200 HTTP code for vendor "${vendor}"`, async () => {
const urlVendor = `http://localhost:8080/vendor/${vendors[vendor]}`;
const urlVendor = `http://localhost:8080/${vendors[vendor]}`;
const res = await fetch(urlVendor);
expect(res.status).toBe(200);
});
});
Object.keys(vendors).forEach((vendor) => {
it(`should return 404 HTTP code for vendor https://localhost/"${vendor}"`, async () => {
const urlVendor = `http://localhost:8080/${vendors[vendor]}`;
const res = await fetch(urlVendor);
expect(res.status).toBe(404);
});
});
});
});

View File

@@ -19,7 +19,7 @@ describe("Electron app environment", () => {
describe("Development console tests", () => {
beforeEach(async () => {
await helpers.startApplication("tests/configs/modules/display.js", null, ["js/electron.js", "dev"]);
await helpers.startApplication("tests/configs/modules/display.js", null, ["dev"]);
});
afterEach(async () => {

View File

@@ -3,7 +3,7 @@
// https://www.anycodings.com/1questions/958135/can-i-set-the-date-for-playwright-browser
const { _electron: electron } = require("playwright");
exports.startApplication = async (configFilename, systemDate = null, electronParams = ["js/electron.js"], timezone = "GMT") => {
exports.startApplication = async (configFilename, systemDate = null, electronParams = [], timezone = "GMT") => {
global.electronApp = null;
global.page = null;
process.env.MM_CONFIG_FILE = configFilename;
@@ -13,6 +13,13 @@ exports.startApplication = async (configFilename, systemDate = null, electronPar
}
process.env.mmTestMode = "true";
// check environment for DISPLAY or WAYLAND_DISPLAY
if (process.env.WAYLAND_DISPLAY) {
electronParams.unshift("js/electron.js", "--enable-features=UseOzonePlatform", "--ozone-platform=wayland");
} else {
electronParams.unshift("js/electron.js");
}
global.electronApp = await electron.launch({ args: electronParams });
await global.electronApp.firstWindow();
@@ -42,10 +49,10 @@ exports.stopApplication = async () => {
process.env.MOCK_DATE = undefined;
};
exports.getElement = async (selector) => {
exports.getElement = async (selector, state = "visible") => {
expect(global.page).not.toBeNull();
let elem = global.page.locator(selector);
await elem.waitFor();
const elem = global.page.locator(selector);
await elem.waitFor({ state: state });
expect(elem).not.toBeNull();
return elem;
};

View File

@@ -13,13 +13,26 @@ describe("Calendar module", () => {
return true;
};
const doTestCount = async () => {
const doTestCount = async (locator = ".calendar .event") => {
expect(global.page).not.toBeNull();
const loc = await global.page.locator(locator);
const elem = loc.first();
await elem.waitFor();
expect(elem).not.toBeNull();
return await loc.count();
};
/**
* Use this for debugging broken tests, it will console log the text of the calendar module
* @returns {Promise<void>}
*/
const logAllText = async () => {
expect(global.page).not.toBeNull();
const loc = await global.page.locator(".calendar .event");
const elem = loc.first();
await elem.waitFor();
expect(elem).not.toBeNull();
return await loc.count();
console.log(await loc.allInnerTexts());
};
const first = 0;
@@ -32,8 +45,8 @@ describe("Calendar module", () => {
// uses playwright nth locator syntax
const doTestTableContent = async (table_row, table_column, content, row = first) => {
const elem = await global.page.locator(table_row);
const date = await global.page.locator(table_column).locator(`nth=${row}`);
await expect(date.textContent()).resolves.toContain(content);
const column = await elem.locator(table_column).locator(`nth=${row}`);
await expect(column.textContent()).resolves.toContain(content);
return true;
};
@@ -81,7 +94,11 @@ describe("Calendar module", () => {
*/
describe("rrule", () => {
it("Issue #3393 recurrence dates past rrule until date", async () => {
await helpers.startApplication("tests/configs/modules/calendar/rrule_until.js", "07 Mar 2024 10:38:00 GMT-07:00", ["js/electron.js"], "America/Los_Angeles");
await helpers.startApplication("tests/configs/modules/calendar/rrule_until.js", "07 Mar 2024 10:38:00 GMT-07:00", [], "America/Los_Angeles");
await expect(doTestCount()).resolves.toBe(1);
});
it("Issue #3781 recurrence rrule until with date only uses timezone offset incorrectly", async () => {
await helpers.startApplication("tests/configs/modules/calendar/fullday_until.js", "01 May 2025", [], "America/Los_Angeles");
await expect(doTestCount()).resolves.toBe(1);
});
});
@@ -98,20 +115,20 @@ describe("Calendar module", () => {
*/
describe("Exdate: LA crossover DST before midnight GMT", () => {
it("LA crossover DST before midnight GMT should have 2 events", async () => {
await helpers.startApplication("tests/configs/modules/calendar/exdate_la_before_midnight.js", "19 Oct 2023 12:30:00 GMT-07:00", ["js/electron.js"], "America/Los_Angeles");
await helpers.startApplication("tests/configs/modules/calendar/exdate_la_before_midnight.js", "19 Oct 2023 12:30:00 GMT-07:00", [], "America/Los_Angeles");
await expect(doTestCount()).resolves.toBe(2);
});
});
describe("Exdate: LA crossover DST at midnight GMT local STD", () => {
it("LA crossover DST before midnight GMT should have 2 events", async () => {
await helpers.startApplication("tests/configs/modules/calendar/exdate_la_at_midnight_std.js", "19 Oct 2023 12:30:00 GMT-07:00", ["js/electron.js"], "America/Los_Angeles");
await helpers.startApplication("tests/configs/modules/calendar/exdate_la_at_midnight_std.js", "19 Oct 2023 12:30:00 GMT-07:00", [], "America/Los_Angeles");
await expect(doTestCount()).resolves.toBe(2);
});
});
describe("Exdate: LA crossover DST at midnight GMT local DST", () => {
it("LA crossover DST before midnight GMT should have 2 events", async () => {
await helpers.startApplication("tests/configs/modules/calendar/exdate_la_at_midnight_dst.js", "19 Oct 2023 12:30:00 GMT-07:00", ["js/electron.js"], "America/Los_Angeles");
await helpers.startApplication("tests/configs/modules/calendar/exdate_la_at_midnight_dst.js", "19 Oct 2023 12:30:00 GMT-07:00", [], "America/Los_Angeles");
await expect(doTestCount()).resolves.toBe(2);
});
});
@@ -128,19 +145,19 @@ describe("Calendar module", () => {
*/
describe("Exdate: SYD crossover DST before midnight GMT", () => {
it("LA crossover DST before midnight GMT should have 2 events", async () => {
await helpers.startApplication("tests/configs/modules/calendar/exdate_syd_before_midnight.js", "14 Sep 2023 12:30:00 GMT+10:00", ["js/electron.js"], "Australia/Sydney");
await helpers.startApplication("tests/configs/modules/calendar/exdate_syd_before_midnight.js", "14 Sep 2023 12:30:00 GMT+10:00", [], "Australia/Sydney");
await expect(doTestCount()).resolves.toBe(2);
});
});
describe("Exdate: SYD crossover DST at midnight GMT local STD", () => {
it("LA crossover DST before midnight GMT should have 2 events", async () => {
await helpers.startApplication("tests/configs/modules/calendar/exdate_syd_at_midnight_std.js", "14 Sep 2023 12:30:00 GMT+10:00", ["js/electron.js"], "Australia/Sydney");
await helpers.startApplication("tests/configs/modules/calendar/exdate_syd_at_midnight_std.js", "14 Sep 2023 12:30:00 GMT+10:00", [], "Australia/Sydney");
await expect(doTestCount()).resolves.toBe(2);
});
});
describe("Exdate: SYD crossover DST at midnight GMT local DST", () => {
it("SYD crossover DST at midnight GMT local DST should have 2 events", async () => {
await helpers.startApplication("tests/configs/modules/calendar/exdate_syd_at_midnight_dst.js", "14 Sep 2023 12:30:00 GMT+10:00", ["js/electron.js"], "Australia/Sydney");
await helpers.startApplication("tests/configs/modules/calendar/exdate_syd_at_midnight_dst.js", "14 Sep 2023 12:30:00 GMT+10:00", [], "Australia/Sydney");
await expect(doTestCount()).resolves.toBe(2);
});
});
@@ -149,71 +166,67 @@ describe("Calendar module", () => {
* RRULE TESTS:
* Add any tests that check rrule functionality here.
*/
describe("sliceMultiDayEvents", () => {
it("Issue #3452 split multiday in Europe", async () => {
await helpers.startApplication("tests/configs/modules/calendar/sliceMultiDayEvents.js", "01 Sept 2024 10:38:00 GMT+02:00", ["js/electron.js"], "Europe/Berlin");
expect(global.page).not.toBeNull();
const loc = await global.page.locator(".calendar .event");
const elem = loc.first();
await elem.waitFor();
expect(elem).not.toBeNull();
const cnt = await loc.count();
expect(cnt).toBe(6);
});
});
describe("sliceMultiDayEvents direct count", () => {
it("Issue #3452 split multiday in Europe", async () => {
await helpers.startApplication("tests/configs/modules/calendar/sliceMultiDayEvents.js", "01 Sept 2024 10:38:00 GMT+02:00", ["js/electron.js"], "Europe/Berlin");
await helpers.startApplication("tests/configs/modules/calendar/sliceMultiDayEvents.js", "01 Sept 2024 10:38:00 GMT+02:00", [], "Europe/Berlin");
await expect(doTestCount()).resolves.toBe(6);
});
});
describe("germany timezone", () => {
it("Issue #unknown fullday timezone East of UTC edge", async () => {
await helpers.startApplication("tests/configs/modules/calendar/germany_at_end_of_day_repeating.js", "01 Oct 2024 10:38:00 GMT+02:00", ["js/electron.js"], "Europe/Berlin");
await helpers.startApplication("tests/configs/modules/calendar/germany_at_end_of_day_repeating.js", "01 Oct 2024 10:38:00 GMT+02:00", [], "Europe/Berlin");
await expect(doTestTableContent(".calendar .event", ".time", "Oct 22nd, 23:00", first)).resolves.toBe(true);
});
});
describe("germany all day repeating moved (recurrence and exdate)", () => {
it("Issue #unknown fullday timezone East of UTC event moved", async () => {
await helpers.startApplication("tests/configs/modules/calendar/3_move_first_allday_repeating_event.js", "01 Oct 2024 10:38:00 GMT+02:00", ["js/electron.js"], "Europe/Berlin");
await helpers.startApplication("tests/configs/modules/calendar/3_move_first_allday_repeating_event.js", "01 Oct 2024 10:38:00 GMT+02:00", [], "Europe/Berlin");
await expect(doTestTableContent(".calendar .event", ".time", "12th.Oct")).resolves.toBe(true);
});
});
describe("chicago late in timezone", () => {
it("Issue #unknown rrule US close to timezone edge", async () => {
await helpers.startApplication("tests/configs/modules/calendar/chicago_late_in_timezone.js", "01 Sept 2024 10:38:00 GMT-5:00", ["js/electron.js"], "America/Chicago");
await helpers.startApplication("tests/configs/modules/calendar/chicago_late_in_timezone.js", "01 Sept 2024 10:38:00 GMT-5:00", [], "America/Chicago");
await expect(doTestTableContent(".calendar .event", ".time", "10th.Sep, 20:15")).resolves.toBe(true);
});
});
describe("berlin late in day event moved, viewed from berlin", () => {
it("Issue #unknown rrule ETC+2 close to timezone edge", async () => {
await helpers.startApplication("tests/configs/modules/calendar/end_of_day_berlin_moved.js", "08 Oct 2024 12:30:00 GMT+02:00", ["js/electron.js"], "Europe/Berlin");
await expect(doTestTableContent(".calendar .event", ".time", "24th.Oct, 23:00-00:00", last)).resolves.toBe(true);
await helpers.startApplication("tests/configs/modules/calendar/end_of_day_berlin_moved.js", "08 Oct 2024 12:30:00 GMT+02:00", [], "Europe/Berlin");
await expect(doTestCount()).resolves.toBe(3);
await expect(doTestTableContent(".calendar .event", ".time", "22nd.Oct, 23:00-00:00", first)).resolves.toBe(true);
await expect(doTestTableContent(".calendar .event", ".time", "23rd.Oct, 23:00-00:00", second)).resolves.toBe(true);
await expect(doTestTableContent(".calendar .event", ".time", "24th.Oct, 23:00-00:00", third)).resolves.toBe(true);
});
});
describe("berlin late in day event moved, viewed from sydney", () => {
it("Issue #unknown rrule ETC+2 close to timezone edge", async () => {
await helpers.startApplication("tests/configs/modules/calendar/end_of_day_berlin_moved.js", "08 Oct 2024 12:30:00 GMT+02:00", ["js/electron.js"], "Australia/Sydney");
await expect(doTestTableContent(".calendar .event", ".time", "25th.Oct, 01:00-02:00", last)).resolves.toBe(true);
await helpers.startApplication("tests/configs/modules/calendar/end_of_day_berlin_moved.js", "08 Oct 2024 12:30:00 GMT+02:00", [], "Australia/Sydney");
await expect(doTestCount()).resolves.toBe(3);
await expect(doTestTableContent(".calendar .event", ".time", "23rd.Oct, 08:00-09:00", first)).resolves.toBe(true);
await expect(doTestTableContent(".calendar .event", ".time", "24th.Oct, 08:00-09:00", second)).resolves.toBe(true);
await expect(doTestTableContent(".calendar .event", ".time", "25th.Oct, 08:00-09:00", third)).resolves.toBe(true);
});
});
describe("berlin late in day event moved, viewed from chicago", () => {
it("Issue #unknown rrule ETC+2 close to timezone edge", async () => {
await helpers.startApplication("tests/configs/modules/calendar/end_of_day_berlin_moved.js", "08 Oct 2024 12:30:00 GMT+02:00", ["js/electron.js"], "America/Chicago");
await expect(doTestTableContent(".calendar .event", ".time", "24th.Oct, 16:00-17:00", last)).resolves.toBe(true);
await helpers.startApplication("tests/configs/modules/calendar/end_of_day_berlin_moved.js", "08 Oct 2024 12:30:00 GMT+02:00", [], "America/Chicago");
await expect(doTestCount()).resolves.toBe(3);
await expect(doTestTableContent(".calendar .event", ".time", "22nd.Oct, 16:00-17:00", first)).resolves.toBe(true);
await expect(doTestTableContent(".calendar .event", ".time", "23rd.Oct, 16:00-17:00", second)).resolves.toBe(true);
await expect(doTestTableContent(".calendar .event", ".time", "24th.Oct, 16:00-17:00", third)).resolves.toBe(true);
});
});
describe("berlin multi-events inside offset", () => {
it("some events before DST. some after midnight", async () => {
await helpers.startApplication("tests/configs/modules/calendar/berlin_multi.js", "08 Oct 2024 12:30:00 GMT+02:00", ["js/electron.js"], "Europe/Berlin");
await helpers.startApplication("tests/configs/modules/calendar/berlin_multi.js", "08 Oct 2024 12:30:00 GMT+02:00", [], "Europe/Berlin");
await expect(doTestTableContent(".calendar .event", ".time", "30th.Oct, 00:00-01:00", last)).resolves.toBe(true);
await expect(doTestTableContent(".calendar .event", ".time", "21st.Oct, 00:00-01:00", first)).resolves.toBe(true);
});
@@ -221,7 +234,7 @@ describe("Calendar module", () => {
describe("berlin whole day repeating, start moved after end", () => {
it("some events before DST. some after", async () => {
await helpers.startApplication("tests/configs/modules/calendar/berlin_whole_day_event_moved_over_dst_change.js", "08 Oct 2024 12:30:00 GMT+02:00", ["js/electron.js"], "Europe/Berlin");
await helpers.startApplication("tests/configs/modules/calendar/berlin_whole_day_event_moved_over_dst_change.js", "08 Oct 2024 12:30:00 GMT+02:00", [], "Europe/Berlin");
await expect(doTestTableContent(".calendar .event", ".time", "30th.Oct", last)).resolves.toBe(true);
await expect(doTestTableContent(".calendar .event", ".time", "27th.Oct", first)).resolves.toBe(true);
});
@@ -229,7 +242,7 @@ describe("Calendar module", () => {
describe("berlin 11pm-midnight", () => {
it("right inside the offset, before midnight", async () => {
await helpers.startApplication("tests/configs/modules/calendar/berlin_end_of_day_repeating.js", "08 Oct 2024 12:30:00 GMT+02:00", ["js/electron.js"], "Europe/Berlin");
await helpers.startApplication("tests/configs/modules/calendar/berlin_end_of_day_repeating.js", "08 Oct 2024 12:30:00 GMT+02:00", [], "Europe/Berlin");
await expect(doTestTableContent(".calendar .event", ".time", "24th.Oct, 23:00-00:00", last)).resolves.toBe(true);
await expect(doTestTableContent(".calendar .event", ".time", "22nd.Oct, 23:00-00:00", first)).resolves.toBe(true);
});
@@ -237,7 +250,7 @@ describe("Calendar module", () => {
describe("both moved and delete events in recurring list", () => {
it("with moved before and after original", async () => {
await helpers.startApplication("tests/configs/modules/calendar/exdate_and_recurrence_together.js", "08 Oct 2024 12:30:00 GMT-07:00", ["js/electron.js"], "America/Los_Angeles");
await helpers.startApplication("tests/configs/modules/calendar/exdate_and_recurrence_together.js", "08 Oct 2024 12:30:00 GMT-07:00", [], "America/Los_Angeles");
// moved after end at oct 26
await expect(doTestTableContent(".calendar .event", ".time", "27th.Oct, 14:30-15:30", last)).resolves.toBe(true);
// moved before start at oct 23
@@ -249,15 +262,20 @@ describe("Calendar module", () => {
describe("one event diff tz", () => {
it("start/end in diff timezones", async () => {
await helpers.startApplication("tests/configs/modules/calendar/diff_tz_start_end.js", "08 Oct 2024 12:30:00 GMT-07:00", ["js/electron.js"], "America/Chicago");
await helpers.startApplication("tests/configs/modules/calendar/diff_tz_start_end.js", "08 Oct 2024 12:30:00 GMT-07:00", [], "America/Chicago");
// just
await expect(doTestTableContent(".calendar .event", ".time", "29th.Oct, 05:00-30th.Oct, 18:00", first)).resolves.toBe(true);
});
it("viewing from further west in diff timezones", async () => {
await helpers.startApplication("tests/configs/modules/calendar/chicago-looking-at-ny-recurring.js", "22 Jan 2025 14:30:00 GMT-06:00", [], "America/Chicago");
// just
await expect(doTestTableContent(".calendar .event", ".time", "22nd.Jan, 17:30-19:30", first)).resolves.toBe(true);
});
});
describe("one event non repeating", () => {
it("fullday non-repeating", async () => {
await helpers.startApplication("tests/configs/modules/calendar/fullday_event_over_multiple_days_nonrepeating.js", "08 Oct 2024 12:30:00 GMT-07:00", ["js/electron.js"], "America/Chicago");
await helpers.startApplication("tests/configs/modules/calendar/fullday_event_over_multiple_days_nonrepeating.js", "08 Oct 2024 12:30:00 GMT-07:00", [], "America/Chicago");
// just
await expect(doTestTableContent(".calendar .event", ".time", "25th.Oct-30th.Oct", first)).resolves.toBe(true);
});
@@ -265,7 +283,7 @@ describe("Calendar module", () => {
describe("one event no end display", () => {
it("don't display end", async () => {
await helpers.startApplication("tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_no_display_end.js", "08 Oct 2024 12:30:00 GMT-07:00", ["js/electron.js"], "America/Chicago");
await helpers.startApplication("tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_no_display_end.js", "08 Oct 2024 12:30:00 GMT-07:00", [], "America/Chicago");
// just
await expect(doTestTableContent(".calendar .event", ".time", "25th.Oct, 20:00", first)).resolves.toBe(true);
});
@@ -273,10 +291,27 @@ describe("Calendar module", () => {
describe("display end display end", () => {
it("display end", async () => {
await helpers.startApplication("tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_display_end.js", "08 Oct 2024 12:30:00 GMT-07:00", ["js/electron.js"], "America/Chicago");
await helpers.startApplication("tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_display_end.js", "08 Oct 2024 12:30:00 GMT-07:00", [], "America/Chicago");
// just
await expect(doTestTableContent(".calendar .event", ".time", "25th.Oct, 20:00-26th.Oct, 06:00", first)).resolves.toBe(true);
});
});
describe("count and check symbols", () => {
it("in array", async () => {
await helpers.startApplication("tests/configs/modules/calendar/symboltest.js", "08 Oct 2024 12:30:00 GMT-07:00", [], "America/Chicago");
// just
await expect(doTestCount(".calendar .event .symbol .fa-fw")).resolves.toBe(2);
await expect(doTestCount(".calendar .event .symbol .fa-calendar-check")).resolves.toBe(1);
await expect(doTestCount(".calendar .event .symbol .fa-google")).resolves.toBe(1);
});
});
describe("count events broadcast", () => {
it("get 12 with maxentries set to 1", async () => {
await helpers.startApplication("tests/configs/modules/calendar/countCalendarEvents.js", "01 Jan 2024 12:30:00 GMT-076:00", [], "America/Chicago");
await expect(doTestTableContent(".testNotification", ".elementCount", "12", first)).resolves.toBe(true);
});
});
});

View File

@@ -5,11 +5,12 @@ describe("Compliments module", () => {
/**
* move similar tests in function doTest
* @param {Array} complimentsArray The array of compliments.
* @param {string} state The state of the element (e.g., "visible" or "attached").
* @returns {boolean} result
*/
const doTest = async (complimentsArray) => {
await helpers.getElement(".compliments");
const elem = await helpers.getElement(".module-content");
const doTest = async (complimentsArray, state = "visible") => {
await helpers.getElement(".compliments", state);
const elem = await helpers.getElement(".module-content", state);
expect(elem).not.toBeNull();
expect(complimentsArray).toContain(await elem.textContent());
return true;
@@ -34,6 +35,11 @@ describe("Compliments module", () => {
await helpers.startApplication("tests/configs/modules/compliments/compliments_parts_day.js", "01 Oct 2022 20:00:00 GMT");
await expect(doTest(["Hello There", "Good Evening", "Evening test"])).resolves.toBe(true);
});
it("doesn't show evening compliments during the day when the other parts of day are not set", async () => {
await helpers.startApplication("tests/configs/modules/compliments/compliments_evening.js", "01 Oct 2022 08:00:00 GMT");
await expect(doTest([""], "attached")).resolves.toBe(true);
});
});
describe("Feature date in compliments module", () => {

164
tests/mocks/12_events.ics Normal file
View File

@@ -0,0 +1,164 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Calendar Labs//Calendar 1.0//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:US Holidays
X-WR-TIMEZONE:Etc/GMT
BEGIN:VEVENT
SUMMARY:Start of Month 1
DTSTART:20190101
DTEND:20190101
RRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1
LOCATION:United States
DESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \n\n Like us on Facebook: http://fb.com/calendarlabs to get updates
UID:5e52949sada28d231582470298@calendarlabs.com
DTSTAMP:20200223T150458Z
STATUS:CONFIRMED
TRANSP:TRANSPARENT
SEQUENCE:0
END:VEVENT
BEGIN:VEVENT
SUMMARY:Start of Month 2
DTSTART:20190201
DTEND:20190201
RRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1
LOCATION:United States
DESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \n\n Like us on Facebook: http://fb.com/calendarlabs to get updates
UID:5e52949a2wds8d231582470298@calendarlabs.com
DTSTAMP:20200223T150458Z
STATUS:CONFIRMED
TRANSP:TRANSPARENT
SEQUENCE:0
END:VEVENT
BEGIN:VEVENT
SUMMARY:Start of Month 3
DTSTART:20190301
DTEND:20190301
RRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1
LOCATION:United States
DESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \n\n Like us on Facebook: http://fb.com/calendarlabs to get updates
UID:5e52949a2SDD8d231582470298@calendarlabs.com
DTSTAMP:20200223T150458Z
STATUS:CONFIRMED
TRANSP:TRANSPARENT
SEQUENCE:0
END:VEVENT
BEGIN:VEVENT
SUMMARY:Start of Month 4
DTSTART:20190401
DTEND:20190401
RRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1
LOCATION:United States
DESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \n\n Like us on Facebook: http://fb.com/calendarlabs to get updates
UID:5e52949a2SDD8d231582FDSFD470298@calendarlabs.com
DTSTAMP:20200223T150458Z
STATUS:CONFIRMED
TRANSP:TRANSPARENT
SEQUENCE:0
END:VEVENT
BEGIN:VEVENT
SUMMARY:Start of Month 5
DTSTART:20190501
DTEND:20190501
RRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1
LOCATION:United States
DESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \n\n Like us on Facebook: http://fb.com/calendarlabs to get updates
UID:5e52949a2SDD8d2DD315824702598@calendarlabs.com
DTSTAMP:20200223T150458Z
STATUS:CONFIRMED
TRANSP:TRANSPARENT
SEQUENCE:0
END:VEVENT
BEGIN:VEVENT
SUMMARY:Start of Month 6
DTSTART:20190601
DTEND:20190601
RRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1
LOCATION:United States
DESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \n\n Like us on Facebook: http://fb.com/calendarlabs to get updates
UID:5e52949a2SDD8d2DD31582470298@calendarlabs.com
DTSTAMP:20200223T150458Z
STATUS:CONFIRMED
TRANSP:TRANSPARENT
SEQUENCE:0
END:VEVENT
BEGIN:VEVENT
SUMMARY:Start of Month 7
DTSTART:20190701
DTEND:20190701
RRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1
LOCATION:United States
DESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \n\n Like us on Facebook: http://fb.com/calendarlabs to get updates
UID:5e52942SDD8d2DD31582470298@calendarlabs.com
DTSTAMP:20200223T150458Z
STATUS:CONFIRMED
TRANSP:TRANSPARENT
SEQUENCE:0
END:VEVENT
BEGIN:VEVENT
SUMMARY:Start of Month 8
DTSTART:20190801
DTEND:20190801
RRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1
LOCATION:United States
DESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \n\n Like us on Facebook: http://fb.com/calendarlabs to get updates
UID:5e52949a2SDD8d2DDt31582470298@calendarlabs.com
DTSTAMP:20200223T150458Z
STATUS:CONFIRMED
TRANSP:TRANSPARENT
SEQUENCE:0
END:VEVENT
BEGIN:VEVENT
SUMMARY:Start of Month 9
DTSTART:20190901
DTEND:20190901
RRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1
LOCATION:United States
DESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \n\n Like us on Facebook: http://fb.com/calendarlabs to get updates
UID:5e529449a2SDD8d2DDt315824702798@calendarlabs.com
DTSTAMP:20200223T150458Z
STATUS:CONFIRMED
TRANSP:TRANSPARENT
SEQUENCE:0
END:VEVENT
BEGIN:VEVENT
SUMMARY:Start of Month 10
DTSTART:20191001
DTEND:20191001
RRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1
LOCATION:United States
DESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \n\n Like us on Facebook: http://fb.com/calendarlabs to get updates
UID:5e529449a2SDD8d2DDt31582470298@calendarlabs.com
DTSTAMP:20200223T150458Z
STATUS:CONFIRMED
TRANSP:TRANSPARENT
SEQUENCE:0
END:VEVENT
BEGIN:VEVENT
SUMMARY:Start of Month 11
DTSTART:20191101
DTEND:20191101
RRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1
LOCATION:United States
DESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \n\n Like us on Facebook: http://fb.com/calendarlabs to get updates
UID:5e5294449a2SDD8d2DDt31582470298@calendarlabs.com
DTSTAMP:20200223T150458Z
STATUS:CONFIRMED
TRANSP:TRANSPARENT
SEQUENCE:0
END:VEVENT
BEGIN:VEVENT
SUMMARY:Start of Month 12
DTSTART:20191201
DTEND:20191201
RRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1
LOCATION:United States
DESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \n\n Like us on Facebook: http://fb.com/calendarlabs to get updates
UID:5e5294a2SDD8d2DDt31582470298@calendarlabs.com
DTSTAMP:20200223T150458Z
STATUS:CONFIRMED
TRANSP:TRANSPARENT
SEQUENCE:0
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,15 @@
BEGIN:VEVENT
DTSTART;TZID=America/New_York:20240918T183000
DTEND;TZID=America/New_York:20240918T203000
RRULE:FREQ=WEEKLY;BYDAY=WE
EXDATE;TZID=America/New_York:20241127T183000
EXDATE;TZID=America/New_York:20241225T183000
DTSTAMP:20250122T045443Z
UID:_@google.com
CREATED:20240916T131843Z
LAST-MODIFIED:20241222T235014Z
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Derby
TRANSP:OPAQUE
END:VEVENT

View File

@@ -0,0 +1,28 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DESCRIPTION:\n
RRULE:FREQ=YEARLY;UNTIL=20250504T230000Z;INTERVAL=1;BYMONTHDAY=5;BYMONTH=5
UID:040000008200E00074C5B7101A82E00800000000DAEF6ED30D9FDA01000000000000000
010000000D37F812F0777844A93E97B96AD2D278B
SUMMARY:Person A's Birthday
DTSTART;VALUE=DATE:20250505
DTEND;VALUE=DATE:20250506
CLASS:PUBLIC
PRIORITY:5
DTSTAMP:20250428T133000Z
TRANSP:TRANSPARENT
STATUS:CONFIRMED
SEQUENCE:0
LOCATION:
X-MICROSOFT-CDO-APPT-SEQUENCE:0
X-MICROSOFT-CDO-BUSYSTATUS:FREE
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
X-MICROSOFT-CDO-ALLDAYEVENT:TRUE
X-MICROSOFT-CDO-IMPORTANCE:1
X-MICROSOFT-CDO-INSTTYPE:1
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
X-MICROSOFT-DISALLOW-COUNTER:FALSE
X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT
X-MICROSOFT-ISRESPONSEREQUESTED:FALSE
END:VEVENT
END:VCALENDAR

View File

@@ -0,0 +1,59 @@
Module.register("testNotification", {
defaults: {
debug: false,
match: {
notificationID: "",
matchtype: "count"
//or
// type: 'contents' // look for item in field of content
}
},
count: 0,
table: null,
notificationReceived (notification, payload) {
if (notification === this.config.match.notificationID) {
if (this.config.match.matchtype === "count") {
this.count = payload.length;
if (this.count) {
this.table = document.createElement("table");
this.addTableRow(this.table, null, `${this.count}:elementCount`);
if (this.config.debug) {
payload.forEach((e, i) => {
this.addTableRow(this.table, i, e.title);
});
}
}
this.updateDom();
}
}
},
maketd (row, info) {
let td = document.createElement("td");
row.appendChild(td);
if (info !== null) {
let colinfo = info.toString().split(":");
if (colinfo.length === 2) td.className = colinfo[1];
td.innerText = colinfo[0];
}
return td;
},
addTableRow (table, col1 = null, col2 = null, col3 = null) {
let tableRow = document.createElement("tr");
table.appendChild(tableRow);
let tablecol1 = this.maketd(tableRow, col1);
let tablecol2 = this.maketd(tableRow, col2);
let tablecol3 = this.maketd(tableRow, col3);
return tableRow;
},
getDom () {
let wrapper = document.createElement("div");
if (this.table) {
wrapper.appendChild(this.table);
}
return wrapper;
}
});

View File

@@ -1,12 +1,15 @@
const fs = require("node:fs");
const path = require("node:path");
const helmet = require("helmet");
const { JSDOM } = require("jsdom");
const express = require("express");
const sockets = new Set();
describe("Translator", () => {
let server;
const sockets = new Set();
const translatorJsPath = path.join(__dirname, "..", "..", "..", "js", "translator.js");
const translatorJsScriptContent = fs.readFileSync(translatorJsPath, "utf8");
const translationTestData = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "..", "tests", "mocks", "translation_test.json"), "utf8"));
beforeAll(() => {
const app = express();
@@ -77,86 +80,82 @@ describe("Translator", () => {
Translator.coreTranslationsFallback = coreTranslationsFallback;
};
it("should return custom module translation", () => {
return new Promise((done) => {
const dom = new JSDOM(`<script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`, { runScripts: "dangerously", resources: "usable" });
dom.window.onload = () => {
const { Translator } = dom.window;
setTranslations(Translator);
let translation = Translator.translate({ name: "MMM-Module" }, "Hello");
expect(translation).toBe("Hallo");
translation = Translator.translate({ name: "MMM-Module" }, "Hello {username}", { username: "fewieden" });
expect(translation).toBe("Hallo fewieden");
done();
};
});
it("should return custom module translation", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
setTranslations(Translator);
let translation = Translator.translate({ name: "MMM-Module" }, "Hello");
expect(translation).toBe("Hallo");
translation = Translator.translate({ name: "MMM-Module" }, "Hello {username}", { username: "fewieden" });
expect(translation).toBe("Hallo fewieden");
});
it("should return core translation", () => {
return new Promise((done) => {
const dom = new JSDOM(`<script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`, { runScripts: "dangerously", resources: "usable" });
dom.window.onload = () => {
const { Translator } = dom.window;
setTranslations(Translator);
let translation = Translator.translate({ name: "MMM-Module" }, "FOO");
expect(translation).toBe("Foo");
translation = Translator.translate({ name: "MMM-Module" }, "BAR {something}", { something: "Lorem Ipsum" });
expect(translation).toBe("Bar Lorem Ipsum");
done();
};
});
it("should return core translation", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
setTranslations(Translator);
let translation = Translator.translate({ name: "MMM-Module" }, "FOO");
expect(translation).toBe("Foo");
translation = Translator.translate({ name: "MMM-Module" }, "BAR {something}", { something: "Lorem Ipsum" });
expect(translation).toBe("Bar Lorem Ipsum");
});
it("should return custom module translation fallback", () => {
return new Promise((done) => {
const dom = new JSDOM(`<script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`, { runScripts: "dangerously", resources: "usable" });
dom.window.onload = () => {
const { Translator } = dom.window;
setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "A key");
expect(translation).toBe("A translation");
done();
};
});
it("should return custom module translation fallback", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "A key");
expect(translation).toBe("A translation");
});
it("should return core translation fallback", () => {
return new Promise((done) => {
const dom = new JSDOM(`<script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`, { runScripts: "dangerously", resources: "usable" });
dom.window.onload = () => {
const { Translator } = dom.window;
setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "Fallback");
expect(translation).toBe("core fallback");
done();
};
});
it("should return core translation fallback", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "Fallback");
expect(translation).toBe("core fallback");
});
it("should return translation with placeholder for missing variables", () => {
return new Promise((done) => {
const dom = new JSDOM(`<script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`, { runScripts: "dangerously", resources: "usable" });
dom.window.onload = () => {
const { Translator } = dom.window;
setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "Hello {username}");
expect(translation).toBe("Hallo {username}");
done();
};
});
it("should return translation with placeholder for missing variables", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "Hello {username}");
expect(translation).toBe("Hallo {username}");
});
it("should return key if no translation was found", () => {
return new Promise((done) => {
const dom = new JSDOM(`<script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`, { runScripts: "dangerously", resources: "usable" });
dom.window.onload = () => {
const { Translator } = dom.window;
setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "MISSING");
expect(translation).toBe("MISSING");
done();
};
});
it("should return key if no translation was found", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
setTranslations(Translator);
const translation = Translator.translate({ name: "MMM-Module" }, "MISSING");
expect(translation).toBe("MISSING");
});
});
@@ -168,144 +167,127 @@ describe("Translator", () => {
}
};
it("should load translations", () => {
return new Promise((done) => {
const dom = new JSDOM(`<script>var Log = {log: () => {}};</script><script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`, { runScripts: "dangerously", resources: "usable" });
dom.window.onload = async () => {
const { Translator } = dom.window;
const file = "translation_test.json";
it("should load translations", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
dom.window.Log = { log: jest.fn() };
await new Promise((resolve) => dom.window.onload = resolve);
await Translator.load(mmm, file, false);
const json = require(path.join(__dirname, "..", "..", "..", "tests", "mocks", file));
expect(Translator.translations[mmm.name]).toEqual(json);
done();
};
});
const { Translator } = dom.window;
const file = "translation_test.json";
await Translator.load(mmm, file, false);
const json = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "..", "tests", "mocks", file), "utf8"));
expect(Translator.translations[mmm.name]).toEqual(json);
});
it("should load translation fallbacks", () => {
return new Promise((done) => {
const dom = new JSDOM(`<script>var Log = {log: () => {}};</script><script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`, { runScripts: "dangerously", resources: "usable" });
dom.window.onload = async () => {
const { Translator } = dom.window;
const file = "translation_test.json";
it("should load translation fallbacks", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
await Translator.load(mmm, file, true);
const json = require(path.join(__dirname, "..", "..", "..", "tests", "mocks", file));
expect(Translator.translationsFallback[mmm.name]).toEqual(json);
done();
};
});
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
const file = "translation_test.json";
dom.window.Log = { log: jest.fn() };
await Translator.load(mmm, file, true);
const json = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "..", "tests", "mocks", file), "utf8"));
expect(Translator.translationsFallback[mmm.name]).toEqual(json);
});
it("should not load translations, if module fallback exists", () => {
return new Promise((done) => {
const dom = new JSDOM(`<script>var Log = {log: () => {}};</script><script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`, { runScripts: "dangerously", resources: "usable" });
dom.window.onload = async () => {
const { Translator, XMLHttpRequest } = dom.window;
const file = "translation_test.json";
it("should not load translations, if module fallback exists", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
await new Promise((resolve) => dom.window.onload = resolve);
XMLHttpRequest.prototype.send = () => {
throw new Error("Shouldn't load files");
};
const { Translator } = dom.window;
const file = "translation_test.json";
Translator.translationsFallback[mmm.name] = {
Hello: "Hallo"
};
await Translator.load(mmm, file, false);
expect(Translator.translations[mmm.name]).toBeUndefined();
expect(Translator.translationsFallback[mmm.name]).toEqual({
Hello: "Hallo"
});
done();
};
dom.window.Log = { log: jest.fn() };
Translator.translationsFallback[mmm.name] = {
Hello: "Hallo"
};
await Translator.load(mmm, file, false);
expect(Translator.translations[mmm.name]).toBeUndefined();
expect(Translator.translationsFallback[mmm.name]).toEqual({
Hello: "Hallo"
});
});
});
describe("loadCoreTranslations", () => {
it("should load core translations and fallback", () => {
return new Promise((done) => {
const dom = new JSDOM(
`<script>var translations = {en: "http://localhost:3000/translations/translation_test.json"}; var Log = {log: () => {}};</script>\
<script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`,
{ runScripts: "dangerously", resources: "usable" }
);
dom.window.onload = async () => {
const { Translator } = dom.window;
await Translator.loadCoreTranslations("en");
it("should load core translations and fallback", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
dom.window.translations = { en: "http://localhost:3000/translations/translation_test.json" };
dom.window.Log = { log: jest.fn() };
await new Promise((resolve) => dom.window.onload = resolve);
const en = require(path.join(__dirname, "..", "..", "..", "tests", "mocks", "translation_test.json"));
setTimeout(() => {
expect(Translator.coreTranslations).toEqual(en);
expect(Translator.coreTranslationsFallback).toEqual(en);
done();
}, 500);
};
});
const { Translator } = dom.window;
await Translator.loadCoreTranslations("en");
const en = translationTestData;
await new Promise((resolve) => setTimeout(resolve, 500));
expect(Translator.coreTranslations).toEqual(en);
expect(Translator.coreTranslationsFallback).toEqual(en);
});
it("should load core fallback if language cannot be found", () => {
return new Promise((done) => {
const dom = new JSDOM(
`<script>var translations = {en: "http://localhost:3000/translations/translation_test.json"}; var Log = {log: () => {}};</script>\
<script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`,
{ runScripts: "dangerously", resources: "usable" }
);
dom.window.onload = async () => {
const { Translator } = dom.window;
await Translator.loadCoreTranslations("MISSINGLANG");
it("should load core fallback if language cannot be found", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
dom.window.translations = { en: "http://localhost:3000/translations/translation_test.json" };
dom.window.Log = { log: jest.fn() };
await new Promise((resolve) => dom.window.onload = resolve);
const en = require(path.join(__dirname, "..", "..", "..", "tests", "mocks", "translation_test.json"));
setTimeout(() => {
expect(Translator.coreTranslations).toEqual({});
expect(Translator.coreTranslationsFallback).toEqual(en);
done();
}, 500);
};
});
const { Translator } = dom.window;
await Translator.loadCoreTranslations("MISSINGLANG");
const en = translationTestData;
await new Promise((resolve) => setTimeout(resolve, 500));
expect(Translator.coreTranslations).toEqual({});
expect(Translator.coreTranslationsFallback).toEqual(en);
});
});
describe("loadCoreTranslationsFallback", () => {
it("should load core translations fallback", () => {
return new Promise((done) => {
const dom = new JSDOM(
`<script>var translations = {en: "http://localhost:3000/translations/translation_test.json"}; var Log = {log: () => {}};</script>\
<script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`,
{ runScripts: "dangerously", resources: "usable" }
);
dom.window.onload = async () => {
const { Translator } = dom.window;
await Translator.loadCoreTranslationsFallback();
it("should load core translations fallback", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
dom.window.translations = { en: "http://localhost:3000/translations/translation_test.json" };
dom.window.Log = { log: jest.fn() };
await new Promise((resolve) => dom.window.onload = resolve);
const en = require(path.join(__dirname, "..", "..", "..", "tests", "mocks", "translation_test.json"));
setTimeout(() => {
expect(Translator.coreTranslationsFallback).toEqual(en);
done();
}, 500);
};
});
const { Translator } = dom.window;
await Translator.loadCoreTranslationsFallback();
const en = translationTestData;
await new Promise((resolve) => setTimeout(resolve, 500));
expect(Translator.coreTranslationsFallback).toEqual(en);
});
it("should load core fallback if language cannot be found", () => {
return new Promise((done) => {
const dom = new JSDOM(
`<script>var translations = {}; var Log = {log: () => {}};</script>\
<script src="file://${path.join(__dirname, "..", "..", "..", "js", "translator.js")}">`,
{ runScripts: "dangerously", resources: "usable" }
);
dom.window.onload = async () => {
const { Translator } = dom.window;
await Translator.loadCoreTranslations();
it("should load core fallback if language cannot be found", async () => {
const dom = new JSDOM("", { runScripts: "outside-only" });
dom.window.eval(translatorJsScriptContent);
dom.window.translations = {};
dom.window.Log = { log: jest.fn() };
setTimeout(() => {
expect(Translator.coreTranslationsFallback).toEqual({});
done();
}, 500);
};
});
await new Promise((resolve) => dom.window.onload = resolve);
const { Translator } = dom.window;
await Translator.loadCoreTranslations();
await new Promise((resolve) => setTimeout(resolve, 500));
expect(Translator.coreTranslationsFallback).toEqual({});
});
});
});

View File

@@ -1,5 +1,8 @@
global.moment = require("moment-timezone");
const ical = require("node-ical");
const { expect } = require("playwright/test");
const moment = require("moment-timezone");
const CalendarFetcherUtils = require("../../../../../modules/default/calendar/calendarfetcherutils");
describe("Calendar fetcher utils test", () => {
@@ -49,5 +52,65 @@ describe("Calendar fetcher utils test", () => {
expect(filteredEvents[0].title).toBe("ongoingEvent");
expect(filteredEvents[1].title).toBe("upcomingEvent");
});
it("should return the correct times when recurring events pass through daylight saving time", () => {
const data = ical.parseICS(`BEGIN:VEVENT
DTSTART;TZID=Europe/Amsterdam:20250311T090000
DTEND;TZID=Europe/Amsterdam:20250311T091500
RRULE:FREQ=WEEKLY;BYDAY=FR,MO,TH,TU,WE,SA,SU
DTSTAMP:20250531T091103Z
ORGANIZER;CN=test:mailto:test@test.com
UID:67e65a1d-b889-4451-8cab-5518cecb9c66
CREATED:20230111T114612Z
DESCRIPTION:Test
LAST-MODIFIED:20250528T071312Z
SEQUENCE:1
STATUS:CONFIRMED
SUMMARY:Test
TRANSP:OPAQUE
END:VEVENT`);
const filteredEvents = CalendarFetcherUtils.filterEvents(data, defaultConfig);
const januaryFirst = filteredEvents.filter((event) => moment(event.startDate, "x").format("MM-DD") === "01-01");
const julyFirst = filteredEvents.filter((event) => moment(event.startDate, "x").format("MM-DD") === "07-01");
let januaryMoment = moment(`${moment(januaryFirst[0].startDate, "x").format("YYYY")}-01-01T09:00:00`)
.tz("Europe/Amsterdam", true) // Convert to Europe/Amsterdam timezone (see event ical) but keep 9 o'clock
.tz(moment.tz.guess()); // Convert to guessed timezone as that is used in the filterEvents
let julyMoment = moment(`${moment(julyFirst[0].startDate, "x").format("YYYY")}-07-01T09:00:00`)
.tz("Europe/Amsterdam", true) // Convert to Europe/Amsterdam timezone (see event ical) but keep 9 o'clock
.tz(moment.tz.guess()); // Convert to guessed timezone as that is used in the filterEvents
expect(januaryFirst[0].startDate).toEqual(januaryMoment.format("x"));
expect(julyFirst[0].startDate).toEqual(julyMoment.format("x"));
});
it("should return the correct moments based on the timezone given", () => {
const data = ical.parseICS(`BEGIN:VEVENT
DTSTART;TZID=Europe/Amsterdam:20250311T090000
DTEND;TZID=Europe/Amsterdam:20250311T091500
RRULE:FREQ=WEEKLY;BYDAY=FR,MO,TH,TU,WE,SA,SU
DTSTAMP:20250531T091103Z
ORGANIZER;CN=test:mailto:test@test.com
UID:67e65a1d-b889-4451-8cab-5518cecb9c66
CREATED:20230111T114612Z
DESCRIPTION:Test
LAST-MODIFIED:20250528T071312Z
SEQUENCE:1
STATUS:CONFIRMED
SUMMARY:Test
TRANSP:OPAQUE
END:VEVENT`);
const moments = CalendarFetcherUtils.getMomentsFromRecurringEvent(data["67e65a1d-b889-4451-8cab-5518cecb9c66"], moment(), moment().add(365, "days"));
const januaryFirst = moments.filter((m) => m.format("MM-DD") === "01-01");
const julyFirst = moments.filter((m) => m.format("MM-DD") === "07-01");
expect(januaryFirst[0].toISOString(true)).toContain("09:00:00.000+01:00");
expect(julyFirst[0].toISOString(true)).toContain("09:00:00.000+02:00");
});
});
});

View File

@@ -1,12 +1,14 @@
{
"LOADING": "Besig om te laai …",
"DAYBEFOREYESTERDAY": "Eergister",
"YESTERDAY": "Gister",
"TODAY": "Vandag",
"TOMORROW": "Môre",
"DAYAFTERTOMORROW": "Oormôre",
"RUNNING": "Eindig in",
"EMPTY": "Geen komende gebeurtenisse.",
"WEEK": "Week {weekNumber}",
"N": "N",
"NNE": "NNO",
@@ -25,8 +27,24 @@
"NW": "NW",
"NNW": "NNW",
"FEELS": "Voel soos {DEGREE}",
"PRECIP_POP": "Neerslag waarskynlikheid",
"PRECIP_AMOUNT": "Neerslag hoeveelheid",
"MODULE_CONFIG_CHANGED": "Die konfigurasie opsies vir die {MODULE_NAME} module het verander.\nGaan asseblief die dokumentasie na.",
"MODULE_CONFIG_ERROR": "Fout in die {MODULE_NAME} module. {ERROR}",
"MODULE_ERROR_MALFORMED_URL": "Ongeldige URL.",
"MODULE_ERROR_NO_CONNECTION": "Geen internetverbinding.",
"MODULE_ERROR_UNAUTHORIZED": "Owerheid het misluk.",
"MODULE_ERROR_UNSPECIFIED": "Gaan die logs na vir meer besonderhede.",
"NEWSFEED_NO_ITEMS": "Geen nuus op die oomblik.",
"UPDATE_NOTIFICATION": "MagicMirror² update beskikbaar.",
"UPDATE_NOTIFICATION_MODULE": "Update beskikbaar vir {MODULE_NAME} module.",
"UPDATE_INFO_SINGLE": "Die huidige installasie is {COMMIT_COUNT} commit agter op die {BRANCH_NAME} branch.",
"UPDATE_INFO_MULTIPLE": "Die huidige installasie is {COMMIT_COUNT} commits agter op die {BRANCH_NAME} branch."
"UPDATE_INFO_MULTIPLE": "Die huidige installasie is {COMMIT_COUNT} commits agter op die {BRANCH_NAME} branch.",
"UPDATE_NOTIFICATION_DONE": "Update voltooi vir {MODULE_NAME} module.",
"UPDATE_NOTIFICATION_ERROR": "Fout tydens opdatering van {MODULE_NAME} module.",
"UPDATE_NOTIFICATION_NEED-RESTART": "MagicMirror moet herbegin word."
}

48
translations/ar.json Normal file
View File

@@ -0,0 +1,48 @@
{
"LOADING": "جار التحميل …",
"YESTERDAY": "أمس",
"TODAY": "اليوم",
"TOMORROW": "غدًا",
"RUNNING": "ينتهي خلال",
"EMPTY": "لا توجد أحداث قادمة.",
"WEEK": "الأسبوع {weekNumber}",
"N": "شمال",
"NNE": "شمال شمال شرقي",
"NE": "شمال شرقي",
"ENE": "شرق شمال شرقي",
"E": "شرق",
"ESE": "شرق جنوب شرقي",
"SE": "جنوب شرقي",
"SSE": "جنوب جنوب شرقي",
"S": "جنوب",
"SSW": "جنوب جنوب غربي",
"SW": "جنوب غربي",
"WSW": "غرب جنوب غربي",
"W": "غرب",
"WNW": "غرب شمال غربي",
"NW": "شمال غربي",
"NNW": "شمال شمال غربي",
"FEELS": "كأنها {DEGREE}",
"PRECIP_POP": "احتمالية الهطول",
"PRECIP_AMOUNT": "كمية الهطول",
"MODULE_CONFIG_CHANGED": "تم تغيير خيارات التهيئة لوحدة {MODULE_NAME}.\nيرجى مراجعة الوثائق.",
"MODULE_CONFIG_ERROR": "خطأ في وحدة {MODULE_NAME}. {ERROR}",
"MODULE_ERROR_MALFORMED_URL": "رابط غير صحيح.",
"MODULE_ERROR_NO_CONNECTION": "لا يوجد اتصال بالإنترنت.",
"MODULE_ERROR_UNAUTHORIZED": "فشل التصريح.",
"MODULE_ERROR_UNSPECIFIED": "تحقق من السجلات لمزيد من التفاصيل.",
"NEWSFEED_NO_ITEMS": "لا توجد أخبار في الوقت الحالي.",
"UPDATE_NOTIFICATION": "تحديث MagicMirror² متاح.",
"UPDATE_NOTIFICATION_MODULE": "تحديث متاح لوحدة {MODULE_NAME}.",
"UPDATE_INFO_SINGLE": "التثبيت الحالي متخلف عن تحديث واحد على فرع {BRANCH_NAME}.",
"UPDATE_INFO_MULTIPLE": "التثبيت الحالي متخلف عن {COMMIT_COUNT} تحديثات على فرع {BRANCH_NAME}.",
"UPDATE_NOTIFICATION_DONE": "تم التحديث لوحدة {MODULE_NAME}",
"UPDATE_NOTIFICATION_ERROR": "حدث خطأ أثناء تحديث وحدة {MODULE_NAME}",
"UPDATE_NOTIFICATION_NEED-RESTART": "يتطلب إعادة تشغيل MagicMirror."
}

View File

@@ -27,8 +27,24 @@
"NW": "СЗ",
"NNW": "ССЗ",
"FEELS": "Усеща се като {DEGREE}",
"PRECIP_POP": "Вероятност за валежи",
"PRECIP_AMOUNT": "Количество валежи",
"MODULE_CONFIG_CHANGED": "Променени са опциите за конфигурация на модула „{MODULE_NAME}“.\nМоля, проверете документацията.",
"MODULE_CONFIG_ERROR": "Грешка в модула „{MODULE_NAME}“. {ERROR}",
"MODULE_ERROR_MALFORMED_URL": "Неправилен URL адрес.",
"MODULE_ERROR_NO_CONNECTION": "Няма интернет връзка.",
"MODULE_ERROR_UNAUTHORIZED": "Неуспешна авторизация.",
"MODULE_ERROR_UNSPECIFIED": "Проверете логовете за повече подробности.",
"NEWSFEED_NO_ITEMS": "Няма новини в момента.",
"UPDATE_NOTIFICATION": "Налична е актуализация за MagicMirror².",
"UPDATE_NOTIFICATION_MODULE": "Налична е актуализация за модула „{MODULE_NAME}“.",
"UPDATE_INFO_SINGLE": "Инсталираната версия е с {COMMIT_COUNT} ревизия назад от клона „{BRANCH_NAME}“.",
"UPDATE_INFO_MULTIPLE": "Инсталираната версия е с {COMMIT_COUNT} ревизии назад от клона „{BRANCH_NAME}“."
"UPDATE_INFO_MULTIPLE": "Инсталираната версия е с {COMMIT_COUNT} ревизии назад от клона „{BRANCH_NAME}“.",
"UPDATE_NOTIFICATION_DONE": "Актуализацията на модула „{MODULE_NAME}“ е завършена.",
"UPDATE_NOTIFICATION_ERROR": "Грешка при актуализацията на модула „{MODULE_NAME}“",
"UPDATE_NOTIFICATION_NEED-RESTART": "Необходимо е рестартиране на MagicMirror."
}

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