Repository: MagicMirrorOrg/MagicMirror Branch: master Commit: b742e839becf Files: 382 Total size: 1.0 MB Directory structure: gitextract__ioz32a6/ ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── CODE_OF_CONDUCT.md │ ├── CONTRIBUTING.md │ ├── FUNDING.yaml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── change_request.yml │ │ ├── config.yml │ │ └── feature_request.yml │ ├── PULL_REQUEST_TEMPLATE.md │ ├── dependabot.yaml │ └── workflows/ │ ├── automated-tests.yaml │ ├── dep-review.yaml │ ├── electron-rebuild.yaml │ ├── enforce-pullrequest-rules.yaml │ ├── release-notes.yaml │ ├── spellcheck.yaml │ └── stale.yaml ├── .gitignore ├── .husky/ │ └── pre-commit ├── .markdownlint.json ├── .npmrc ├── .prettierignore ├── CHANGELOG.md ├── Collaboration.md ├── LICENSE.md ├── README.md ├── clientonly/ │ └── index.js ├── cspell.config.json ├── css/ │ ├── custom.css.sample │ ├── font-awesome.css │ ├── main.css │ └── roboto.css ├── eslint.config.mjs ├── index.html ├── jest.config.js ├── js/ │ ├── alias-resolver.js │ ├── animateCSS.js │ ├── app.js │ ├── check_config.js │ ├── class.js │ ├── defaults.js │ ├── deprecated.js │ ├── electron.js │ ├── ip_access_control.js │ ├── loader.js │ ├── logger.js │ ├── main.js │ ├── module.js │ ├── module_functions.js │ ├── node_helper.js │ ├── releasenotes.js │ ├── server.js │ ├── server_functions.js │ ├── socketclient.js │ ├── translator.js │ ├── utils.js │ └── vendor.js ├── jsconfig.json ├── module-types.ts ├── modules/ │ └── default/ │ ├── alert/ │ │ ├── README.md │ │ ├── alert.js │ │ ├── notificationFx.js │ │ ├── styles/ │ │ │ ├── center.css │ │ │ ├── left.css │ │ │ ├── notificationFx.css │ │ │ └── right.css │ │ ├── templates/ │ │ │ ├── alert.njk │ │ │ └── notification.njk │ │ └── translations/ │ │ ├── bg.json │ │ ├── da.json │ │ ├── de.json │ │ ├── el.json │ │ ├── en.json │ │ ├── eo.json │ │ ├── es.json │ │ ├── fr.json │ │ ├── hu.json │ │ ├── nl.json │ │ ├── pt-br.json │ │ ├── pt.json │ │ ├── ru.json │ │ └── th.json │ ├── calendar/ │ │ ├── README.md │ │ ├── calendar.css │ │ ├── calendar.js │ │ ├── calendarfetcher.js │ │ ├── calendarfetcherutils.js │ │ ├── calendarutils.js │ │ ├── debug.js │ │ ├── node_helper.js │ │ └── windowsZones.json │ ├── clock/ │ │ ├── README.md │ │ ├── clock.js │ │ └── clock_styles.css │ ├── compliments/ │ │ ├── README.md │ │ └── compliments.js │ ├── defaultmodules.js │ ├── helloworld/ │ │ ├── README.md │ │ ├── helloworld.js │ │ └── helloworld.njk │ ├── newsfeed/ │ │ ├── README.md │ │ ├── fullarticle.njk │ │ ├── newsfeed.css │ │ ├── newsfeed.js │ │ ├── newsfeed.njk │ │ ├── newsfeedfetcher.js │ │ ├── node_helper.js │ │ └── oldconfig.njk │ ├── updatenotification/ │ │ ├── README.md │ │ ├── git_helper.js │ │ ├── node_helper.js │ │ ├── update_helper.js │ │ ├── updatenotification.css │ │ ├── updatenotification.js │ │ └── updatenotification.njk │ ├── utils.js │ └── weather/ │ ├── README.md │ ├── current.njk │ ├── forecast.njk │ ├── hourly.njk │ ├── providers/ │ │ ├── README.md │ │ ├── envcanada.js │ │ ├── openmeteo.js │ │ ├── openweathermap.js │ │ ├── overrideWrapper.js │ │ ├── pirateweather.js │ │ ├── smhi.js │ │ ├── ukmetofficedatahub.js │ │ ├── weatherbit.js │ │ ├── weatherflow.js │ │ ├── weathergov.js │ │ └── yr.js │ ├── weather.css │ ├── weather.js │ ├── weatherobject.js │ ├── weatherprovider.js │ └── weatherutils.js ├── package.json ├── prettier.config.mjs ├── serveronly/ │ ├── index.js │ └── watcher.js ├── stylelint.config.mjs ├── tests/ │ ├── configs/ │ │ ├── customregions.js │ │ ├── default.js │ │ ├── empty_ipWhiteList.js │ │ ├── modules/ │ │ │ ├── alert/ │ │ │ │ ├── welcome_false.js │ │ │ │ ├── welcome_string.js │ │ │ │ └── welcome_true.js │ │ │ ├── calendar/ │ │ │ │ ├── 3_move_first_allday_repeating_event.js │ │ │ │ ├── auth-default.js │ │ │ │ ├── bad_rrule.js │ │ │ │ ├── basic-auth.js │ │ │ │ ├── berlin_end_of_day_repeating.js │ │ │ │ ├── berlin_multi.js │ │ │ │ ├── berlin_whole_day_event_moved_over_dst_change.js │ │ │ │ ├── changed-port.js │ │ │ │ ├── chicago-looking-at-ny-recurring.js │ │ │ │ ├── chicago_late_in_timezone.js │ │ │ │ ├── countCalendarEvents.js │ │ │ │ ├── custom.js │ │ │ │ ├── default.js │ │ │ │ ├── diff_tz_start_end.js │ │ │ │ ├── end_of_day_berlin_moved.js │ │ │ │ ├── event_with_time_over_multiple_days_non_repeating_display_end.js │ │ │ │ ├── event_with_time_over_multiple_days_non_repeating_no_display_end.js │ │ │ │ ├── exdate_and_recurrence_together.js │ │ │ │ ├── exdate_la_at_midnight_dst.js │ │ │ │ ├── exdate_la_at_midnight_std.js │ │ │ │ ├── exdate_la_before_midnight.js │ │ │ │ ├── exdate_syd_at_midnight_dst.js │ │ │ │ ├── exdate_syd_at_midnight_std.js │ │ │ │ ├── exdate_syd_before_midnight.js │ │ │ │ ├── fail-basic-auth.js │ │ │ │ ├── fullday_event_over_multiple_days_nonrepeating.js │ │ │ │ ├── fullday_until.js │ │ │ │ ├── germany_at_end_of_day_repeating.js │ │ │ │ ├── long-fullday-event.js │ │ │ │ ├── old-basic-auth.js │ │ │ │ ├── recurring.js │ │ │ │ ├── rrule_until.js │ │ │ │ ├── show-duplicates-in-calendar.js │ │ │ │ ├── single-fullday-event.js │ │ │ │ ├── sliceMultiDayEvents.js │ │ │ │ └── symboltest.js │ │ │ ├── clock/ │ │ │ │ ├── clock_12hr.js │ │ │ │ ├── clock_24hr.js │ │ │ │ ├── clock_analog.js │ │ │ │ ├── clock_displaySeconds_false.js │ │ │ │ ├── clock_showDateAnalog.js │ │ │ │ ├── clock_showPeriodUpper.js │ │ │ │ ├── clock_showSunMoon.js │ │ │ │ ├── clock_showSunNoEvent.js │ │ │ │ ├── clock_showTime.js │ │ │ │ ├── clock_showWeek.js │ │ │ │ ├── clock_showWeek_short.js │ │ │ │ ├── de/ │ │ │ │ │ ├── clock_showWeek.js │ │ │ │ │ └── clock_showWeek_short.js │ │ │ │ └── es/ │ │ │ │ ├── clock_12hr.js │ │ │ │ ├── clock_24hr.js │ │ │ │ ├── clock_showPeriodUpper.js │ │ │ │ ├── clock_showWeek.js │ │ │ │ └── clock_showWeek_short.js │ │ │ ├── compliments/ │ │ │ │ ├── compliments_animateCSS.js │ │ │ │ ├── compliments_animateCSS_fallbackToDefault.js │ │ │ │ ├── compliments_animateCSS_invertedAnimationName.js │ │ │ │ ├── compliments_anytime.js │ │ │ │ ├── compliments_cron_entry.js │ │ │ │ ├── compliments_date.js │ │ │ │ ├── compliments_e2e_cron_entry.js │ │ │ │ ├── compliments_evening.js │ │ │ │ ├── compliments_file.js │ │ │ │ ├── compliments_file_change.js │ │ │ │ ├── compliments_only_anytime.js │ │ │ │ ├── compliments_parts_day.js │ │ │ │ ├── compliments_remote.js │ │ │ │ ├── compliments_specialDayUnique_false.js │ │ │ │ └── compliments_specialDayUnique_true.js │ │ │ ├── display.js │ │ │ ├── helloworld/ │ │ │ │ ├── helloworld.js │ │ │ │ └── helloworld_default.js │ │ │ ├── newsfeed/ │ │ │ │ ├── default.js │ │ │ │ ├── ignore_items.js │ │ │ │ ├── incorrect_url.js │ │ │ │ └── prohibited_words.js │ │ │ ├── positions.js │ │ │ └── weather/ │ │ │ ├── currentweather_compliments.js │ │ │ ├── currentweather_default.js │ │ │ ├── currentweather_options.js │ │ │ ├── currentweather_units.js │ │ │ ├── forecastweather_absolute.js │ │ │ ├── forecastweather_default.js │ │ │ ├── forecastweather_options.js │ │ │ ├── forecastweather_units.js │ │ │ ├── hourlyweather_default.js │ │ │ ├── hourlyweather_options.js │ │ │ └── hourlyweather_showPrecipitation.js │ │ ├── noIpWhiteList.js │ │ ├── port_8090.js │ │ ├── port_variable.env │ │ ├── port_variable.js.template │ │ └── without_modules.js │ ├── e2e/ │ │ ├── animateCSS_spec.js │ │ ├── custom_module_regions_spec.js │ │ ├── env_spec.js │ │ ├── fonts_spec.js │ │ ├── helpers/ │ │ │ ├── basic-auth.js │ │ │ ├── global-setup.js │ │ │ └── weather-functions.js │ │ ├── ipWhitelist_spec.js │ │ ├── modules/ │ │ │ ├── alert_spec.js │ │ │ ├── calendar_spec.js │ │ │ ├── clock_de_spec.js │ │ │ ├── clock_es_spec.js │ │ │ ├── clock_spec.js │ │ │ ├── compliments_spec.js │ │ │ ├── helloworld_spec.js │ │ │ ├── newsfeed_spec.js │ │ │ ├── weather_current_spec.js │ │ │ ├── weather_forecast_spec.js │ │ │ └── weather_hourly_spec.js │ │ ├── modules_display_spec.js │ │ ├── modules_empty_spec.js │ │ ├── modules_position_spec.js │ │ ├── port_spec.js │ │ ├── serveronly_spec.js │ │ ├── template_spec.js │ │ ├── translations_spec.js │ │ └── vendor_spec.js │ ├── electron/ │ │ ├── env_spec.js │ │ ├── helpers/ │ │ │ ├── global-setup.js │ │ │ └── weather-setup.js │ │ └── modules/ │ │ ├── calendar_spec.js │ │ ├── compliments_spec.js │ │ └── weather_spec.js │ ├── mocks/ │ │ ├── 12_events.ics │ │ ├── 3_move_first_allday_repeating_event.ics │ │ ├── RepeatingEvent.Oct21.ics │ │ ├── bad_rrule.ics │ │ ├── calendar_duplicates_1.ics │ │ ├── calendar_duplicates_2.ics │ │ ├── calendar_test.ics │ │ ├── calendar_test_clone.ics │ │ ├── calendar_test_full_day_events.ics │ │ ├── calendar_test_icons.ics │ │ ├── calendar_test_multi_day_starting_today.ics │ │ ├── calendar_test_recurring.ics │ │ ├── chicago-nyedge.ics │ │ ├── chicago_late_in_timezone.ics │ │ ├── compliments_file.json │ │ ├── compliments_test.json │ │ ├── diff_tz_start_end.ics │ │ ├── end_of_day_berlin_moved.ics │ │ ├── event_with_time_over_multiple_days_non_repeating.ics │ │ ├── exdate_and_recurrence_together.ics │ │ ├── exdate_la_at_midnight_dst.ics │ │ ├── exdate_la_at_midnight_std.ics │ │ ├── exdate_la_before_midnight.ics │ │ ├── exdate_syd_at_midnight_dst.ics │ │ ├── exdate_syd_at_midnight_std.ics │ │ ├── exdate_syd_before_midnight.ics │ │ ├── fullday_event_over_multiple_days_nonrepeating.ics │ │ ├── fullday_until.ics │ │ ├── germany_at_end_of_day_repeating.ics │ │ ├── newsfeed_test.xml │ │ ├── rrule_until.ics │ │ ├── sliceMultiDayEvents.ics │ │ ├── testNotification/ │ │ │ └── testNotification.js │ │ ├── translation_test.json │ │ ├── weather_current.json │ │ ├── weather_forecast.json │ │ ├── weather_hourly.json │ │ └── whole_day_moved_over_dst_change_berlin.ics │ ├── unit/ │ │ ├── classes/ │ │ │ ├── class_spec.js │ │ │ ├── deprecated_spec.js │ │ │ ├── translator_spec.js │ │ │ └── utils_spec.js │ │ ├── functions/ │ │ │ ├── __snapshots__/ │ │ │ │ └── updatenotification_spec.js.snap │ │ │ ├── cmp_versions_spec.js │ │ │ ├── server_functions_spec.js │ │ │ └── updatenotification_spec.js │ │ ├── global_vars/ │ │ │ ├── defaults_modules_spec.js │ │ │ └── root_path_spec.js │ │ ├── helpers/ │ │ │ └── global-setup.js │ │ └── modules/ │ │ └── default/ │ │ ├── calendar/ │ │ │ ├── calendar_fetcher_utils_bad_rrule.js │ │ │ ├── calendar_fetcher_utils_spec.js │ │ │ └── calendar_utils_spec.js │ │ ├── compliments/ │ │ │ └── compliments_spec.js │ │ ├── utils_spec.js │ │ └── weather/ │ │ ├── weather_object_spec.js │ │ └── weather_utils_spec.js │ └── utils/ │ ├── vitest-setup.js │ └── weather_mocker.js ├── translations/ │ ├── af.json │ ├── ar.json │ ├── bg.json │ ├── ca.json │ ├── cs.json │ ├── cv.json │ ├── cy.json │ ├── da.json │ ├── de.json │ ├── el.json │ ├── en.json │ ├── eo.json │ ├── es.json │ ├── et.json │ ├── fi.json │ ├── fr.json │ ├── fy.json │ ├── gl.json │ ├── gu.json │ ├── he.json │ ├── hi.json │ ├── hr.json │ ├── hu.json │ ├── id.json │ ├── is.json │ ├── it.json │ ├── ja.json │ ├── ko.json │ ├── lt.json │ ├── ms-my.json │ ├── nb.json │ ├── nl.json │ ├── nn.json │ ├── pl.json │ ├── ps.json │ ├── pt-br.json │ ├── pt.json │ ├── ro.json │ ├── ru.json │ ├── sk.json │ ├── sv.json │ ├── th.json │ ├── tlh.json │ ├── tr.json │ ├── translations.js │ ├── uk.json │ ├── zh-cn.json │ └── zh-tw.json └── vitest.config.mjs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # editorconfig.org root = true [*] charset = utf-8 end_of_line = lf indent_size = 2 indent_style = space insert_final_newline = true max_line_length = 250 trim_trailing_whitespace = true [*.{js,json}] indent_size = 4 indent_style = tab ================================================ FILE: .gitattributes ================================================ # .gitattributes snippet to force users to use same line endings for project. # # Handle line endings automatically for files detected as text # and leave all files detected as binary untouched. * text=auto # # The above will handle all files NOT found below # https://help.github.com/articles/dealing-with-line-endings/ # https://github.com/Danimoth/gitattributes/blob/master/Web.gitattributes # These files are text and should be normalized (Convert crlf => lf) *.php text *.css text *.scss text *.js text *.json text *.htm text *.html text *.xml text *.txt text *.ini text *.inc text *.pl text *.rb text *.py text *.scm text *.sql text .htaccess text *.sh text Dockerfile* text *.yml text *.yaml text *.md text *.markdown text # These files are binary and should be left untouched # (binary is a macro for -text -diff) *.png binary *.jpg binary *.jpeg binary *.gif binary *.ico binary *.mov binary *.mp4 binary *.mp3 binary *.flv binary *.fla binary *.swf binary *.gz binary *.zip binary *.7z binary *.ttf binary *.pyc binary ================================================ FILE: .github/CODE_OF_CONDUCT.md ================================================ # 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 ================================================ FILE: .github/CONTRIBUTING.md ================================================ # Contribution Policy for MagicMirror² Thanks for contributing to MagicMirror²! We hold our code to standard, and these standards are documented below. ## Linters We use [prettier](https://prettier.io/) for automatic formatting a lot all our files. The configuration is in our `prettier.config.mjs` file. To run prettier, use `node --run lint:prettier`. ### JavaScript: Run ESLint We use [ESLint](https://eslint.org) to lint our JavaScript files. The configuration is in our `eslint.config.mjs` file. 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 `stylelint.config.mjs` file. To run StyleLint, use `node --run lint:css`. ### Markdown: Run markdownlint We use [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2) to lint our markdown files. The configuration is in our `.markdownlint.json` file. To run markdownlint, use `node --run lint:markdown`. ## Testing We use [Vitest](https://vitest.dev) for JavaScript testing. To run all tests, use `node --run test`. The `package.json` scripts expose finer-grained test commands: - `test:unit` – run unit tests only - `test:e2e` – execute browser-driven end-to-end tests - `test:electron` – launch the Electron-based regression suite - `test:coverage` – collect coverage while running every suite - `test:watch` – keep Vitest in watch mode for fast local feedback - `test:ui` – open the Vitest UI dashboard (needs OS file-watch support enabled) - `test:calendar` – run the legacy calendar debug helper - `test:css`, `test:markdown`, `test:prettier`, `test:spelling`, `test:js` – lint-only scripts that enforce formatting, spelling, markdown style, and ESLint. You can invoke any script with `node --run

================================================ FILE: jest.config.js ================================================ const aliasMapper = { logger: "/js/logger.js" }; const config = { verbose: true, testTimeout: 20000, testSequencer: "/tests/utils/test_sequencer.js", projects: [ { displayName: "unit", globalSetup: "/tests/unit/helpers/global-setup.js", moduleNameMapper: aliasMapper, testMatch: ["**/tests/unit/**/*.[jt]s?(x)"], testPathIgnorePatterns: ["/tests/unit/mocks", "/tests/unit/helpers"] }, { displayName: "electron", testMatch: ["**/tests/electron/**/*.[jt]s?(x)"], moduleNameMapper: aliasMapper, testPathIgnorePatterns: ["/tests/electron/helpers"] }, { displayName: "e2e", testMatch: ["**/tests/e2e/**/*.[jt]s?(x)"], modulePaths: ["/js/"], moduleNameMapper: aliasMapper, testPathIgnorePatterns: ["/tests/e2e/helpers", "/tests/e2e/mocks"] } ], collectCoverageFrom: [ "/clientonly/**/*.js", "/js/**/*.js", "/modules/default/**/*.js", "/serveronly/**/*.js" ], coverageReporters: ["lcov", "text"], coverageProvider: "v8" }; module.exports = config; ================================================ FILE: js/alias-resolver.js ================================================ // Internal alias mapping for default and 3rd party modules. // Provides short require identifiers: "logger" and "node_helper". // For a future ESM migration, replace this with a public export/import surface. const path = require("node:path"); const Module = require("module"); const root = path.join(__dirname, ".."); // Keep this list minimal; do not add new aliases without architectural review. const ALIASES = { logger: "js/logger.js", node_helper: "js/node_helper.js" }; // Resolve to absolute paths now. const resolved = Object.fromEntries( Object.entries(ALIASES).map(([k, rel]) => [k, path.join(root, rel)]) ); // Prevent multiple patching if this file is required more than once. if (!Module._mmAliasPatched) { const origResolveFilename = Module._resolveFilename; Module._resolveFilename = function (request, parent, isMain, options) { if (Object.prototype.hasOwnProperty.call(resolved, request)) { return resolved[request]; } return origResolveFilename.call(this, request, parent, isMain, options); }; Module._mmAliasPatched = true; // non-enumerable marker would be overkill here } ================================================ FILE: js/animateCSS.js ================================================ /* enumeration of animations in Array **/ const AnimateCSSIn = [ // Attention seekers "bounce", "flash", "pulse", "rubberBand", "shakeX", "shakeY", "headShake", "swing", "tada", "wobble", "jello", "heartBeat", // Back entrances "backInDown", "backInLeft", "backInRight", "backInUp", // Bouncing entrances "bounceIn", "bounceInDown", "bounceInLeft", "bounceInRight", "bounceInUp", // Fading entrances "fadeIn", "fadeInDown", "fadeInDownBig", "fadeInLeft", "fadeInLeftBig", "fadeInRight", "fadeInRightBig", "fadeInUp", "fadeInUpBig", "fadeInTopLeft", "fadeInTopRight", "fadeInBottomLeft", "fadeInBottomRight", // Flippers "flip", "flipInX", "flipInY", // Lightspeed "lightSpeedInRight", "lightSpeedInLeft", // Rotating entrances "rotateIn", "rotateInDownLeft", "rotateInDownRight", "rotateInUpLeft", "rotateInUpRight", // Specials "jackInTheBox", "rollIn", // Zooming entrances "zoomIn", "zoomInDown", "zoomInLeft", "zoomInRight", "zoomInUp", // Sliding entrances "slideInDown", "slideInLeft", "slideInRight", "slideInUp" ]; const AnimateCSSOut = [ // Back exits "backOutDown", "backOutLeft", "backOutRight", "backOutUp", // Bouncing exits "bounceOut", "bounceOutDown", "bounceOutLeft", "bounceOutRight", "bounceOutUp", // Fading exits "fadeOut", "fadeOutDown", "fadeOutDownBig", "fadeOutLeft", "fadeOutLeftBig", "fadeOutRight", "fadeOutRightBig", "fadeOutUp", "fadeOutUpBig", "fadeOutTopLeft", "fadeOutTopRight", "fadeOutBottomRight", "fadeOutBottomLeft", // Flippers "flipOutX", "flipOutY", // Lightspeed "lightSpeedOutRight", "lightSpeedOutLeft", // Rotating exits "rotateOut", "rotateOutDownLeft", "rotateOutDownRight", "rotateOutUpLeft", "rotateOutUpRight", // Specials "hinge", "rollOut", // Zooming exits "zoomOut", "zoomOutDown", "zoomOutLeft", "zoomOutRight", "zoomOutUp", // Sliding exits "slideOutDown", "slideOutLeft", "slideOutRight", "slideOutUp" ]; /** * Create an animation with Animate CSS * @param {string} [element] div element to animate. * @param {string} [animation] animation name. * @param {number} [animationTime] animation duration. */ function addAnimateCSS (element, animation, animationTime) { const animationName = `animate__${animation}`; const node = document.getElementById(element); if (!node) { // don't execute animate: we don't find div Log.warn("node not found for adding", element); return; } node.style.setProperty("--animate-duration", `${animationTime}s`); node.classList.add("animate__animated", animationName); } /** * Remove an animation with Animate CSS * @param {string} [element] div element to animate. * @param {string} [animation] animation name. */ function removeAnimateCSS (element, animation) { const animationName = `animate__${animation}`; const node = document.getElementById(element); if (!node) { // don't execute animate: we don't find div Log.warn("node not found for removing", element); return; } node.classList.remove("animate__animated", animationName); node.style.removeProperty("--animate-duration"); } if (typeof window === "undefined") module.exports = { AnimateCSSIn, AnimateCSSOut }; ================================================ FILE: js/app.js ================================================ // Load lightweight internal alias resolver require("./alias-resolver"); const fs = require("node:fs"); const path = require("node:path"); const envsub = require("envsub"); const Log = require("logger"); // global absolute root path global.root_path = path.resolve(`${__dirname}/../`); const Server = require(`${__dirname}/server`); const Utils = require(`${__dirname}/utils`); const defaultModules = require(`${global.root_path}/modules/default/defaultmodules`); // used to control fetch timeout for node_helpers const { setGlobalDispatcher, Agent } = require("undici"); const { getEnvVarsAsObj, getConfigFilePath } = require("#server_functions"); // common timeout value, provide environment override in case const fetch_timeout = process.env.mmFetchTimeout !== undefined ? process.env.mmFetchTimeout : 30000; // Get version number. global.version = require(`${global.root_path}/package.json`).version; global.mmTestMode = process.env.mmTestMode === "true"; Log.log(`Starting MagicMirror: v${global.version}`); // Log system information. Utils.logSystemInformation(global.version); if (process.env.MM_CONFIG_FILE) { global.configuration_file = process.env.MM_CONFIG_FILE.replace(`${global.root_path}/`, ""); } // FIXME: Hotfix Pull Request // https://github.com/MagicMirrorOrg/MagicMirror/pull/673 if (process.env.MM_PORT) { global.mmPort = process.env.MM_PORT; } // The next part is here to prevent a major exception when there // is no internet connection. This could probable be solved better. process.on("uncaughtException", function (err) { // ignore strange exceptions under aarch64 coming from systeminformation: if (!err.stack.includes("node_modules/systeminformation")) { Log.error("Whoops! There was an uncaught exception..."); Log.error(err); Log.error("MagicMirror² will not quit, but it might be a good idea to check why this happened. Maybe no internet connection?"); Log.error("If you think this really is an issue, please open an issue on GitHub: https://github.com/MagicMirrorOrg/MagicMirror/issues"); } }); /** * The core app. * @class */ function App () { let nodeHelpers = []; let httpServer; /** * Loads the config file. Combines it with the defaults and returns the config * @async * @returns {Promise} the loaded config or the defaults if something goes wrong */ async function loadConfig () { Log.log("Loading config ..."); const defaults = require(`${__dirname}/defaults`); if (global.mmTestMode) { // if we are running in test mode defaults.address = "0.0.0.0"; } // For this check proposed to TestSuite // https://forum.magicmirror.builders/topic/1456/test-suite-for-magicmirror/8 const configFilename = getConfigFilePath(); let templateFile = `${configFilename}.template`; // check if templateFile exists try { fs.accessSync(templateFile, fs.constants.F_OK); } catch (err) { templateFile = null; Log.log("config template file not exists, no envsubst"); } if (templateFile) { // save current config.js try { if (fs.existsSync(configFilename)) { fs.copyFileSync(configFilename, `${configFilename}-old`); } } catch (err) { Log.warn(`Could not copy ${configFilename}: ${err.message}`); } // check if config.env exists const envFiles = []; const configEnvFile = `${configFilename.substr(0, configFilename.lastIndexOf("."))}.env`; try { if (fs.existsSync(configEnvFile)) { envFiles.push(configEnvFile); } } catch (err) { Log.log(`${configEnvFile} does not exist. ${err.message}`); } let options = { all: true, diff: false, envFiles: envFiles, protect: false, syntax: "default", system: true }; // envsubst variables in templateFile and create new config.js // naming for envsub must be templateFile and outputFile const outputFile = configFilename; try { await envsub({ templateFile, outputFile, options }); } catch (err) { Log.error(`Could not envsubst variables: ${err.message}`); } } require(`${global.root_path}/js/check_config.js`); try { 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?"); } checkDeprecatedOptions(c); return Object.assign(defaults, c); } catch (e) { if (e.code === "ENOENT") { Log.error("WARNING! Could not find config file. Please create one. Starting with default configuration."); } else if (e instanceof ReferenceError || e instanceof SyntaxError) { Log.error(`WARNING! Could not validate config file. Starting with default configuration. Please correct syntax errors at or above this line: ${e.stack}`); } else { Log.error(`WARNING! Could not load config file. Starting with default configuration. Error found: ${e}`); } } return defaults; } /** * Checks the config for deprecated options and throws a warning in the logs * if it encounters one option from the deprecated.js list * @param {object} userConfig The user config */ function checkDeprecatedOptions (userConfig) { const deprecated = require(`${global.root_path}/js/deprecated`); // 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 option(s): ${usedDeprecated.join(", ")}. Check README and Documentation for more up-to-date ways of getting the same functionality.`); } // check for deprecated module options for (const element of userConfig.modules) { if (deprecated[element.module] !== undefined && element.config !== undefined) { const deprecatedModuleOptions = deprecated[element.module]; const usedDeprecatedModuleOptions = deprecatedModuleOptions.filter((option) => element.config.hasOwnProperty(option)); if (usedDeprecatedModuleOptions.length > 0) { Log.warn(`WARNING! Your config for module ${element.module} is using deprecated option(s): ${usedDeprecatedModuleOptions.join(", ")}. Check README and Documentation for more up-to-date ways of getting the same functionality.`); } } } } /** * Loads a specific module. * @param {string} module The name of the module (including subpath). */ function loadModule (module) { const elements = module.split("/"); const moduleName = elements[elements.length - 1]; const env = getEnvVarsAsObj(); let moduleFolder = path.resolve(`${global.root_path}/${env.modulesDir}`, module); if (defaultModules.includes(moduleName)) { const defaultModuleFolder = path.resolve(`${global.root_path}/modules/default/`, module); if (!global.mmTestMode) { moduleFolder = defaultModuleFolder; } else { // running in test mode, allow defaultModules placed under moduleDir for testing if (env.modulesDir === "modules" || env.modulesDir === "tests/mocks") { moduleFolder = defaultModuleFolder; } } } const moduleFile = `${moduleFolder}/${moduleName}.js`; try { fs.accessSync(moduleFile, fs.constants.R_OK); } catch (e) { Log.warn(`No ${moduleFile} found for module: ${moduleName}.`); } const helperPath = `${moduleFolder}/node_helper.js`; let loadHelper = true; try { fs.accessSync(helperPath, fs.constants.R_OK); } catch (e) { loadHelper = false; Log.log(`No helper found for module: ${moduleName}.`); } // if the helper was found if (loadHelper) { let Module; try { Module = require(helperPath); } catch (e) { Log.error(`Error when loading ${moduleName}:`, e.message); return; } let m = new Module(); if (m.requiresVersion) { Log.log(`Check MagicMirror² version for node helper '${moduleName}' - Minimum version: ${m.requiresVersion} - Current version: ${global.version}`); if (cmpVersions(global.version, m.requiresVersion) >= 0) { Log.log("Version is ok!"); } else { Log.warn(`Version is incorrect. Skip module: '${moduleName}'`); return; } } m.setName(moduleName); m.setPath(path.resolve(moduleFolder)); nodeHelpers.push(m); m.loaded(); } } /** * Loads all modules. * @param {Module[]} modules All modules to be loaded * @returns {Promise} A promise that is resolved when all modules been loaded */ async function loadModules (modules) { Log.log("Loading module helpers ..."); for (let module of modules) { await loadModule(module); } Log.log("All module helpers loaded."); } /** * Compare two semantic version numbers and return the difference. * @param {string} a Version number a. * @param {string} b Version number b. * @returns {number} A positive number if a is larger than b, a negative * number if a is smaller and 0 if they are the same */ function cmpVersions (a, b) { let i, diff; const regExStrip0 = /(\.0+)+$/; const segmentsA = a.replace(regExStrip0, "").split("."); const segmentsB = b.replace(regExStrip0, "").split("."); const l = Math.min(segmentsA.length, segmentsB.length); for (i = 0; i < l; i++) { diff = parseInt(segmentsA[i], 10) - parseInt(segmentsB[i], 10); if (diff) { return diff; } } return segmentsA.length - segmentsB.length; } /** * Start the core app. * * It loads the config, then it loads all modules. * @async * @returns {Promise} the config used */ this.start = async function () { config = await loadConfig(); Log.setLogLevel(config.logLevel); // get the used module positions Utils.getModulePositions(); let modules = []; for (const module of config.modules) { if (module.disabled) continue; if (module.module) { if (Utils.moduleHasValidPosition(module.position) || typeof (module.position) === "undefined") { // Only add this module to be loaded if it is not a duplicate (repeated instance of the same module) if (!modules.includes(module.module)) { modules.push(module.module); } } else { Log.warn("Invalid module position found for this configuration:" + `\n${JSON.stringify(module, null, 2)}`); } } else { Log.warn("No module name found for this configuration:" + `\n${JSON.stringify(module, null, 2)}`); } } setGlobalDispatcher(new Agent({ connect: { timeout: fetch_timeout } })); await loadModules(modules); httpServer = new Server(config); const { app, io } = await httpServer.open(); Log.log("Server started ..."); const nodePromises = []; for (let nodeHelper of nodeHelpers) { nodeHelper.setExpressApp(app); nodeHelper.setSocketIO(io); try { nodePromises.push(nodeHelper.start()); } catch (error) { Log.error(`Error when starting node_helper for module ${nodeHelper.name}:`); Log.error(error); } } const results = await Promise.allSettled(nodePromises); // Log errors that happened during async node_helper startup results.forEach((result) => { if (result.status === "rejected") { Log.error(result.reason); } }); Log.log("Sockets connected & modules started ..."); return config; }; /** * Stops the core app. This calls each node_helper's STOP() function, if it * exists. * * Added to fix #1056 * @returns {Promise} A promise that is resolved when all node_helpers and * the http server has been closed */ this.stop = async function () { const nodePromises = []; for (let nodeHelper of nodeHelpers) { try { if (typeof nodeHelper.stop === "function") { nodePromises.push(nodeHelper.stop()); } } catch (error) { Log.error(`Error when stopping node_helper for module ${nodeHelper.name}:`); Log.error(error); } } const results = await Promise.allSettled(nodePromises); // Log errors that happened during async node_helper stopping results.forEach((result) => { if (result.status === "rejected") { Log.error(result.reason); } }); Log.log("Node_helpers stopped ..."); // To be able to stop the app even if it hasn't been started (when // running with Electron against another server) if (!httpServer) { return Promise.resolve(); } return httpServer.close(); }; /** * Listen for SIGINT signal and call stop() function. * * Added to fix #1056 * Note: this is only used if running `server-only`. Otherwise * this.stop() is called by app.on("before-quit"... in `electron.js` */ process.on("SIGINT", async () => { Log.log("[SIGINT] Received. Shutting down server..."); setTimeout(() => { process.exit(0); }, 3000); // Force quit after 3 seconds await this.stop(); process.exit(0); }); /** * Listen to SIGTERM signals so we can stop everything when we * are asked to stop by the OS. */ process.on("SIGTERM", async () => { Log.log("[SIGTERM] Received. Shutting down server..."); setTimeout(() => { process.exit(0); }, 3000); // Force quit after 3 seconds await this.stop(); process.exit(0); }); } module.exports = new App(); ================================================ FILE: js/check_config.js ================================================ // Ensure internal require aliases (e.g., "logger") resolve when this file is run as a standalone script require("./alias-resolver"); const path = require("node:path"); const fs = require("node:fs"); const { styleText } = require("node:util"); const Ajv = require("ajv"); const globals = require("globals"); const { Linter } = require("eslint"); const Log = require("logger"); const rootPath = path.resolve(`${__dirname}/../`); const Utils = require(`${rootPath}/js/utils.js`); const linter = new Linter({ configType: "flat" }); const ajv = new Ajv(); /** * Returns a string with path of configuration file. * Check if set by environment variable MM_CONFIG_FILE * @returns {string} path and filename of the config file */ function getConfigFile () { // FIXME: This function should be in core. Do you want refactor me ;) ?, be good! return path.resolve(process.env.MM_CONFIG_FILE || `${rootPath}/config/config.js`); } /** * Checks the config file using eslint. */ function checkConfigFile () { const configFileName = getConfigFile(); // Check if file exists and is accessible try { fs.accessSync(configFileName, fs.constants.R_OK); } catch (error) { if (error.code === "ENOENT") { Log.error(`File not found: ${configFileName}`); } else if (error.code === "EACCES") { Log.error(`No permission to read config file: ${configFileName}`); } else { Log.error(`Cannot access config file: ${configFileName}\n${error.message}`); } process.exit(1); } // Validate syntax of the configuration file. Log.info(`Checking config file ${configFileName} ...`); // I'm not sure if all ever is utf-8 const configFile = fs.readFileSync(configFileName, "utf-8"); const errors = linter.verify( configFile, { languageOptions: { ecmaVersion: "latest", globals: { ...globals.browser, ...globals.node } }, rules: { "no-sparse-arrays": "error", "no-undef": "error" } }, configFileName ); if (errors.length === 0) { Log.info(styleText("green", "Your configuration file doesn't contain syntax errors :)")); validateModulePositions(configFileName); } else { let errorMessage = "Your configuration file contains syntax errors :("; for (const error of errors) { errorMessage += `\nLine ${error.line} column ${error.column}: ${error.message}`; } Log.error(errorMessage); process.exit(1); } } /** * * @param {string} configFileName - The path and filename of the configuration file to validate. */ function validateModulePositions (configFileName) { Log.info("Checking modules structure configuration ..."); const positionList = Utils.getModulePositions(); // Make Ajv schema configuration of modules config // Only scan "module" and "position" const schema = { type: "object", properties: { modules: { type: "array", items: { type: "object", properties: { module: { type: "string" }, position: { type: "string" } }, required: ["module"] } } } }; // Scan all modules const validate = ajv.compile(schema); const data = require(configFileName); const valid = validate(data); if (valid) { Log.info(styleText("green", "Your modules structure configuration doesn't contain errors :)")); // Check for unknown positions (warning only, not an error) if (data.modules) { for (const [index, module] of data.modules.entries()) { if (module.position && !positionList.includes(module.position)) { Log.warn(`Module ${index} ("${module.module}") uses unknown position: "${module.position}"`); Log.warn(`Known positions are: ${positionList.join(", ")}`); } } } } else { const module = validate.errors[0].instancePath.split("/")[2]; const position = validate.errors[0].instancePath.split("/")[3]; let errorMessage = "This module configuration contains errors:"; errorMessage += `\n${JSON.stringify(data.modules[module], null, 2)}`; if (position) { errorMessage += `\n${position}: ${validate.errors[0].message}`; errorMessage += `\n${JSON.stringify(validate.errors[0].params.allowedValues, null, 2).slice(1, -1)}`; } else { errorMessage += validate.errors[0].message; } Log.error(errorMessage); process.exit(1); } } try { checkConfigFile(); } catch (error) { const message = error && error.message ? error.message : error; Log.error(`Unexpected error: ${message}`); process.exit(1); } ================================================ FILE: js/class.js ================================================ /* global Class, xyz */ /* * Simple JavaScript Inheritance * By John Resig https://johnresig.com/ * * Inspired by base2 and Prototype * * MIT Licensed. */ (function () { let initializing = false; const fnTest = (/xyz/).test(function () { xyz; }) ? /\b_super\b/ : /.*/; // The base Class implementation (does nothing) this.Class = function () {}; // Create a new Class that inherits from this class Class.extend = function (prop) { let _super = this.prototype; /* * Instantiate a base class (but only create the instance, * don't run the init constructor) */ initializing = true; const prototype = new this(); initializing = false; // Make a copy of all prototype properties, to prevent reference issues. for (const p in prototype) { prototype[p] = cloneObject(prototype[p]); } // Copy the properties over onto the new prototype for (const name in prop) { // Check if we're overwriting an existing function prototype[name] = typeof prop[name] === "function" && typeof _super[name] === "function" && fnTest.test(prop[name]) ? (function (name, fn) { return function () { const tmp = this._super; /* * Add a new ._super() method that is the same method * but on the super-class */ this._super = _super[name]; /* * The method only need to be bound temporarily, so we * remove it when we're done executing */ const ret = fn.apply(this, arguments); this._super = tmp; return ret; }; }(name, prop[name])) : prop[name]; } /** * The dummy class constructor */ function Class () { // All construction is actually done in the init method if (!initializing && this.init) { this.init.apply(this, arguments); } } // Populate our constructed prototype object Class.prototype = prototype; // Enforce the constructor to be what we expect Class.prototype.constructor = Class; // And make this class extendable Class.extend = arguments.callee; return Class; }; }()); /** * Define the clone method for later use. Helper Method. * @param {object} obj Object to be cloned * @returns {object} the cloned object */ function cloneObject (obj) { if (obj === null || typeof obj !== "object") { return obj; } if (obj.constructor.name === "RegExp") { return new RegExp(obj); } const temp = obj.constructor(); // give temp the original obj's constructor for (const key in obj) { temp[key] = cloneObject(obj[key]); if (key === "lockStrings") { Log.log(key); } } return temp; } /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = Class; } ================================================ FILE: js/defaults.js ================================================ /* global mmPort */ const address = "localhost"; let port = 8080; if (typeof mmPort !== "undefined") { port = mmPort; } const defaults = { address: address, port: port, basePath: "/", kioskmode: false, electronOptions: {}, ipWhitelist: ["127.0.0.1", "::ffff:127.0.0.1", "::1"], language: "en", logLevel: ["INFO", "LOG", "WARN", "ERROR"], timeFormat: 24, units: "metric", zoom: 1, customCss: "css/custom.css", foreignModulesDir: "modules", // httpHeaders used by helmet, see https://helmetjs.github.io/. You can add other/more object values by overriding this in config.js, // e.g. you need to add `frameguard: false` for embedding MagicMirror in another website, see https://github.com/MagicMirrorOrg/MagicMirror/issues/2847 httpHeaders: { contentSecurityPolicy: false, crossOriginOpenerPolicy: false, crossOriginEmbedderPolicy: false, crossOriginResourcePolicy: false, originAgentCluster: false }, // properties for checking if server is alive and has same startup-timestamp, the check is per default enabled // (interval 30 seconds). If startup-timestamp has changed the client reloads the magicmirror webpage. checkServerInterval: 30 * 1000, reloadAfterServerRestart: false, modules: [ { module: "updatenotification", position: "top_center" }, { module: "helloworld", position: "upper_third", classes: "large thin", config: { text: "MagicMirror²" } }, { module: "helloworld", position: "middle_center", config: { text: "Please create a config file or check the existing one for errors." } }, { module: "helloworld", position: "middle_center", classes: "small dimmed", config: { text: "See README for more information." } }, { module: "helloworld", position: "middle_center", classes: "xsmall", config: { text: "If you get this message while your config file is already created,
" + "it probably contains an error. To validate your config file run in your MagicMirror² directory
" + "
node --run config:check
" } }, { module: "helloworld", position: "bottom_bar", classes: "xsmall dimmed", config: { text: "https://magicmirror.builders/" } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = defaults; } ================================================ FILE: js/deprecated.js ================================================ module.exports = { configs: ["kioskmode"], clock: ["secondsColor"] }; ================================================ FILE: js/electron.js ================================================ "use strict"; const electron = require("electron"); const core = require("./app"); const Log = require("./logger"); // Config let config = process.env.config ? JSON.parse(process.env.config) : {}; // Module to control application life. const app = electron.app; /* * Per default electron is started with --disable-gpu flag, if you want the gpu enabled, * you must set the env var ELECTRON_ENABLE_GPU=1 on startup. * See https://www.electronjs.org/docs/latest/tutorial/offscreen-rendering for more info. */ if (process.env.ELECTRON_ENABLE_GPU !== "1") { app.disableHardwareAcceleration(); } // Module to create native browser window. const BrowserWindow = electron.BrowserWindow; /* * Keep a global reference of the window object, if you don't, the window will * be closed automatically when the JavaScript object is garbage collected. */ let mainWindow; /** * */ function createWindow () { /* * see https://www.electronjs.org/docs/latest/api/screen * Create a window that fills the screen's available work area. */ let electronSize = (800, 600); try { electronSize = electron.screen.getPrimaryDisplay().workAreaSize; } catch { Log.warn("Could not get display size, using defaults ..."); } let electronSwitchesDefaults = ["autoplay-policy", "no-user-gesture-required"]; app.commandLine.appendSwitch(...new Set(electronSwitchesDefaults, config.electronSwitches)); let electronOptionsDefaults = { width: electronSize.width, height: electronSize.height, icon: "mm2.png", x: 0, y: 0, darkTheme: true, webPreferences: { contextIsolation: true, nodeIntegration: false, zoomFactor: config.zoom }, backgroundColor: "#000000" }; /* * DEPRECATED: "kioskmode" backwards compatibility, to be removed * settings these options directly instead provides cleaner interface */ if (config.kioskmode) { electronOptionsDefaults.kiosk = true; } else { electronOptionsDefaults.show = false; electronOptionsDefaults.frame = false; electronOptionsDefaults.transparent = true; electronOptionsDefaults.hasShadow = false; electronOptionsDefaults.fullscreen = true; } const electronOptions = Object.assign({}, electronOptionsDefaults, config.electronOptions); if (process.env.MOCK_DATE !== undefined) { // if we are running tests and we want to mock the current date const fakeNow = new Date(process.env.MOCK_DATE).valueOf(); Date = class extends Date { constructor (...args) { if (args.length === 0) { super(fakeNow); } else { super(...args); } } }; const __DateNowOffset = fakeNow - Date.now(); const __DateNow = Date.now; Date.now = () => __DateNow() + __DateNowOffset; } // Create the browser window. mainWindow = new BrowserWindow(electronOptions); /* * and load the index.html of the app. * If config.address is not defined or is an empty string (listening on all interfaces), connect to localhost */ let prefix; if ((config.tls !== null && config.tls) || config.useHttps) { prefix = "https://"; } else { prefix = "http://"; } let address = (config.address === void 0) | (config.address === "") | (config.address === "0.0.0.0") ? (config.address = "localhost") : config.address; const port = process.env.MM_PORT || config.port; mainWindow.loadURL(`${prefix}${address}:${port}`); // Open the DevTools if run with "node --run start:dev" if (process.argv.includes("dev")) { if (process.env.mmTestMode) { // if we are running tests const devtools = new BrowserWindow(electronOptions); mainWindow.webContents.setDevToolsWebContents(devtools.webContents); } mainWindow.webContents.openDevTools(); } // simulate mouse move to hide black cursor on start mainWindow.webContents.on("dom-ready", (event) => { mainWindow.webContents.sendInputEvent({ type: "mouseMove", x: 0, y: 0 }); }); // Set responders for window events. mainWindow.on("closed", function () { mainWindow = null; }); if (config.kioskmode) { mainWindow.on("blur", function () { mainWindow.focus(); }); mainWindow.on("leave-full-screen", function () { mainWindow.setFullScreen(true); }); mainWindow.on("resize", function () { setTimeout(function () { mainWindow.reload(); }, 1000); }); } //remove response headers that prevent sites of being embedded into iframes if configured mainWindow.webContents.session.webRequest.onHeadersReceived((details, callback) => { let curHeaders = details.responseHeaders; if (config.ignoreXOriginHeader || false) { curHeaders = Object.fromEntries(Object.entries(curHeaders).filter((header) => !(/x-frame-options/i).test(header[0]))); } if (config.ignoreContentSecurityPolicy || false) { curHeaders = Object.fromEntries(Object.entries(curHeaders).filter((header) => !(/content-security-policy/i).test(header[0]))); } callback({ responseHeaders: curHeaders }); }); mainWindow.once("ready-to-show", () => { mainWindow.show(); }); } // Quit when all windows are closed. app.on("window-all-closed", function () { if (process.env.mmTestMode) { // if we are running tests app.quit(); } else { createWindow(); } }); app.on("activate", function () { /* * On OS X it's common to re-create a window in the app when the * dock icon is clicked and there are no other windows open. */ if (mainWindow === null) { createWindow(); } }); /* * This method will be called when SIGINT is received and will call * each node_helper's stop function if it exists. Added to fix #1056 * * Note: this is only used if running Electron. Otherwise * core.stop() is called by process.on("SIGINT"... in `app.js` */ app.on("before-quit", async (event) => { Log.log("Shutting down server..."); event.preventDefault(); setTimeout(() => { process.exit(0); }, 3000); // Force-quit after 3 seconds. await core.stop(); process.exit(0); }); /** * Handle errors from self-signed certificates */ app.on("certificate-error", (event, webContents, url, error, certificate, callback) => { event.preventDefault(); callback(true); }); if (process.env.clientonly) { app.whenReady().then(() => { Log.log("Launching client viewer application."); createWindow(); }); } /* * Start the core application if server is run on localhost * This starts all node helpers and starts the webserver. */ if (["localhost", "127.0.0.1", "::1", "::ffff:127.0.0.1", undefined].includes(config.address)) { core.start().then((c) => { config = c; app.whenReady().then(() => { Log.log("Launching application."); createWindow(); }); }); } ================================================ FILE: js/ip_access_control.js ================================================ const ipaddr = require("ipaddr.js"); const Log = require("logger"); /** * Checks if a client IP matches any entry in the whitelist * @param {string} clientIp - The IP address to check * @param {string[]} whitelist - Array of IP addresses or CIDR ranges * @returns {boolean} True if IP is allowed */ function isAllowed (clientIp, whitelist) { try { const addr = ipaddr.process(clientIp); return whitelist.some((entry) => { try { // CIDR notation if (entry.includes("/")) { const [rangeAddr, prefixLen] = ipaddr.parseCIDR(entry); return addr.match(rangeAddr, prefixLen); } // Single IP address - let ipaddr.process normalize both const allowedAddr = ipaddr.process(entry); return addr.toString() === allowedAddr.toString(); } catch (err) { Log.warn(`Invalid whitelist entry: ${entry}`); return false; } }); } catch (err) { Log.warn(`Failed to parse client IP: ${clientIp}`); return false; } } /** * Creates an Express middleware for IP whitelisting * @param {string[]} whitelist - Array of allowed IP addresses or CIDR ranges * @returns {import("express").RequestHandler} Express middleware function */ function ipAccessControl (whitelist) { // Empty whitelist means allow all if (!Array.isArray(whitelist) || whitelist.length === 0) { return function (req, res, next) { res.header("Access-Control-Allow-Origin", "*"); next(); }; } return function (req, res, next) { const clientIp = req.ip || req.socket.remoteAddress; if (isAllowed(clientIp, whitelist)) { res.header("Access-Control-Allow-Origin", "*"); next(); } else { Log.log(`IP ${clientIp} is not allowed to access the mirror`); res.status(403).send("This device is not allowed to access your mirror.
Please check your config.js or config.js.sample to change this."); } }; } module.exports = { ipAccessControl }; ================================================ FILE: js/loader.js ================================================ /* global defaultModules, vendor */ const Loader = (function () { /* Create helper variables */ const loadedModuleFiles = []; const loadedFiles = []; const moduleObjects = []; /* Private Methods */ /** * Get environment variables from config. * @returns {object} Env vars with modulesDir and customCss paths from config. */ const getEnvVarsFromConfig = function () { return { modulesDir: config.foreignModulesDir || "modules", customCss: config.customCss || "css/custom.css" }; }; /** * Retrieve object of env variables. * @returns {object} with key: values as assembled in js/server_functions.js */ const getEnvVars = async function () { // In test mode, skip server fetch and use config values directly if (typeof process !== "undefined" && process.env && process.env.mmTestMode === "true") { return getEnvVarsFromConfig(); } // In production, fetch env vars from server try { const res = await fetch(new URL("env", `${location.origin}${config.basePath}`)); return JSON.parse(await res.text()); } catch (error) { // Fallback to config values if server fetch fails Log.error("Unable to retrieve env configuration", error); return getEnvVarsFromConfig(); } }; /** * Loops through all modules and requests start for every module. */ const startModules = async function () { const modulePromises = []; for (const module of moduleObjects) { try { modulePromises.push(module.start()); } catch (error) { Log.error(`Error when starting node_helper for module ${module.name}:`); Log.error(error); } } const results = await Promise.allSettled(modulePromises); // Log errors that happened during async node_helper startup results.forEach((result) => { if (result.status === "rejected") { Log.error(result.reason); } }); // Notify core of loaded modules. MM.modulesStarted(moduleObjects); // Starting modules also hides any modules that have requested to be initially hidden for (const thisModule of moduleObjects) { if (thisModule.data.hiddenOnStartup) { Log.info(`Initially hiding ${thisModule.name}`); thisModule.hide(); } } }; /** * Retrieve list of all modules. * @returns {object[]} module data as configured in config */ const getAllModules = function () { const AllModules = config.modules.filter((module) => (module.module !== undefined) && (MM.getAvailableModulePositions.indexOf(module.position) > -1 || typeof (module.position) === "undefined")); return AllModules; }; /** * Generate array with module information including module paths. * @returns {object[]} Module information. */ const getModuleData = async function () { const modules = getAllModules(); const moduleFiles = []; const envVars = await getEnvVars(); modules.forEach(function (moduleData, index) { const module = moduleData.module; const elements = module.split("/"); const moduleName = elements[elements.length - 1]; let moduleFolder = `${envVars.modulesDir}/${module}`; if (defaultModules.indexOf(moduleName) !== -1) { const defaultModuleFolder = `modules/default/${module}`; if (window.name !== "jsdom") { moduleFolder = defaultModuleFolder; } else { // running in test mode, allow defaultModules placed under moduleDir for testing if (envVars.modulesDir === "modules") { moduleFolder = defaultModuleFolder; } } } if (moduleData.disabled === true) { return; } moduleFiles.push({ index: index, identifier: `module_${index}_${module}`, name: moduleName, path: `${moduleFolder}/`, file: `${moduleName}.js`, position: moduleData.position, animateIn: moduleData.animateIn, animateOut: moduleData.animateOut, hiddenOnStartup: moduleData.hiddenOnStartup, header: moduleData.header, configDeepMerge: typeof moduleData.configDeepMerge === "boolean" ? moduleData.configDeepMerge : false, config: moduleData.config, classes: typeof moduleData.classes !== "undefined" ? `${moduleData.classes} ${module}` : module, order: (typeof moduleData.order === "number" && Number.isInteger(moduleData.order)) ? moduleData.order : 0 }); }); return moduleFiles; }; /** * Load modules via ajax request and create module objects. * @param {object} module Information about the module we want to load. * @returns {Promise} resolved when module is loaded */ const loadModule = async function (module) { const url = module.path + module.file; /** * @returns {Promise} */ const afterLoad = async function () { const moduleObject = Module.create(module.name); if (moduleObject) { await bootstrapModule(module, moduleObject); } }; if (loadedModuleFiles.indexOf(url) !== -1) { await afterLoad(); } else { await loadFile(url); loadedModuleFiles.push(url); await afterLoad(); } }; /** * Bootstrap modules by setting the module data and loading the scripts & styles. * @param {object} module Information about the module we want to load. * @param {Module} mObj Modules instance. */ const bootstrapModule = async function (module, mObj) { Log.info(`Bootstrapping module: ${module.name}`); mObj.setData(module); await mObj.loadScripts(); Log.log(`Scripts loaded for: ${module.name}`); await mObj.loadStyles(); Log.log(`Styles loaded for: ${module.name}`); await mObj.loadTranslations(); Log.log(`Translations loaded for: ${module.name}`); moduleObjects.push(mObj); }; /** * Load a script or stylesheet by adding it to the dom. * @param {string} fileName Path of the file we want to load. * @returns {Promise} resolved when the file is loaded */ const loadFile = async function (fileName) { const extension = fileName.slice((Math.max(0, fileName.lastIndexOf(".")) || Infinity) + 1); let script, stylesheet; switch (extension.toLowerCase()) { case "js": return new Promise((resolve) => { Log.log(`Load script: ${fileName}`); script = document.createElement("script"); script.type = "text/javascript"; script.src = fileName; script.onload = function () { resolve(); }; script.onerror = function () { Log.error("Error on loading script:", fileName); script.remove(); resolve(); }; document.getElementsByTagName("body")[0].appendChild(script); }); case "css": return new Promise((resolve) => { Log.log(`Load stylesheet: ${fileName}`); stylesheet = document.createElement("link"); stylesheet.rel = "stylesheet"; stylesheet.type = "text/css"; stylesheet.href = fileName; stylesheet.onload = function () { resolve(); }; stylesheet.onerror = function () { Log.error("Error on loading stylesheet:", fileName); stylesheet.remove(); resolve(); }; document.getElementsByTagName("head")[0].appendChild(stylesheet); }); } }; /* Public Methods */ return { /** * Load all modules as defined in the config. */ async loadModules () { const moduleData = await getModuleData(); const envVars = await getEnvVars(); const customCss = envVars.customCss; // 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(); }, /** * Load a file (script or stylesheet). * Prevent double loading and search for files in the vendor folder. * @param {string} fileName Path of the file we want to load. * @param {Module} module The module that calls the loadFile function. * @returns {Promise} resolved when the file is loaded */ async loadFileForModule (fileName, module) { if (loadedFiles.indexOf(fileName.toLowerCase()) !== -1) { Log.log(`File already loaded: ${fileName}`); return; } if (fileName.indexOf("http://") === 0 || fileName.indexOf("https://") === 0 || fileName.indexOf("/") !== -1) { // This is an absolute or relative path. // Load it and then return. loadedFiles.push(fileName.toLowerCase()); return loadFile(fileName); } if (vendor[fileName] !== undefined) { // This file is available in the vendor folder. // Load it from this vendor folder. loadedFiles.push(fileName.toLowerCase()); return loadFile(`${vendor[fileName]}`); } // File not loaded yet. // Load it based on the module path. loadedFiles.push(fileName.toLowerCase()); return loadFile(module.file(fileName)); } }; }()); ================================================ FILE: js/logger.js ================================================ // This logger is very simple, but needs to be extended. (function (root, factory) { if (typeof exports === "object") { if (process.env.mmTestMode !== "true") { const { styleText } = require("node:util"); // add timestamps in front of log messages require("console-stamp")(console, { format: ":date(yyyy-mm-dd HH:MM:ss.l) :label(7) :pre() :msg", tokens: { pre: () => { const err = new Error(); Error.prepareStackTrace = (_, stack) => stack; const stack = err.stack; Error.prepareStackTrace = undefined; try { for (const line of stack) { const file = line.getFileName(); if (file && !file.includes("node:") && !file.includes("js/logger.js") && !file.includes("node_modules")) { const filename = file.replace(/.*\/(.*).js/, "$1"); const filepath = file.replace(/.*\/(.*)\/.*.js/, "$1"); if (filepath === "js") { return styleText("grey", `[${filename}]`); } else { return styleText("grey", `[${filepath}]`); } } } } catch (err) { return styleText("grey", "[unknown]"); } }, label: (arg) => { const { method, defaultTokens } = arg; let label = defaultTokens.label(arg); 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); 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; } } }); } // Node, CommonJS-like module.exports = factory(root.config); } else { // Browser globals (root is window) root.Log = factory(root.config); } }(this, function (config) { let logLevel; let enableLog; if (typeof exports === "object") { // in nodejs and not running in test mode enableLog = process.env.mmTestMode !== "true"; } else { // in browser and not running with jsdom enableLog = typeof window === "object" && window.name !== "jsdom"; } if (enableLog) { logLevel = { debug: Function.prototype.bind.call(console.debug, console), log: Function.prototype.bind.call(console.log, console), info: Function.prototype.bind.call(console.info, console), warn: Function.prototype.bind.call(console.warn, console), error: Function.prototype.bind.call(console.error, console), group: Function.prototype.bind.call(console.group, console), groupCollapsed: Function.prototype.bind.call(console.groupCollapsed, console), groupEnd: Function.prototype.bind.call(console.groupEnd, console), time: Function.prototype.bind.call(console.time, console), timeEnd: Function.prototype.bind.call(console.timeEnd, console), timeStamp: console.timeStamp ? Function.prototype.bind.call(console.timeStamp, console) : function () {} }; logLevel.setLogLevel = function (newLevel) { if (newLevel) { Object.keys(logLevel).forEach(function (key) { if (!newLevel.includes(key.toLocaleUpperCase())) { logLevel[key] = function () {}; } }); } }; } else { logLevel = { debug () {}, log () {}, info () {}, warn () {}, error () {}, group () {}, groupCollapsed () {}, groupEnd () {}, time () {}, timeEnd () {}, timeStamp () {} }; logLevel.setLogLevel = function () {}; } return logLevel; })); ================================================ FILE: js/main.js ================================================ /* global Loader, defaults, Translator, addAnimateCSS, removeAnimateCSS, AnimateCSSIn, AnimateCSSOut, modulePositions, io */ const MM = (function () { let modules = []; /* Private Methods */ /** * Create dom objects for all modules that are configured for a specific position. */ const createDomObjects = function () { const domCreationPromises = []; modules.forEach(function (module) { if (typeof module.data.position !== "string") { return; } let haveAnimateIn = null; // check if have valid animateIn in module definition (module.data.animateIn) if (module.data.animateIn && AnimateCSSIn.indexOf(module.data.animateIn) !== -1) haveAnimateIn = module.data.animateIn; const wrapper = selectWrapper(module.data.position); const dom = document.createElement("div"); dom.id = module.identifier; dom.className = module.name; if (typeof module.data.classes === "string") { 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); const moduleHeader = document.createElement("header"); moduleHeader.innerHTML = module.getHeader(); moduleHeader.className = "module-header"; dom.appendChild(moduleHeader); if (typeof module.getHeader() === "undefined" || module.getHeader() !== "") { moduleHeader.style.display = "none;"; } else { moduleHeader.style.display = "block;"; } const moduleContent = document.createElement("div"); moduleContent.className = "module-content"; dom.appendChild(moduleContent); // create the domCreationPromise with AnimateCSS (with animateIn of module definition) // or just display it var domCreationPromise; if (haveAnimateIn) domCreationPromise = updateDom(module, { options: { speed: 1000, animate: { in: haveAnimateIn } } }, true); else domCreationPromise = updateDom(module, 0); domCreationPromises.push(domCreationPromise); domCreationPromise .then(function () { sendNotification("MODULE_DOM_CREATED", null, null, module); }) .catch(Log.error); }); updateWrapperStates(); Promise.all(domCreationPromises).then(function () { sendNotification("DOM_OBJECTS_CREATED"); }); }; /** * Select the wrapper dom object for a specific position. * @param {string} position The name of the position. * @returns {HTMLElement | void} the wrapper element */ const selectWrapper = function (position) { const classes = position.replace("_", " "); const parentWrapper = document.getElementsByClassName(classes); if (parentWrapper.length > 0) { const wrapper = parentWrapper[0].getElementsByClassName("container"); if (wrapper.length > 0) { return wrapper[0]; } } }; /** * Send a notification to all modules. * @param {string} notification The identifier of the notification. * @param {object} payload The payload of the notification. * @param {Module} sender The module that sent the notification. * @param {Module} [sendTo] The (optional) module to send the notification to. */ const sendNotification = function (notification, payload, sender, sendTo) { for (const m in modules) { const module = modules[m]; if (module !== sender && (!sendTo || module === sendTo)) { module.notificationReceived(notification, payload, sender); } } }; /** * Update the dom for a specific module. * @param {Module} module The module that needs an update. * @param {object|number} [updateOptions] The (optional) number of microseconds for the animation or object with updateOptions (speed/animates) * @param {boolean} [createAnimatedDom] for displaying only animateIn (used on first start of MagicMirror) * @returns {Promise} Resolved when the dom is fully updated. */ const updateDom = function (module, updateOptions, createAnimatedDom = false) { return new Promise(function (resolve) { let speed = updateOptions; let animateOut = null; let animateIn = null; if (typeof updateOptions === "object") { if (typeof updateOptions.options === "object" && updateOptions.options.speed !== undefined) { speed = updateOptions.options.speed; Log.debug(`updateDom: ${module.identifier} Has speed in object: ${speed}`); if (typeof updateOptions.options.animate === "object") { animateOut = updateOptions.options.animate.out; animateIn = updateOptions.options.animate.in; Log.debug(`updateDom: ${module.identifier} Has animate in object: out->${animateOut}, in->${animateIn}`); } } else { Log.debug(`updateDom: ${module.identifier} Has no speed in object`); speed = 0; } } const newHeader = module.getHeader(); let newContentPromise = module.getDom(); if (!(newContentPromise instanceof Promise)) { // convert to a promise if not already one to avoid if/else's everywhere newContentPromise = Promise.resolve(newContentPromise); } newContentPromise .then(function (newContent) { const updatePromise = updateDomWithContent(module, speed, newHeader, newContent, animateOut, animateIn, createAnimatedDom); updatePromise.then(resolve).catch(Log.error); }) .catch(Log.error); }); }; /** * Update the dom with the specified content * @param {Module} module The module that needs an update. * @param {number} [speed] The (optional) number of microseconds for the animation. * @param {string} newHeader The new header that is generated. * @param {HTMLElement} newContent The new content that is generated. * @param {string} [animateOut] AnimateCss animation name before hidden * @param {string} [animateIn] AnimateCss animation name on show * @param {boolean} [createAnimatedDom] for displaying only animateIn (used on first start) * @returns {Promise} Resolved when the module dom has been updated. */ const updateDomWithContent = function (module, speed, newHeader, newContent, animateOut, animateIn, createAnimatedDom = false) { return new Promise(function (resolve) { if (module.hidden || !speed) { updateModuleContent(module, newHeader, newContent); resolve(); return; } if (!moduleNeedsUpdate(module, newHeader, newContent)) { resolve(); return; } if (!speed) { updateModuleContent(module, newHeader, newContent); resolve(); return; } if (createAnimatedDom && animateIn !== null) { Log.debug(`${module.identifier} createAnimatedDom (${animateIn})`); updateModuleContent(module, newHeader, newContent); if (!module.hidden) { showModule(module, speed, null, { animate: animateIn }); } resolve(); return; } hideModule( module, speed / 2, function () { updateModuleContent(module, newHeader, newContent); if (!module.hidden) { showModule(module, speed / 2, null, { animate: animateIn }); } resolve(); }, { animate: animateOut } ); }); }; /** * Check if the content has changed. * @param {Module} module The module to check. * @param {string} newHeader The new header that is generated. * @param {HTMLElement} newContent The new content that is generated. * @returns {boolean} True if the module need an update, false otherwise */ const moduleNeedsUpdate = function (module, newHeader, newContent) { const moduleWrapper = document.getElementById(module.identifier); if (moduleWrapper === null) { return false; } const contentWrapper = moduleWrapper.getElementsByClassName("module-content"); const headerWrapper = moduleWrapper.getElementsByClassName("module-header"); let headerNeedsUpdate = false; let contentNeedsUpdate; if (headerWrapper.length > 0) { headerNeedsUpdate = newHeader !== headerWrapper[0].innerHTML; } const tempContentWrapper = document.createElement("div"); tempContentWrapper.appendChild(newContent); contentNeedsUpdate = tempContentWrapper.innerHTML !== contentWrapper[0].innerHTML; return headerNeedsUpdate || contentNeedsUpdate; }; /** * Update the content of a module on screen. * @param {Module} module The module to check. * @param {string} newHeader The new header that is generated. * @param {HTMLElement} newContent The new content that is generated. */ const updateModuleContent = function (module, newHeader, newContent) { const moduleWrapper = document.getElementById(module.identifier); if (moduleWrapper === null) { return; } const headerWrapper = moduleWrapper.getElementsByClassName("module-header"); const contentWrapper = moduleWrapper.getElementsByClassName("module-content"); contentWrapper[0].innerHTML = ""; contentWrapper[0].appendChild(newContent); headerWrapper[0].innerHTML = newHeader; if (headerWrapper.length > 0 && newHeader) { headerWrapper[0].style.display = "block"; } else { headerWrapper[0].style.display = "none"; } }; /** * Hide the module. * @param {Module} module The module to hide. * @param {number} speed The speed of the hide animation. * @param {Promise} callback Called when the animation is done. * @param {object} [options] Optional settings for the hide method. */ const hideModule = function (module, speed, callback, options = {}) { // set lockString if set in options. if (options.lockString) { if (module.lockStrings.indexOf(options.lockString) === -1) { module.lockStrings.push(options.lockString); } } const moduleWrapper = document.getElementById(module.identifier); if (moduleWrapper !== null) { clearTimeout(module.showHideTimer); // reset all animations if needed if (module.hasAnimateOut) { removeAnimateCSS(module.identifier, module.hasAnimateOut); Log.debug(`${module.identifier} Force remove animateOut (in hide): ${module.hasAnimateOut}`); module.hasAnimateOut = false; } if (module.hasAnimateIn) { removeAnimateCSS(module.identifier, module.hasAnimateIn); Log.debug(`${module.identifier} Force remove animateIn (in hide): ${module.hasAnimateIn}`); module.hasAnimateIn = false; } // haveAnimateName for verify if we are using AnimateCSS library // we check AnimateCSSOut Array for validate it // and finally return the animate name or `null` (for default MM² animation) let haveAnimateName = null; // check if have valid animateOut in module definition (module.data.animateOut) if (module.data.animateOut && AnimateCSSOut.indexOf(module.data.animateOut) !== -1) haveAnimateName = module.data.animateOut; // can't be override with options.animate else if (options.animate && AnimateCSSOut.indexOf(options.animate) !== -1) haveAnimateName = options.animate; if (haveAnimateName) { // with AnimateCSS Log.debug(`${module.identifier} Has animateOut: ${haveAnimateName}`); module.hasAnimateOut = haveAnimateName; addAnimateCSS(module.identifier, haveAnimateName, speed / 1000); module.showHideTimer = setTimeout(function () { removeAnimateCSS(module.identifier, haveAnimateName); Log.debug(`${module.identifier} Remove animateOut: ${module.hasAnimateOut}`); // AnimateCSS is now done moduleWrapper.style.opacity = 0; moduleWrapper.classList.add("hidden"); moduleWrapper.style.position = "fixed"; module.hasAnimateOut = false; updateWrapperStates(); if (typeof callback === "function") { callback(); } }, speed); } else { // default MM² Animate moduleWrapper.style.transition = `opacity ${speed / 1000}s`; moduleWrapper.style.opacity = 0; moduleWrapper.classList.add("hidden"); module.showHideTimer = setTimeout(function () { // To not take up any space, we just make the position absolute. // since it's fade out anyway, we can see it lay above or // below other modules. This works way better than adjusting // the .display property. moduleWrapper.style.position = "fixed"; updateWrapperStates(); if (typeof callback === "function") { callback(); } }, speed); } } else { // invoke callback even if no content, issue 1308 if (typeof callback === "function") { callback(); } } }; /** * Show the module. * @param {Module} module The module to show. * @param {number} speed The speed of the show animation. * @param {Promise} callback Called when the animation is done. * @param {object} [options] Optional settings for the show method. */ const showModule = function (module, speed, callback, options = {}) { // remove lockString if set in options. if (options.lockString) { const index = module.lockStrings.indexOf(options.lockString); if (index !== -1) { module.lockStrings.splice(index, 1); } } // Check if there are no more lockStrings set, or the force option is set. // Otherwise cancel show action. if (module.lockStrings.length !== 0 && options.force !== true) { Log.log(`Will not show ${module.name}. LockStrings active: ${module.lockStrings.join(",")}`); if (typeof options.onError === "function") { options.onError(new Error("LOCK_STRING_ACTIVE")); } return; } // reset all animations if needed if (module.hasAnimateOut) { removeAnimateCSS(module.identifier, module.hasAnimateOut); Log.debug(`${module.identifier} Force remove animateOut (in show): ${module.hasAnimateOut}`); module.hasAnimateOut = false; } if (module.hasAnimateIn) { removeAnimateCSS(module.identifier, module.hasAnimateIn); Log.debug(`${module.identifier} Force remove animateIn (in show): ${module.hasAnimateIn}`); module.hasAnimateIn = false; } module.hidden = false; // If forced show, clean current lockStrings. if (module.lockStrings.length !== 0 && options.force === true) { Log.log(`Force show of module: ${module.name}`); module.lockStrings = []; } const moduleWrapper = document.getElementById(module.identifier); if (moduleWrapper !== null) { clearTimeout(module.showHideTimer); // haveAnimateName for verify if we are using AnimateCSS library // we check AnimateCSSIn Array for validate it // and finally return the animate name or `null` (for default MM² animation) let haveAnimateName = null; // check if have valid animateOut in module definition (module.data.animateIn) if (module.data.animateIn && AnimateCSSIn.indexOf(module.data.animateIn) !== -1) haveAnimateName = module.data.animateIn; // can't be override with options.animate else if (options.animate && AnimateCSSIn.indexOf(options.animate) !== -1) haveAnimateName = options.animate; if (!haveAnimateName) moduleWrapper.style.transition = `opacity ${speed / 1000}s`; // Restore the position. See hideModule() for more info. moduleWrapper.style.position = "static"; moduleWrapper.classList.remove("hidden"); updateWrapperStates(); // Waiting for DOM-changes done in updateWrapperStates before we can start the animation. const dummy = moduleWrapper.parentElement.parentElement.offsetHeight; moduleWrapper.style.opacity = 1; if (haveAnimateName) { // with AnimateCSS Log.debug(`${module.identifier} Has animateIn: ${haveAnimateName}`); module.hasAnimateIn = haveAnimateName; addAnimateCSS(module.identifier, haveAnimateName, speed / 1000); module.showHideTimer = setTimeout(function () { removeAnimateCSS(module.identifier, haveAnimateName); Log.debug(`${module.identifier} Remove animateIn: ${haveAnimateName}`); module.hasAnimateIn = false; if (typeof callback === "function") { callback(); } }, speed); } else { // default MM² Animate module.showHideTimer = setTimeout(function () { if (typeof callback === "function") { callback(); } }, speed); } } else { // invoke callback if (typeof callback === "function") { callback(); } } }; /** * Checks for all positions if it has visible content. * If not, if will hide the position to prevent unwanted margins. * This method should be called by the show and hide methods. * * Example: * If the top_bar only contains the update notification. And no update is available, * the update notification is hidden. The top bar still occupies space making for * an ugly top margin. By using this function, the top bar will be hidden if the * update notification is not visible. */ const updateWrapperStates = function () { modulePositions.forEach(function (position) { const wrapper = selectWrapper(position); const moduleWrappers = wrapper.getElementsByClassName("module"); let showWrapper = false; Array.prototype.forEach.call(moduleWrappers, function (moduleWrapper) { if (moduleWrapper.style.position === "" || moduleWrapper.style.position === "static") { showWrapper = true; } }); // move container definitions to main CSS wrapper.className = showWrapper ? "container" : "container hidden"; }); }; /** * Loads the core config and combines it with the system defaults. */ const loadConfig = function () { // FIXME: Think about how to pass config around without breaking tests if (typeof config === "undefined") { config = defaults; Log.error("Config file is missing! Please create a config file."); return; } config = Object.assign({}, defaults, config); }; /** * Adds special selectors on a collection of modules. * @param {Module[]} modules Array of modules. */ const setSelectionMethodsForModules = function (modules) { /** * Filter modules with the specified classes. * @param {string|string[]} className one or multiple classnames (array or space divided). * @returns {Module[]} Filtered collection of modules. */ const withClass = function (className) { return modulesByClass(className, true); }; /** * Filter modules without the specified classes. * @param {string|string[]} className one or multiple classnames (array or space divided). * @returns {Module[]} Filtered collection of modules. */ const exceptWithClass = function (className) { return modulesByClass(className, false); }; /** * Filters a collection of modules based on classname(s). * @param {string|string[]} className one or multiple classnames (array or space divided). * @param {boolean} include if the filter should include or exclude the modules with the specific classes. * @returns {Module[]} Filtered collection of modules. */ const modulesByClass = function (className, include) { let searchClasses = className; if (typeof className === "string") { searchClasses = className.split(" "); } const newModules = modules.filter(function (module) { const classes = module.data.classes.toLowerCase().split(" "); for (const searchClass of searchClasses) { if (classes.indexOf(searchClass.toLowerCase()) !== -1) { return include; } } return !include; }); setSelectionMethodsForModules(newModules); return newModules; }; /** * Removes a module instance from the collection. * @param {object} module The module instance to remove from the collection. * @returns {Module[]} Filtered collection of modules. */ const exceptModule = function (module) { const newModules = modules.filter(function (mod) { return mod.identifier !== module.identifier; }); setSelectionMethodsForModules(newModules); return newModules; }; /** * Walks thru a collection of modules and executes the callback with the module as an argument. * @param {module} callback The function to execute with the module as an argument. */ const enumerate = function (callback) { modules.map(function (module) { callback(module); }); }; if (typeof modules.withClass === "undefined") { Object.defineProperty(modules, "withClass", { value: withClass, enumerable: false }); } if (typeof modules.exceptWithClass === "undefined") { Object.defineProperty(modules, "exceptWithClass", { value: exceptWithClass, enumerable: false }); } if (typeof modules.exceptModule === "undefined") { Object.defineProperty(modules, "exceptModule", { value: exceptModule, enumerable: false }); } if (typeof modules.enumerate === "undefined") { Object.defineProperty(modules, "enumerate", { value: enumerate, enumerable: false }); } }; return { /* Public Methods */ /** * Main init method. */ async init () { Log.info("Initializing MagicMirror²."); loadConfig(); Log.setLogLevel(config.logLevel); await Translator.loadCoreTranslations(config.language); await Loader.loadModules(); }, /** * Gets called when all modules are started. * @param {Module[]} moduleObjects All module instances. */ modulesStarted (moduleObjects) { modules = []; let startUp = ""; moduleObjects.forEach((module) => modules.push(module)); Log.info("All modules started!"); sendNotification("ALL_MODULES_STARTED"); createDomObjects(); // Setup global socket listener for RELOAD event (watch mode) if (typeof io !== "undefined") { const socket = io("/", { path: `${config.basePath || "/"}socket.io` }); socket.on("RELOAD", () => { Log.warn("Reload notification received from server"); window.location.reload(true); }); } if (config.reloadAfterServerRestart) { setInterval(async () => { // if server startup time has changed (which means server was restarted) // the client reloads the mm page try { const res = await fetch(`${location.protocol}//${location.host}${config.basePath}startup`); const curr = await res.text(); if (startUp === "") startUp = curr; if (startUp !== curr) { startUp = ""; window.location.reload(true); Log.warn("Refreshing Website because server was restarted"); } } catch (err) { Log.error(`MagicMirror not reachable: ${err}`); } }, config.checkServerInterval); } }, /** * Send a notification to all modules. * @param {string} notification The identifier of the notification. * @param {object} payload The payload of the notification. * @param {Module} sender The module that sent the notification. */ sendNotification (notification, payload, sender) { if (arguments.length < 3) { Log.error("sendNotification: Missing arguments."); return; } if (typeof notification !== "string") { Log.error("sendNotification: Notification should be a string."); return; } if (!(sender instanceof Module)) { Log.error("sendNotification: Sender should be a module."); return; } // Further implementation is done in the private method. sendNotification(notification, payload, sender); }, /** * Update the dom for a specific module. * @param {Module} module The module that needs an update. * @param {object|number} [updateOptions] The (optional) number of microseconds for the animation or object with updateOptions (speed/animates) */ updateDom (module, updateOptions) { if (!(module instanceof Module)) { Log.error("updateDom: Sender should be a module."); return; } if (!module.data.position) { Log.warn("module tries to update the DOM without being displayed."); return; } // Further implementation is done in the private method. updateDom(module, updateOptions).then(function () { // Once the update is complete and rendered, send a notification to the module that the DOM has been updated sendNotification("MODULE_DOM_UPDATED", null, null, module); }); }, /** * Returns a collection of all modules currently active. * @returns {Module[]} A collection of all modules currently active. */ getModules () { setSelectionMethodsForModules(modules); return modules; }, /** * Hide the module. * @param {Module} module The module to hide. * @param {number} speed The speed of the hide animation. * @param {Promise} callback Called when the animation is done. * @param {object} [options] Optional settings for the hide method. */ hideModule (module, speed, callback, options) { module.hidden = true; hideModule(module, speed, callback, options); }, /** * Show the module. * @param {Module} module The module to show. * @param {number} speed The speed of the show animation. * @param {Promise} callback Called when the animation is done. * @param {object} [options] Optional settings for the show method. */ showModule (module, speed, callback, options) { // do not change module.hidden yet, only if we really show it later showModule(module, speed, callback, options); }, // Return all available module positions. getAvailableModulePositions: modulePositions }; }()); // Add polyfill for Object.assign. if (typeof Object.assign !== "function") { (function () { Object.assign = function (target) { "use strict"; if (target === undefined || target === null) { throw new TypeError("Cannot convert undefined or null to object"); } const output = Object(target); for (let index = 1; index < arguments.length; index++) { const source = arguments[index]; if (source !== undefined && source !== null) { for (const nextKey in source) { if (source.hasOwnProperty(nextKey)) { output[nextKey] = source[nextKey]; } } } } return output; }; }()); } MM.init(); ================================================ FILE: js/module.js ================================================ /* global Class, cloneObject, Loader, MMSocket, nunjucks, Translator */ /* * Module Blueprint. * @typedef {Object} Module */ const Module = Class.extend({ /** ********************************************************* * All methods (and properties) below can be overridden. * ********************************************************* */ // Set the minimum MagicMirror² module version for this module. requiresVersion: "2.0.0", // Module config defaults. defaults: {}, // Timer reference used for showHide animation callbacks. showHideTimer: null, /* * Array to store lockStrings. These strings are used to lock * visibility when hiding and showing module. */ lockStrings: [], /* * Storage of the nunjucks Environment, * This should not be referenced directly. * Use the nunjucksEnvironment() to get it. */ _nunjucksEnvironment: null, /** * Called when the module is instantiated. */ init () { }, /** * Called when the module is started. */ async start () { Log.info(`Starting module: ${this.name}`); }, /** * Returns a list of scripts the module requires to be loaded. * @returns {string[]} An array with filenames. */ getScripts () { return []; }, /** * Returns a list of stylesheets the module requires to be loaded. * @returns {string[]} An array with filenames. */ getStyles () { return []; }, /** * Returns a map of translation files the module requires to be loaded. * * return Map - * @returns {Map} A map with langKeys and filenames. */ getTranslations () { return false; }, /** * Generates the dom which needs to be displayed. This method is called by the MagicMirror² core. * This method can to be overridden if the module wants to display info on the mirror. * Alternatively, the getTemplate method could be overridden. * @returns {HTMLElement|Promise} The dom or a promise with the dom to display. */ getDom () { return new Promise((resolve) => { const div = document.createElement("div"); const template = this.getTemplate(); const templateData = this.getTemplateData(); // Check to see if we need to render a template string or a file. if ((/^.*((\.html)|(\.njk))$/).test(template)) { // the template is a filename this.nunjucksEnvironment().render(template, templateData, function (err, res) { if (err) { Log.error(err); } div.innerHTML = res; resolve(div); }); } else { // the template is a template string. div.innerHTML = this.nunjucksEnvironment().renderString(template, templateData); resolve(div); } }); }, /** * Generates the header string which needs to be displayed if a user has a header configured for this module. * This method is called by the MagicMirror² core, but only if the user has configured a default header for the module. * This method needs to be overridden if the module wants to display modified headers on the mirror. * @returns {string} The header to display above the header. */ getHeader () { return this.data.header; }, /** * Returns the template for the module which is used by the default getDom implementation. * This method needs to be overridden if the module wants to use a template. * It can either return a template string, or a template filename. * If the string ends with '.html' it's considered a file from within the module's folder. * @returns {string} The template string of filename. */ getTemplate () { return `
${this.name}
${this.identifier}
`; }, /** * Returns the data to be used in the template. * This method needs to be overridden if the module wants to use a custom data. * @returns {object} The data for the template */ getTemplateData () { return {}; }, /** * Called by the MagicMirror² core when a notification arrives. * @param {string} notification The identifier of the notification. * @param {object} payload The payload of the notification. * @param {Module} sender The module that sent the notification. */ notificationReceived (notification, payload, sender) { if (sender) { Log.debug(`${this.name} received a module notification: ${notification} from sender: ${sender.name}`); } else { Log.debug(`${this.name} received a system notification: ${notification}`); } }, /** * Returns the nunjucks environment for the current module. * The environment is checked in the _nunjucksEnvironment instance variable. * @returns {object} The Nunjucks Environment */ nunjucksEnvironment () { if (this._nunjucksEnvironment !== null) { return this._nunjucksEnvironment; } this._nunjucksEnvironment = new nunjucks.Environment(new nunjucks.WebLoader(this.file(""), { async: true }), { trimBlocks: true, lstripBlocks: true }); this._nunjucksEnvironment.addFilter("translate", (str, variables) => { return nunjucks.runtime.markSafe(this.translate(str, variables)); }); return this._nunjucksEnvironment; }, /** * Called when a socket notification arrives. * @param {string} notification The identifier of the notification. * @param {object} payload The payload of the notification. */ socketNotificationReceived (notification, payload) { Log.log(`${this.name} received a socket notification: ${notification} - Payload: ${payload}`); }, /** * Called when the module is hidden. */ suspend () { Log.log(`${this.name} is suspended.`); }, /** * Called when the module is shown. */ resume () { Log.log(`${this.name} is resumed.`); }, /** *********************************************** * The methods below should not be overridden. * *********************************************** */ /** * Set the module data. * @param {object} data The module data */ setData (data) { this.data = data; this.name = data.name; this.identifier = data.identifier; this.hidden = false; this.hasAnimateIn = false; this.hasAnimateOut = false; this.setConfig(data.config, data.configDeepMerge); }, /** * Set the module config and combine it with the module defaults. * @param {object} config The combined module config. * @param {boolean} deep Merge module config in deep. */ setConfig (config, deep) { this.config = deep ? configMerge({}, this.defaults, config) : Object.assign({}, this.defaults, config); }, /** * Returns a socket object. If it doesn't exist, it's created. * It also registers the notification callback. * @returns {MMSocket} a socket object */ socket () { if (typeof this._socket === "undefined") { this._socket = new MMSocket(this.name); } this._socket.setNotificationCallback((notification, payload) => { this.socketNotificationReceived(notification, payload); }); return this._socket; }, /** * Retrieve the path to a module file. * @param {string} file Filename * @returns {string} the file path */ file (file) { return `${this.data.path}/${file}`.replace("//", "/"); }, /** * Load all required stylesheets by requesting the MM object to load the files. * @returns {Promise} */ loadStyles () { return this.loadDependencies("getStyles"); }, /** * Load all required scripts by requesting the MM object to load the files. * @returns {Promise} */ loadScripts () { return this.loadDependencies("getScripts"); }, /** * Helper method to load all dependencies. * @param {string} funcName Function name to call to get scripts or styles. * @returns {Promise} */ async loadDependencies (funcName) { let dependencies = this[funcName](); const loadNextDependency = async () => { if (dependencies.length > 0) { const nextDependency = dependencies[0]; await Loader.loadFileForModule(nextDependency, this); dependencies = dependencies.slice(1); await loadNextDependency(); } else { return Promise.resolve(); } }; await loadNextDependency(); }, /** * Load all translations. * @returns {Promise} */ async loadTranslations () { const translations = this.getTranslations() || {}; const language = config.language.toLowerCase(); const languages = Object.keys(translations); const fallbackLanguage = languages[0]; if (languages.length === 0) { return; } const translationFile = translations[language]; const translationsFallbackFile = translations[fallbackLanguage]; if (!translationFile) { return Translator.load(this, translationsFallbackFile, true); } await Translator.load(this, translationFile, false); if (translationFile !== translationsFallbackFile) { return Translator.load(this, translationsFallbackFile, true); } }, /** * Request the translation for a given key with optional variables and default value. * @param {string} key The key of the string to translate * @param {string|object} [defaultValueOrVariables] The default value or variables for translating. * @param {string} [defaultValue] The default value with variables. * @returns {string} the translated key */ translate (key, defaultValueOrVariables, defaultValue) { if (typeof defaultValueOrVariables === "object") { return Translator.translate(this, key, defaultValueOrVariables) || defaultValue || ""; } return Translator.translate(this, key) || defaultValueOrVariables || ""; }, /** * Request an (animated) update of the module. * @param {number|object} [updateOptions] The speed of the animation or object with for updateOptions (speed/animates) */ updateDom (updateOptions) { MM.updateDom(this, updateOptions); }, /** * Send a notification to all modules. * @param {string} notification The identifier of the notification. * @param {object} payload The payload of the notification. */ sendNotification (notification, payload) { MM.sendNotification(notification, payload, this); }, /** * Send a socket notification to the node helper. * @param {string} notification The identifier of the notification. * @param {object} payload The payload of the notification. */ sendSocketNotification (notification, payload) { this.socket().sendNotification(notification, payload); }, /** * Hide this module. * @param {number} speed The speed of the hide animation. * @param {Promise} callback Called when the animation is done. * @param {object} [options] Optional settings for the hide method. */ hide (speed, callback, options = {}) { let usedCallback = callback || function () {}; let usedOptions = options; if (typeof callback === "object") { Log.error("Parameter mismatch in module.hide: callback is not an optional parameter!"); usedOptions = callback; usedCallback = function () {}; } MM.hideModule( this, speed, () => { this.suspend(); usedCallback(); }, usedOptions ); }, /** * Show this module. * @param {number} speed The speed of the show animation. * @param {Promise} callback Called when the animation is done. * @param {object} [options] Optional settings for the show method. */ show (speed, callback, options) { let usedCallback = callback || function () {}; let usedOptions = options; if (typeof callback === "object") { Log.error("Parameter mismatch in module.show: callback is not an optional parameter!"); usedOptions = callback; usedCallback = function () {}; } MM.showModule( this, speed, () => { this.resume(); usedCallback(); }, usedOptions ); } }); /** * Merging MagicMirror² (or other) default/config script by `@bugsounet` * Merge 2 objects or/with array * * Usage: * ------- * this.config = configMerge({}, this.defaults, this.config) * ------- * arg1: initial object * arg2: config model * arg3: config to merge * ------- * why using it ? * Object.assign() function don't to all job * it don't merge all thing in deep * -> object in object and array is not merging * ------- * * Todo: idea of Mich determinate what do you want to merge or not * @param {object} result the initial object * @returns {object} the merged config */ function configMerge (result) { const stack = Array.prototype.slice.call(arguments, 1); let item, key; while (stack.length) { item = stack.shift(); for (key in item) { if (item.hasOwnProperty(key)) { if (typeof result[key] === "object" && result[key] && Object.prototype.toString.call(result[key]) !== "[object Array]") { if (typeof item[key] === "object" && item[key] !== null) { result[key] = configMerge({}, result[key], item[key]); } else { result[key] = item[key]; } } else { result[key] = item[key]; } } } } return result; } Module.definitions = {}; Module.create = function (name) { // Make sure module definition is available. if (!Module.definitions[name]) { return; } const moduleDefinition = Module.definitions[name]; const clonedDefinition = cloneObject(moduleDefinition); // Note that we clone the definition. Otherwise the objects are shared, which gives problems. const ModuleClass = Module.extend(clonedDefinition); return new ModuleClass(); }; Module.register = function (name, moduleDefinition) { if (moduleDefinition.requiresVersion) { Log.log(`Check MagicMirror² version for module '${name}' - Minimum version: ${moduleDefinition.requiresVersion} - Current version: ${window.mmVersion}`); if (cmpVersions(window.mmVersion, moduleDefinition.requiresVersion) >= 0) { Log.log("Version is ok!"); } else { Log.warn(`Version is incorrect. Skip module: '${name}'`); return; } } Log.log(`Module registered: ${name}`); Module.definitions[name] = moduleDefinition; }; window.Module = Module; /** * Compare two semantic version numbers and return the difference. * @param {string} a Version number a. * @param {string} b Version number b. * @returns {number} A positive number if a is larger than b, a negative * number if a is smaller and 0 if they are the same */ function cmpVersions (a, b) { const regExStrip0 = /(\.0+)+$/; const segmentsA = a.replace(regExStrip0, "").split("."); const segmentsB = b.replace(regExStrip0, "").split("."); const l = Math.min(segmentsA.length, segmentsB.length); for (let i = 0; i < l; i++) { let diff = parseInt(segmentsA[i], 10) - parseInt(segmentsB[i], 10); if (diff) { return diff; } } return segmentsA.length - segmentsB.length; } ================================================ FILE: js/module_functions.js ================================================ /** * Schedule the timer for the next update * @param {object} timer The timer of the module * @param {bigint} intervalMS interval in milliseconds * @param {Promise} callback function to call when the timer expires */ const scheduleTimer = function (timer, intervalMS, callback) { if (process.env.mmTestMode !== "true") { // only set timer when not running in test mode let tmr = timer; clearTimeout(tmr); tmr = setTimeout(function () { callback(); }, intervalMS); } }; module.exports = { scheduleTimer }; ================================================ FILE: js/node_helper.js ================================================ const express = require("express"); const Log = require("logger"); const Class = require("./class"); const NodeHelper = Class.extend({ init () { Log.log("Initializing new module helper ..."); }, loaded () { Log.log(`Module helper loaded: ${this.name}`); }, start () { Log.log(`Starting module helper: ${this.name}`); }, /** * Called when the MagicMirror² server receives a `SIGINT` * Close any open connections, stop any sub-processes and * gracefully exit the module. */ stop () { Log.log(`Stopping module helper: ${this.name}`); }, /** * This method is called when a socket notification arrives. * @param {string} notification The identifier of the notification. * @param {object} payload The payload of the notification. */ socketNotificationReceived (notification, payload) { Log.log(`${this.name} received a socket notification: ${notification} - Payload: ${payload}`); }, /** * Set the module name. * @param {string} name Module name. */ setName (name) { this.name = name; }, /** * Set the module path. * @param {string} path Module path. */ setPath (path) { this.path = path; }, /* * sendSocketNotification(notification, payload) * Send a socket notification to the node helper. * * argument notification string - The identifier of the notification. * argument payload mixed - The payload of the notification. */ sendSocketNotification (notification, payload) { this.io.of(this.name).emit(notification, payload); }, /* * setExpressApp(app) * Sets the express app object for this module. * This allows you to host files from the created webserver. * * argument app Express app - The Express app object. */ setExpressApp (app) { this.expressApp = app; app.use(`/${this.name}`, express.static(`${this.path}/public`)); }, /* * setSocketIO(io) * Sets the socket io object for this module. * Binds message receiver. * * argument io Socket.io - The Socket io object. */ setSocketIO (io) { this.io = io; Log.log(`Connecting socket for: ${this.name}`); io.of(this.name).on("connection", (socket) => { // register catch all. socket.onAny((notification, payload) => { this.socketNotificationReceived(notification, payload); }); }); } }); NodeHelper.checkFetchStatus = function (response) { // response.status >= 200 && response.status < 300 if (response.ok) { return response; } else { throw Error(response.statusText); } }; /** * Look at the specified error and return an appropriate error type, that * can be translated to a detailed error message * @param {Error} error the error from fetching something * @returns {string} the string of the detailed error message in the translations */ NodeHelper.checkFetchError = function (error) { let error_type = "MODULE_ERROR_UNSPECIFIED"; if (error.code === "EAI_AGAIN") { error_type = "MODULE_ERROR_NO_CONNECTION"; } else { const message = typeof error.message === "string" ? error.message.toLowerCase() : ""; if (message.includes("unauthorized") || message.includes("http 401") || message.includes("http 403")) { error_type = "MODULE_ERROR_UNAUTHORIZED"; } } return error_type; }; NodeHelper.create = function (moduleDefinition) { return NodeHelper.extend(moduleDefinition); }; module.exports = NodeHelper; ================================================ FILE: js/releasenotes.js ================================================ /* eslint no-console: "off" */ const util = require("node:util"); const exec = util.promisify(require("node:child_process").exec); const fs = require("node:fs"); const createReleaseNotes = async () => { let repoName = "MagicMirrorOrg/MagicMirror"; if (process.env.GITHUB_REPOSITORY) { repoName = process.env.GITHUB_REPOSITORY; } const baseUrl = `https://api.github.com/repos/${repoName}`; const getOptions = (type) => { if (process.env.GITHUB_TOKEN) { return { method: `${type}`, headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } }; } else { return { method: `${type}` }; } }; const execShell = async (command) => { const { stdout = "", stderr = "" } = await exec(command); if (stderr) console.error(`Error in execShell executing command ${command}: ${stderr}`); return stdout; }; // Check Draft Release const draftReleases = []; const jsonReleases = await fetch(`${baseUrl}/releases`, getOptions("GET")).then((res) => res.json()); for (const rel of jsonReleases) { if (rel.draft && rel.tag_name === "" && rel.published_at === null && rel.name === "unreleased") draftReleases.push(rel); } let draftReleaseId = 0; if (draftReleases.length > 1) { throw new Error("More than one draft release found, exiting."); } else { if (draftReleases[0]) draftReleaseId = draftReleases[0].id; } // Get last Git Tag const gitTag = await execShell("git describe --tags `git rev-list --tags --max-count=1`"); const lastTag = gitTag.toString().replaceAll("\n", ""); console.info(`latest tag is ${lastTag}`); // Get Git Commits const gitOut = await execShell(`git log develop --pretty=format:"%H --- %s" --after="$(git log -1 --format=%aI ${lastTag})"`); console.info(gitOut); const commits = gitOut.toString().split("\n"); // Get Node engine version from package.json const nodeVersion = JSON.parse(fs.readFileSync("package.json")).engines.node; // Search strings const labelArr = ["alert", "calendar", "clock", "compliments", "helloworld", "newsfeed", "updatenotification", "weather", "envcanada", "openmeteo", "openweathermap", "smhi", "ukmetoffice", "yr", "eslint", "bump", "dependencies", "deps", "logg", "translation", "test", "ci"]; // Map search strings to categories const getFirstLabel = (text) => { let res; labelArr.every((item) => { const labelIncl = text.includes(item); if (labelIncl) { switch (item) { case "ci": case "test": res = "testing"; break; case "logg": res = "logging"; break; case "eslint": case "bump": case "deps": res = "dependencies"; break; case "envcanada": case "openmeteo": case "openweathermap": case "smhi": case "ukmetoffice": case "yr": case "weather": res = "modules/weather"; break; case "alert": res = "modules/alert"; break; case "calendar": res = "modules/calendar"; break; case "clock": res = "modules/clock"; break; case "compliments": res = "modules/compliments"; break; case "helloworld": res = "modules/helloworld"; break; case "newsfeed": res = "modules/newsfeed"; break; case "updatenotification": res = "modules/updatenotification"; break; default: res = item; break; } return false; } else { return true; } }); if (!res) res = "core"; return res; }; const grouped = {}; const contrib = []; const sha = []; // Loop through each Commit for (const item of commits) { const cm = item.trim(); // ignore `prepare release` line if (cm.length > 0 && !cm.match(/^.* --- prepare .*-develop$/gi)) { const [ref, title] = cm.split(" --- "); const groupTitle = getFirstLabel(title.toLowerCase()); if (!grouped[groupTitle]) { grouped[groupTitle] = []; } grouped[groupTitle].push(`- ${title}`); sha.push(ref); } } // function to remove duplicates const sortedArr = (arr) => { return arr.filter((item, index) => (arr.indexOf(item) === index && item !== "@dependabot[bot]")).sort(function (a, b) { return a.toLowerCase().localeCompare(b.toLowerCase()); }); }; // Get Contributors logins for (const ref of sha) { const jsonRes = await fetch(`${baseUrl}/commits/${ref}`, getOptions("GET")).then((res) => res.json()); if (jsonRes && jsonRes.author && jsonRes.author.login) contrib.push(`@${jsonRes.author.login}`); } // Build Markdown content let markdown = "## Release Notes\n"; markdown += `Thanks to: ${sortedArr(contrib).join(", ")}\n`; markdown += `> ⚠️ This release needs nodejs version ${nodeVersion}\n`; markdown += "\n"; markdown += `[Compare to previous Release ${lastTag}](https://github.com/${repoName}/compare/${lastTag}...develop)\n\n`; const sorted = Object.keys(grouped) .sort() // Sort the keys alphabetically .reduce((obj, key) => { obj[key] = grouped[key]; // Rebuild the object with sorted keys return obj; }, {}); for (const group in sorted) { markdown += `\n### [${group}]\n`; markdown += `${sorted[group].join("\n")}\n`; } console.info(markdown); // Create Github Release if (process.env.GITHUB_TOKEN) { if (draftReleaseId > 0) { // delete release await fetch(`${baseUrl}/releases/${draftReleaseId}`, getOptions("DELETE")); console.info(`Old Release with id ${draftReleaseId} deleted.`); } const relContent = getOptions("POST"); relContent.body = JSON.stringify( { tag_name: "", name: "unreleased", body: `${markdown}`, draft: true } ); const createRelease = await fetch(`${baseUrl}/releases`, relContent).then((res) => res.json()); console.info(`New release created with id ${createRelease.id}, GitHub-Url: ${createRelease.html_url}`); } }; createReleaseNotes(); ================================================ FILE: js/server.js ================================================ const fs = require("node:fs"); const http = require("node:http"); const https = require("node:https"); const path = require("node:path"); const express = require("express"); const helmet = require("helmet"); const socketio = require("socket.io"); const Log = require("logger"); const { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars } = require("#server_functions"); const { ipAccessControl } = require(`${__dirname}/ip_access_control`); const vendor = require(`${__dirname}/vendor`); /** * Server * @param {object} config The MM config * @class */ function Server (config) { const app = express(); const port = process.env.MM_PORT || config.port; const serverSockets = new Set(); let server = null; /** * Opens the server for incoming connections * @returns {Promise} A promise that is resolved when the server listens to connections */ this.open = function () { return new Promise((resolve) => { if (config.useHttps) { const options = { key: fs.readFileSync(config.httpsPrivateKey), cert: fs.readFileSync(config.httpsCertificate) }; server = https.Server(options, app); } else { server = http.Server(app); } const io = socketio(server, { cors: { origin: /.*$/, credentials: true }, allowEIO3: true, pingInterval: 120000, // server → client ping every 2 mins pingTimeout: 120000 // wait up to 2 mins for client pong }); server.on("connection", (socket) => { serverSockets.add(socket); socket.on("close", () => { serverSockets.delete(socket); }); }); Log.log(`Starting server on port ${port} ... `); // Add explicit error handling BEFORE calling listen so we can give user-friendly feedback server.once("error", (err) => { if (err && err.code === "EADDRINUSE") { const bindAddr = config.address || "localhost"; const portInUseMessage = [ "", "────────────────────────────────────────────────────────────────", ` PORT IN USE: ${bindAddr}:${port}`, "", " Another process (most likely another MagicMirror instance)", " is already using this port.", "", " Stop the other process (free the port) or use a different port.", "────────────────────────────────────────────────────────────────" ].join("\n"); Log.error(portInUseMessage); return; } Log.error("Failed to start server:", err); }); server.listen(port, config.address || "localhost"); if (config.ipWhitelist instanceof Array && config.ipWhitelist.length === 0) { Log.warn("You're using a full whitelist configuration to allow for all IPs"); } app.use(ipAccessControl(config.ipWhitelist)); app.use(helmet(config.httpHeaders)); app.use("/js", express.static(__dirname)); 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))); } app.get("/cors", async (req, res) => await cors(req, res)); app.get("/version", (req, res) => getVersion(req, res)); app.get("/config", (req, res) => getConfig(req, res)); app.get("/startup", (req, res) => getStartup(req, res)); app.get("/env", (req, res) => getEnvVars(req, res)); app.get("/", (req, res) => getHtml(req, res)); // Reload endpoint for watch mode - triggers browser reload app.get("/reload", (req, res) => { Log.info("Reload request received, notifying all clients"); io.emit("RELOAD"); res.status(200).send("OK"); }); server.on("listening", () => { resolve({ app, io }); }); }); }; /** * Closes the server and destroys all lingering connections to it. * @returns {Promise} A promise that resolves when server has successfully shut down */ this.close = function () { return new Promise((resolve) => { for (const socket of serverSockets.values()) { socket.destroy(); } server.close(resolve); }); }; } module.exports = Server; ================================================ FILE: js/server_functions.js ================================================ const fs = require("node:fs"); const path = require("node:path"); const Log = require("logger"); const startUp = new Date(); /** * Gets the config. * @param {Request} req - the request * @param {Response} res - the result */ function getConfig (req, res) { res.send(config); } /** * Gets the startup time. * @param {Request} req - the request * @param {Response} res - the result */ function getStartup (req, res) { res.send(startUp); } /** * A method that forwards HTTP Get-methods to the internet to avoid CORS-errors. * * Example input request url: /cors?sendheaders=header1:value1,header2:value2&expectedheaders=header1,header2&url=http://www.test.com/path?param1=value1 * * Only the url-param of the input request url is required. It must be the last parameter. * @param {Request} req - the request * @param {Response} res - the result * @returns {Promise} A promise that resolves when the response is sent */ async function cors (req, res) { try { const urlRegEx = "url=(.+?)$"; let url; const match = new RegExp(urlRegEx, "g").exec(req.url); if (!match) { url = `invalid url: ${req.url}`; Log.error(url); return res.status(400).send(url); } else { url = match[1]; const headersToSend = getHeadersToSend(req.url); const expectedReceivedHeaders = geExpectedReceivedHeaders(req.url); Log.log(`cors url: ${url}`); const response = await fetch(url, { headers: headersToSend }); if (response.ok) { for (const header of expectedReceivedHeaders) { const headerValue = response.headers.get(header); if (header) res.set(header, headerValue); } const data = await response.text(); res.send(data); } else { throw new Error(`Response status: ${response.status}`); } } } catch (error) { // Only log errors in non-test environments to keep test output clean if (process.env.mmTestMode !== "true") { Log.error(`Error in CORS request: ${error}`); } res.status(500).json({ error: error.message }); } } /** * Gets headers and values to attach to the web request. * @param {string} url - The url containing the headers and values to send. * @returns {object} An object specifying name and value of the headers. */ function getHeadersToSend (url) { const headersToSend = { "User-Agent": getUserAgent() }; const headersToSendMatch = new RegExp("sendheaders=(.+?)(&|$)", "g").exec(url); if (headersToSendMatch) { const headers = headersToSendMatch[1].split(","); for (const header of headers) { const keyValue = header.split(":"); if (keyValue.length !== 2) { throw new Error(`Invalid format for header ${header}`); } headersToSend[keyValue[0]] = decodeURIComponent(keyValue[1]); } } return headersToSend; } /** * Gets the headers expected from the response. * @param {string} url - The url containing the expected headers from the response. * @returns {string[]} headers - The name of the expected headers. */ function geExpectedReceivedHeaders (url) { const expectedReceivedHeaders = ["Content-Type"]; const expectedReceivedHeadersMatch = new RegExp("expectedheaders=(.+?)(&|$)", "g").exec(url); if (expectedReceivedHeadersMatch) { const headers = expectedReceivedHeadersMatch[1].split(","); for (const header of headers) { expectedReceivedHeaders.push(header); } } return expectedReceivedHeaders; } /** * Gets the HTML to display the magic mirror. * @param {Request} req - the request * @param {Response} res - the result */ function getHtml (req, res) { let html = fs.readFileSync(path.resolve(`${global.root_path}/index.html`), { encoding: "utf8" }); html = html.replace("#VERSION#", global.version); html = html.replace("#TESTMODE#", global.mmTestMode); let configFile = "config/config.js"; if (typeof global.configuration_file !== "undefined") { configFile = global.configuration_file; } html = html.replace("#CONFIG_FILE#", configFile); res.send(html); } /** * Gets the MagicMirror version. * @param {Request} req - the request * @param {Response} res - the result */ function getVersion (req, res) { res.send(global.version); } /** * Gets the preferred `User-Agent` * @returns {string} `User-Agent` to be used */ function getUserAgent () { const defaultUserAgent = `Mozilla/5.0 (Node.js ${Number(process.version.match(/^v(\d+\.\d+)/)[1])}) MagicMirror/${global.version}`; if (typeof config === "undefined") { return defaultUserAgent; } switch (typeof config.userAgent) { case "function": return config.userAgent(); case "string": return config.userAgent; default: return defaultUserAgent; } } /** * Gets environment variables needed in the browser. * @returns {object} environment variables key: values */ function getEnvVarsAsObj () { const obj = { modulesDir: `${config.foreignModulesDir}`, customCss: `${config.customCss}` }; if (process.env.MM_MODULES_DIR) { obj.modulesDir = process.env.MM_MODULES_DIR.replace(`${global.root_path}/`, ""); } if (process.env.MM_CUSTOMCSS_FILE) { obj.customCss = process.env.MM_CUSTOMCSS_FILE.replace(`${global.root_path}/`, ""); } return obj; } /** * Gets environment variables needed in the browser. * @param {Request} req - the request * @param {Response} res - the result */ function getEnvVars (req, res) { const obj = getEnvVarsAsObj(); res.send(obj); } /** * Get the config file path from environment or default location * @returns {string} The absolute config file path */ function getConfigFilePath () { // Ensure root_path is set (for standalone contexts like watcher) if (!global.root_path) { global.root_path = path.resolve(`${__dirname}/../`); } // Check environment variable if global not set if (!global.configuration_file && process.env.MM_CONFIG_FILE) { global.configuration_file = process.env.MM_CONFIG_FILE; } return path.resolve(global.configuration_file || `${global.root_path}/config/config.js`); } module.exports = { cors, getConfig, getHtml, getVersion, getStartup, getEnvVars, getEnvVarsAsObj, getUserAgent, getConfigFilePath }; ================================================ FILE: js/socketclient.js ================================================ /* global io */ const MMSocket = function (moduleName) { if (typeof moduleName !== "string") { throw new Error("Please set the module name for the MMSocket."); } this.moduleName = moduleName; // Private Methods let base = "/"; if (typeof config !== "undefined" && typeof config.basePath !== "undefined") { base = config.basePath; } this.socket = io(`/${this.moduleName}`, { path: `${base}socket.io`, pingInterval: 120000, // send pings every 2 mins pingTimeout: 120000 // wait up to 2 mins for a pong }); let notificationCallback = function () {}; const onevent = this.socket.onevent; this.socket.onevent = (packet) => { const args = packet.data || []; onevent.call(this.socket, packet); // original call packet.data = ["*"].concat(args); onevent.call(this.socket, packet); // additional call to catch-all }; // register catch all. this.socket.on("*", (notification, payload) => { if (notification !== "*") { notificationCallback(notification, payload); } }); // Public Methods this.setNotificationCallback = (callback) => { notificationCallback = callback; }; this.sendNotification = (notification, payload = {}) => { this.socket.emit(notification, payload); }; }; ================================================ FILE: js/translator.js ================================================ /* global translations */ const Translator = (function () { /** * Load a JSON file via fetch. * @param {string} file Path of the file we want to load. * @returns {Promise} the translations in the specified file */ async function loadJSON (file) { const baseHref = document.baseURI; const url = new URL(file, baseHref); try { const response = await fetch(url); if (!response.ok) { throw new Error(`Unexpected response status: ${response.status}`); } return await response.json(); } catch (exception) { Log.error(`Loading json file =${file} failed`); return null; } } return { coreTranslations: {}, coreTranslationsFallback: {}, translations: {}, translationsFallback: {}, /** * Load a translation for a given key for a given module. * @param {Module} module The module to load the translation for. * @param {string} key The key of the text to translate. * @param {object} variables The variables to use within the translation template (optional) * @returns {string} the translated key */ translate (module, key, variables = {}) { /** * Combines template and variables like: * template: "Please wait for {timeToWait} before continuing with {work}." * variables: {timeToWait: "2 hours", work: "painting"} * to: "Please wait for 2 hours before continuing with painting." * @param {string} template Text with placeholder * @param {object} variables Variables for the placeholder * @returns {string} the template filled with the variables */ function createStringFromTemplate (template, variables) { if (Object.prototype.toString.call(template) !== "[object String]") { return template; } let templateToUse = template; if (variables.fallback && !template.match(new RegExp("{.+}"))) { templateToUse = variables.fallback; } return templateToUse.replace(new RegExp("{([^}]+)}", "g"), function (_unused, varName) { return varName in variables ? variables[varName] : `{${varName}}`; }); } if (this.translations[module.name] && key in this.translations[module.name]) { return createStringFromTemplate(this.translations[module.name][key], variables); } if (key in this.coreTranslations) { return createStringFromTemplate(this.coreTranslations[key], variables); } if (this.translationsFallback[module.name] && key in this.translationsFallback[module.name]) { return createStringFromTemplate(this.translationsFallback[module.name][key], variables); } if (key in this.coreTranslationsFallback) { return createStringFromTemplate(this.coreTranslationsFallback[key], variables); } return key; }, /** * Load a translation file (json) and remember the data. * @param {Module} module The module to load the translation file for. * @param {string} file Path of the file we want to load. * @param {boolean} isFallback Flag to indicate fallback translations. */ async load (module, file, isFallback) { Log.log(`[translator] ${module.name} - Load translation${isFallback ? " fallback" : ""}: ${file}`); if (this.translationsFallback[module.name]) { return; } const json = await loadJSON(module.file(file)); const property = isFallback ? "translationsFallback" : "translations"; this[property][module.name] = json; }, /** * Load the core translations. * @param {string} lang The language identifier of the core language. */ async loadCoreTranslations (lang) { if (lang in translations) { Log.log(`[translator] Loading core translation file: ${translations[lang]}`); this.coreTranslations = await loadJSON(translations[lang]); } else { Log.log("[translator] Configured language not found in core translations."); } await this.loadCoreTranslationsFallback(); }, /** * Load the core translations' fallback. * The first language defined in translations.js will be used. */ async loadCoreTranslationsFallback () { let first = Object.keys(translations)[0]; if (first) { Log.log(`[translator] Loading core translation fallback file: ${translations[first]}`); this.coreTranslationsFallback = await loadJSON(translations[first]); } } }; }()); window.Translator = Translator; ================================================ FILE: js/utils.js ================================================ const os = require("node:os"); const fs = require("node:fs"); const si = require("systeminformation"); const Log = require("logger"); const modulePositions = []; // will get list from index.html const regionRegEx = /"region ([^"]*)/i; const indexFileName = "index.html"; const discoveredPositionsJSFilename = "js/positions.js"; module.exports = { async logSystemInformation (mirrorVersion) { try { const system = await si.system(); const osInfo = await si.osInfo(); const versions = await si.versions(); const usedNodeVersion = process.version.replace("v", ""); const installedNodeVersion = versions.node; const totalRam = (os.totalmem() / 1024 / 1024).toFixed(2); const freeRam = (os.freemem() / 1024 / 1024).toFixed(2); const usedRam = ((os.totalmem() - os.freemem()) / 1024 / 1024).toFixed(2); let systemDataString = [ "\n#### System Information ####", `- SYSTEM: manufacturer: ${system.manufacturer}; model: ${system.model}; virtual: ${system.virtual}; MM: ${mirrorVersion}`, `- OS: platform: ${osInfo.platform}; distro: ${osInfo.distro}; release: ${osInfo.release}; arch: ${osInfo.arch}; kernel: ${versions.kernel}`, `- VERSIONS: electron: ${process.versions.electron}; used node: ${usedNodeVersion}; installed node: ${installedNodeVersion}; npm: ${versions.npm}; pm2: ${versions.pm2}`, `- ENV: XDG_SESSION_TYPE: ${process.env.XDG_SESSION_TYPE}; MM_CONFIG_FILE: ${process.env.MM_CONFIG_FILE}`, ` WAYLAND_DISPLAY: ${process.env.WAYLAND_DISPLAY}; DISPLAY: ${process.env.DISPLAY}; ELECTRON_ENABLE_GPU: ${process.env.ELECTRON_ENABLE_GPU}`, `- RAM: total: ${totalRam} MB; free: ${freeRam} MB; used: ${usedRam} MB`, `- OTHERS: uptime: ${Math.floor(os.uptime() / 60)} minutes; timeZone: ${Intl.DateTimeFormat().resolvedOptions().timeZone}` ].join("\n"); Log.info(systemDataString); // Return is currently only for tests return systemDataString; } catch (error) { Log.error(error); } }, // return all available module positions getAvailableModulePositions () { return modulePositions; }, // return if position is on modulePositions Array (true/false) moduleHasValidPosition (position) { if (this.getAvailableModulePositions().indexOf(position) === -1) return false; return true; }, getModulePositions () { // if not already discovered if (modulePositions.length === 0) { // get the lines of the index.html const lines = fs.readFileSync(indexFileName).toString().split("\n"); // loop thru the lines lines.forEach((line) => { // run the regex on each line const results = regionRegEx.exec(line); // if the regex returned something if (results && results.length > 0) { // get the position parts and replace space with underscore const positionName = results[1].replace(" ", "_"); // add it to the list only if not already present (avoid duplicates) if (!modulePositions.includes(positionName)) { modulePositions.push(positionName); } } }); try { fs.writeFileSync(discoveredPositionsJSFilename, `const modulePositions=${JSON.stringify(modulePositions)}`); } catch (error) { Log.error("unable to write js/positions.js with the discovered module positions\nmake the MagicMirror/js folder writeable by the user starting MagicMirror"); } } // return the list to the caller return modulePositions; } }; ================================================ FILE: js/vendor.js ================================================ const vendor = { "moment.js": "node_modules/moment/min/moment-with-locales.js", "moment-timezone.js": "node_modules/moment-timezone/builds/moment-timezone-with-data.js", "weather-icons.css": "node_modules/weathericons/css/weather-icons.css", "weather-icons-wind.css": "node_modules/weathericons/css/weather-icons-wind.css", "font-awesome.css": "css/font-awesome.css", "nunjucks.js": "node_modules/nunjucks/browser/nunjucks.min.js", "suncalc.js": "node_modules/suncalc/suncalc.js", "croner.js": "node_modules/croner/dist/croner.umd.js" }; if (typeof module !== "undefined") { module.exports = vendor; } ================================================ FILE: jsconfig.json ================================================ { // See https://go.microsoft.com/fwlink/?LinkId=759670 // for the documentation about the jsconfig.json format "compilerOptions": { "target": "es6", "module": "commonjs", "allowSyntheticDefaultImports": true }, "exclude": ["modules", "node_modules"] } ================================================ FILE: module-types.ts ================================================ type ModuleProperties = { defaults?: object; [key: string]: any; start?(): void; getScripts?(): string[]; getStyles?(): string[]; getTranslations?(): object; getDom?(): HTMLElement; getHeader?(): string; getTemplate?(): string; getTemplateData?(): object; notificationReceived?(notification: string, payload: any, sender: object): void; nunjucksEnvironment?(): void; socketNotificationReceived?(notification: string, payload: any): void; suspend?(): void; resume?(): void; }; export declare const Module: { register(moduleName: string, moduleProperties: ModuleProperties): void; }; export declare const Log: { info(message?: any, ...optionalParams: any[]): void; log(message?: any, ...optionalParams: any[]): void; error(message?: any, ...optionalParams: any[]): void; warn(message?: any, ...optionalParams: any[]): void; group(groupTitle?: string, ...optionalParams: any[]): void; groupCollapsed(groupTitle?: string, ...optionalParams: any[]): void; groupEnd(): void; time(timerName?: string): void; timeEnd(timerName?: string): void; timeStamp(timerName?: string): void; }; ================================================ FILE: modules/default/alert/README.md ================================================ # Module: Alert The alert module is one of the default modules of the MagicMirror². This module displays notifications from other modules. For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/alert.html). ================================================ FILE: modules/default/alert/alert.js ================================================ /* global NotificationFx */ Module.register("alert", { alerts: {}, defaults: { effect: "slide", // scale|slide|genie|jelly|flip|bouncyflip|exploader alert_effect: "jelly", // scale|slide|genie|jelly|flip|bouncyflip|exploader display_time: 3500, // time a notification is displayed in seconds position: "center", welcome_message: false // shown at startup }, getScripts () { return ["notificationFx.js"]; }, getStyles () { return ["font-awesome.css", this.file("./styles/notificationFx.css"), this.file(`./styles/${this.config.position}.css`)]; }, getTranslations () { return { bg: "translations/bg.json", da: "translations/da.json", de: "translations/de.json", en: "translations/en.json", eo: "translations/eo.json", es: "translations/es.json", fr: "translations/fr.json", hu: "translations/hu.json", nl: "translations/nl.json", pt: "translations/pt.json", "pt-br": "translations/pt-br.json", ru: "translations/ru.json", th: "translations/th.json" }; }, getTemplate (type) { return `templates/${type}.njk`; }, async start () { Log.info(`Starting module: ${this.name}`); if (this.config.effect === "slide") { this.config.effect = `${this.config.effect}-${this.config.position}`; } if (this.config.welcome_message) { const message = this.config.welcome_message === true ? this.translate("welcome") : this.config.welcome_message; await this.showNotification({ title: this.translate("sysTitle"), message }); } }, notificationReceived (notification, payload, sender) { if (notification === "SHOW_ALERT") { if (payload.type === "notification") { this.showNotification(payload); } else { this.showAlert(payload, sender); } } else if (notification === "HIDE_ALERT") { this.hideAlert(sender); } }, async showNotification (notification) { const message = await this.renderMessage(notification.templateName || "notification", notification); new NotificationFx({ message, layout: "growl", effect: this.config.effect, ttl: notification.timer || this.config.display_time }).show(); }, async showAlert (alert, sender) { // If module already has an open alert close it if (this.alerts[sender.name]) { this.hideAlert(sender, false); } // Add overlay if (!Object.keys(this.alerts).length) { this.toggleBlur(true); } const message = await this.renderMessage(alert.templateName || "alert", alert); // Store alert in this.alerts this.alerts[sender.name] = new NotificationFx({ message, effect: this.config.alert_effect, ttl: alert.timer, onClose: () => this.hideAlert(sender), al_no: "ns-alert" }); // Show alert this.alerts[sender.name].show(); // Add timer to dismiss alert and overlay if (alert.timer) { setTimeout(() => { this.hideAlert(sender); }, alert.timer); } }, hideAlert (sender, close = true) { // Dismiss alert and remove from this.alerts if (this.alerts[sender.name]) { this.alerts[sender.name].dismiss(close); delete this.alerts[sender.name]; // Remove overlay if (!Object.keys(this.alerts).length) { this.toggleBlur(false); } } }, renderMessage (type, data) { return new Promise((resolve) => { this.nunjucksEnvironment().render(this.getTemplate(type), data, function (err, res) { if (err) { Log.error("[alert] Failed to render alert", err); } resolve(res); }); }); }, toggleBlur (add = false) { const method = add ? "add" : "remove"; const modules = document.querySelectorAll(".module"); for (const module of modules) { module.classList[method]("alert-blur"); } } }); ================================================ FILE: modules/default/alert/notificationFx.js ================================================ /** * Based on work by * * notificationFx.js v1.0.0 * https://tympanus.net/codrops/ * * Licensed under the MIT license. * https://opensource.org/licenses/mit-license.php * * Copyright 2014, Codrops * https://tympanus.net/codrops/ * @param {object} window The window object */ (function (window) { /** * Extend one object with another one * @param {object} a The object to extend * @param {object} b The object which extends the other, overwrites existing keys * @returns {object} The merged object */ function extend (a, b) { for (let key in b) { if (b.hasOwnProperty(key)) { a[key] = b[key]; } } return a; } /** * NotificationFx constructor * @param {object} options The configuration options * @class */ function NotificationFx (options) { this.options = extend({}, this.options); extend(this.options, options); this._init(); } /** * NotificationFx options */ NotificationFx.prototype.options = { // element to which the notification will be appended // defaults to the document.body wrapper: document.body, // the message message: "yo!", // layout type: growl|attached|bar|other layout: "growl", // effects for the specified layout: // for growl layout: scale|slide|genie|jelly // for attached layout: flip|bouncyflip // for other layout: boxspinner|cornerexpand|loadingcircle|thumbslider // ... effect: "slide", // notice, warning, error, success // will add class ns-type-warning, ns-type-error or ns-type-success type: "notice", // if the user doesn't close the notification then we remove it // after the following time ttl: 6000, al_no: "ns-box", // callbacks onClose () { return false; }, onOpen () { return false; } }; /** * Initialize and cache some vars */ NotificationFx.prototype._init = function () { // create HTML structure this.ntf = document.createElement("div"); this.ntf.className = `${this.options.al_no} ns-${this.options.layout} ns-effect-${this.options.effect} ns-type-${this.options.type}`; let strinner = "
"; strinner += this.options.message; strinner += "
"; this.ntf.innerHTML = strinner; // append to body or the element specified in options.wrapper this.options.wrapper.insertBefore(this.ntf, this.options.wrapper.nextSibling); // dismiss after [options.ttl]ms if (this.options.ttl) { this.dismissttl = setTimeout(() => { if (this.active) { this.dismiss(); } }, this.options.ttl); } // init events this._initEvents(); }; /** * Init events */ NotificationFx.prototype._initEvents = function () { // dismiss notification by tapping on it if someone has a touchscreen this.ntf.querySelector(".ns-box-inner").addEventListener("click", () => { this.dismiss(); }); }; /** * Show the notification */ NotificationFx.prototype.show = function () { this.active = true; this.ntf.classList.remove("ns-hide"); this.ntf.classList.add("ns-show"); this.options.onOpen(); }; /** * Dismiss the notification * @param {boolean} [close] call the onClose callback at the end */ NotificationFx.prototype.dismiss = function (close = true) { this.active = false; clearTimeout(this.dismissttl); this.ntf.classList.remove("ns-show"); setTimeout(() => { this.ntf.classList.add("ns-hide"); // callback if (close) this.options.onClose(); }, 25); // after animation ends remove ntf from the DOM const onEndAnimationFn = (ev) => { if (ev.target !== this.ntf) { return false; } this.ntf.removeEventListener("animationend", onEndAnimationFn); if (ev.target.parentNode === this.options.wrapper) { this.options.wrapper.removeChild(this.ntf); } }; this.ntf.addEventListener("animationend", onEndAnimationFn); }; /** * Add to global namespace */ window.NotificationFx = NotificationFx; }(window)); ================================================ FILE: modules/default/alert/styles/center.css ================================================ .ns-box { margin-left: auto; margin-right: auto; text-align: center; } ================================================ FILE: modules/default/alert/styles/left.css ================================================ .ns-box { margin-right: auto; text-align: left; } ================================================ FILE: modules/default/alert/styles/notificationFx.css ================================================ /* Based on work by https://tympanus.net/codrops/licensing/ */ .ns-box { background-color: rgb(0 0 0 / 93%); padding: 17px; line-height: 1.4; margin-bottom: 10px; z-index: 1; font-size: 70%; position: relative; display: table; overflow-wrap: break-word; max-width: 100%; border-width: 1px; border-radius: 5px; border-style: solid; border-color: var(--color-text-dimmed); } .ns-alert { border-style: solid; border-color: var(--color-text-bright); padding: 17px; line-height: 1.4; margin-bottom: 10px; z-index: 3; color: var(--color-text-bright); font-size: 70%; position: fixed; text-align: center; right: 0; left: 0; margin-right: auto; margin-left: auto; top: 40%; width: 40%; height: auto; overflow-wrap: break-word; border-radius: 20px; } .alert-blur { filter: blur(2px) brightness(50%); } [class^="ns-effect-"].ns-growl.ns-hide, [class*=" ns-effect-"].ns-growl.ns-hide { animation-direction: reverse; } .ns-effect-flip { transform-origin: 50% 100%; backface-visibility: hidden; } .ns-effect-flip.ns-show, .ns-effect-flip.ns-hide { animation-name: anim-flip-front; animation-duration: 0.3s; } .ns-effect-flip.ns-hide { animation-name: anim-flip-back; } @keyframes anim-flip-front { 0% { transform: perspective(1000px) rotate3d(1, 0, 0, -90deg); } 100% { transform: perspective(1000px); } } @keyframes anim-flip-back { 0% { transform: perspective(1000px) rotate3d(1, 0, 0, 90deg); } 100% { transform: perspective(1000px); } } .ns-effect-bouncyflip.ns-show, .ns-effect-bouncyflip.ns-hide { animation-name: flip-in-x; animation-duration: 0.8s; } @keyframes flip-in-x { 0% { transform: perspective(400px) rotate3d(1, 0, 0, -90deg); transition-timing-function: ease-in; } 40% { transform: perspective(400px) rotate3d(1, 0, 0, 20deg); transition-timing-function: ease-out; } 60% { transform: perspective(400px) rotate3d(1, 0, 0, -10deg); transition-timing-function: ease-in; opacity: 1; } 80% { transform: perspective(400px) rotate3d(1, 0, 0, 5deg); transition-timing-function: ease-out; } 100% { transform: perspective(400px); } } .ns-effect-bouncyflip.ns-hide { animation-name: flip-in-x-simple; animation-duration: 0.3s; } @keyframes flip-in-x-simple { 0% { transform: perspective(400px) rotate3d(1, 0, 0, -90deg); transition-timing-function: ease-in; } 100% { transform: perspective(400px); } } .ns-effect-exploader { transform-origin: 0 0; } .ns-effect-exploader p { padding: 0.25em 2em 0.25em 3em; } .ns-effect-exploader.ns-show { animation-name: anim-load; animation-duration: 1s; } @keyframes anim-load { 0% { opacity: 1; transform: scale3d(0, 0.3, 1); } 100% { opacity: 1; transform: scale3d(1, 1, 1); } } .ns-effect-exploader.ns-hide { animation-name: anim-fade; animation-duration: 0.3s; } .ns-effect-exploader.ns-show .ns-box-inner, .ns-effect-exploader.ns-show .ns-close { animation-fill-mode: both; animation-duration: 0.3s; animation-delay: 0.6s; } .ns-effect-exploader.ns-show .ns-close { animation-name: anim-fade; } .ns-effect-exploader.ns-show .ns-box-inner { animation-name: anim-fade-move; animation-timing-function: ease-out; } @keyframes anim-fade-move { 0% { opacity: 0; transform: translate3d(0, 10px, 0); } 100% { opacity: 1; transform: translate3d(0, 0, 0); } } @keyframes anim-fade { 0% { opacity: 0; } 100% { opacity: 1; } } .ns-effect-scale.ns-show, .ns-effect-scale.ns-hide { animation-name: anim-scale; animation-duration: 0.25s; } @keyframes anim-scale { 0% { opacity: 0; transform: translate3d(0, 40px, 0) scale3d(0.1, 0.6, 1); } 100% { opacity: 1; transform: translate3d(0, 0, 0) scale3d(1, 1, 1); } } .ns-effect-jelly.ns-show { animation-name: anim-jelly; animation-duration: 1s; animation-timing-function: linear; } .ns-effect-jelly.ns-hide { animation-name: anim-fade; animation-duration: 0.3s; } @keyframes anim-fade { 0% { opacity: 0; } 100% { opacity: 1; } } @keyframes anim-jelly { 0% { transform: matrix3d(0.7, 0, 0, 0, 0, 0.7, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 2.083333% { transform: matrix3d(0.7527, 0, 0, 0, 0, 0.7634, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 4.166667% { transform: matrix3d(0.8107, 0, 0, 0, 0, 0.8454, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 6.25% { transform: matrix3d(0.8681, 0, 0, 0, 0, 0.929, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 8.333333% { transform: matrix3d(0.9204, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 10.416667% { transform: matrix3d(0.9648, 0, 0, 0, 0, 1.052, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 12.5% { transform: matrix3d(1, 0, 0, 0, 0, 1.082, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 14.583333% { transform: matrix3d(1.0256, 0, 0, 0, 0, 1.0915, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 16.666667% { transform: matrix3d(1.0423, 0, 0, 0, 0, 1.0845, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 18.75% { transform: matrix3d(1.051, 0, 0, 0, 0, 1.0667, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 20.833333% { transform: matrix3d(1.0533, 0, 0, 0, 0, 1.0436, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 22.916667% { transform: matrix3d(1.0508, 0, 0, 0, 0, 1.0201, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 25% { transform: matrix3d(1.0449, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 27.083333% { transform: matrix3d(1.037, 0, 0, 0, 0, 0.9853, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 29.166667% { transform: matrix3d(1.0283, 0, 0, 0, 0, 0.9769, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 31.25% { transform: matrix3d(1.0197, 0, 0, 0, 0, 0.9742, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 33.333333% { transform: matrix3d(1.0119, 0, 0, 0, 0, 0.9762, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 35.416667% { transform: matrix3d(1.0053, 0, 0, 0, 0, 0.9812, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 37.5% { transform: matrix3d(1, 0, 0, 0, 0, 0.9877, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 39.583333% { transform: matrix3d(0.9962, 0, 0, 0, 0, 0.9943, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 41.666667% { transform: matrix3d(0.9937, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 43.75% { transform: matrix3d(0.9924, 0, 0, 0, 0, 1.0041, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 45.833333% { transform: matrix3d(0.992, 0, 0, 0, 0, 1.0065, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 47.916667% { transform: matrix3d(0.9924, 0, 0, 0, 0, 1.0073, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 50% { transform: matrix3d(0.9933, 0, 0, 0, 0, 1.0067, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 52.083333% { transform: matrix3d(0.9945, 0, 0, 0, 0, 1.0053, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 54.166667% { transform: matrix3d(0.9958, 0, 0, 0, 0, 1.0035, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 56.25% { transform: matrix3d(0.997, 0, 0, 0, 0, 1.002, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 58.333333% { transform: matrix3d(0.9982, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 60.416667% { transform: matrix3d(0.9992, 0, 0, 0, 0, 0.9989, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 62.5% { transform: matrix3d(1, 0, 0, 0, 0, 0.9982, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 64.583333% { transform: matrix3d(1.0006, 0, 0, 0, 0, 0.998, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 66.666667% { transform: matrix3d(1.001, 0, 0, 0, 0, 0.9981, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 68.75% { transform: matrix3d(1.0011, 0, 0, 0, 0, 0.9985, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 70.833333% { transform: matrix3d(1.0012, 0, 0, 0, 0, 0.999, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 72.916667% { transform: matrix3d(1.0011, 0, 0, 0, 0, 0.9996, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 75% { transform: matrix3d(1.001, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 77.083333% { transform: matrix3d(1.0008, 0, 0, 0, 0, 1.0003, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 79.166667% { transform: matrix3d(1.0006, 0, 0, 0, 0, 1.0005, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 81.25% { transform: matrix3d(1.0004, 0, 0, 0, 0, 1.0006, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 83.333333% { transform: matrix3d(1.0003, 0, 0, 0, 0, 1.0005, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 85.416667% { transform: matrix3d(1.0001, 0, 0, 0, 0, 1.0004, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 87.5% { transform: matrix3d(1, 0, 0, 0, 0, 1.0003, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 89.583333% { transform: matrix3d(0.9999, 0, 0, 0, 0, 1.0001, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 91.666667% { transform: matrix3d(0.9999, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 93.75% { transform: matrix3d(0.9998, 0, 0, 0, 0, 0.9999, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 95.833333% { transform: matrix3d(0.9998, 0, 0, 0, 0, 0.9999, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 97.916667% { transform: matrix3d(0.9998, 0, 0, 0, 0, 0.9998, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 100% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } } .ns-effect-slide-left.ns-show { animation-name: anim-slide-elastic-left; animation-duration: 1s; animation-timing-function: linear; } @keyframes anim-slide-elastic-left { 0% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -1000, 0, 0, 1); } 1.666667% { transform: matrix3d(1.9293, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -739.2681, 0, 0, 1); } 3.333333% { transform: matrix3d(1.9699, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -521.8255, 0, 0, 1); } 5% { transform: matrix3d(1.709, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -349.2612, 0, 0, 1); } 6.666667% { transform: matrix3d(1.424, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -218.324, 0, 0, 1); } 8.333333% { transform: matrix3d(1.2107, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -123.2985, 0, 0, 1); } 10% { transform: matrix3d(1.0817, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -57.5927, 0, 0, 1); } 11.666667% { transform: matrix3d(1.017, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -14.7237, 0, 0, 1); } 13.333333% { transform: matrix3d(0.9906, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 11.1279, 0, 0, 1); } 15% { transform: matrix3d(0.9848, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 24.8634, 0, 0, 1); } 16.666667% { transform: matrix3d(0.9872, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 30.405, 0, 0, 1); } 18.333333% { transform: matrix3d(0.992, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 30.7528, 0, 0, 1); } 20% { transform: matrix3d(0.9954, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 28.1014, 0, 0, 1); } 21.666667% { transform: matrix3d(0.998, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 23.9827, 0, 0, 1); } 23.333333% { transform: matrix3d(0.9994, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 19.4075, 0, 0, 1); } 25% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 14.9956, 0, 0, 1); } 26.666667% { transform: matrix3d(1.0002, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 11.0858, 0, 0, 1); } 28.333333% { transform: matrix3d(1.0002, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 7.8251, 0, 0, 1); } 30% { transform: matrix3d(1.0002, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 5.2374, 0, 0, 1); } 31.666667% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 3.2739, 0, 0, 1); } 33.333333% { transform: matrix3d(1.0001, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1.8489, 0, 0, 1); } 35% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.8636, 0, 0, 1); } 36.666667% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.2208, 0, 0, 1); } 38.333333% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.1669, 0, 0, 1); } 40% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.3728, 0, 0, 1); } 41.666667% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.4559, 0, 0, 1); } 43.333333% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.4612, 0, 0, 1); } 45% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.421, 0, 0, 1); } 46.666667% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.3596, 0, 0, 1); } 48.333333% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.291, 0, 0, 1); } 50% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.2249, 0, 0, 1); } 51.666667% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.1662, 0, 0, 1); } 53.333333% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.1173, 0, 0, 1); } 55% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0785, 0, 0, 1); } 56.666667% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0491, 0, 0, 1); } 58.333333% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0277, 0, 0, 1); } 60% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.013, 0, 0, 1); } 61.666667% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0033, 0, 0, 1); } 63.333333% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0025, 0, 0, 1); } 65% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0056, 0, 0, 1); } 66.666667% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0068, 0, 0, 1); } 68.333333% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0069, 0, 0, 1); } 70% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0063, 0, 0, 1); } 71.666667% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0054, 0, 0, 1); } 73.333333% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0044, 0, 0, 1); } 75% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0034, 0, 0, 1); } 76.666667% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0025, 0, 0, 1); } 78.333333% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0018, 0, 0, 1); } 80% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0012, 0, 0, 1); } 81.666667% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0007, 0, 0, 1); } 83.333333% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0004, 0, 0, 1); } 85% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0002, 0, 0, 1); } 86.666667% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0001, 0, 0, 1); } 88.333333% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1); } 90% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1); } 91.666667% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1); } 93.333333% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1); } 95% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1); } 96.666667% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1); } 98.333333% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1); } 100% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } } .ns-effect-slide-left.ns-hide { animation-name: anim-slide-left; animation-duration: 0.25s; } @keyframes anim-slide-left { 0% { transform: translate3d(-30px, 0, 0) translate3d(-100%, 0, 0); } 100% { transform: translate3d(0, 0, 0); } } .ns-effect-slide-right.ns-show { animation: anim-slide-elastic-right 2000ms linear both; } @keyframes anim-slide-elastic-right { 0% { transform: matrix3d(2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1000, 0, 0, 1); } 2.15% { transform: matrix3d(1.486, 0, 0, 0, 0, 0.514, 0, 0, 0, 0, 1, 0, 664.594, 0, 0, 1); } 4.1% { transform: matrix3d(1.147, 0, 0, 0, 0, 0.853, 0, 0, 0, 0, 1, 0, 419.708, 0, 0, 1); } 4.3% { transform: matrix3d(1.121, 0, 0, 0, 0, 0.879, 0, 0, 0, 0, 1, 0, 398.136, 0, 0, 1); } 6.46% { transform: matrix3d(0.948, 0, 0, 0, 0, 1.052, 0, 0, 0, 0, 1, 0, 206.714, 0, 0, 1); } 8.11% { transform: matrix3d(0.908, 0, 0, 0, 0, 1.092, 0, 0, 0, 0, 1, 0, 105.491, 0, 0, 1); } 8.61% { transform: matrix3d(0.907, 0, 0, 0, 0, 1.093, 0, 0, 0, 0, 1, 0, 81.572, 0, 0, 1); } 12.11% { transform: matrix3d(0.95, 0, 0, 0, 0, 1.05, 0, 0, 0, 0, 1, 0, -18.434, 0, 0, 1); } 14.16% { transform: matrix3d(0.979, 0, 0, 0, 0, 1.021, 0, 0, 0, 0, 1, 0, -38.734, 0, 0, 1); } 16.12% { transform: matrix3d(0.997, 0, 0, 0, 0, 1.003, 0, 0, 0, 0, 1, 0, -43.356, 0, 0, 1); } 19.72% { transform: matrix3d(1.006, 0, 0, 0, 0, 0.994, 0, 0, 0, 0, 1, 0, -34.155, 0, 0, 1); } 27.23% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -7.839, 0, 0, 1); } 30.83% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -1.951, 0, 0, 1); } 38.34% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1.037, 0, 0, 1); } 41.99% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.812, 0, 0, 1); } 50% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.159, 0, 0, 1); } 60.56% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.025, 0, 0, 1); } 82.78% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.001, 0, 0, 1); } 100% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } } .ns-effect-slide-right.ns-hide { animation-name: anim-slide-right; animation-duration: 0.25s; } @keyframes anim-slide-right { 0% { transform: translate3d(30px, 0, 0) translate3d(100%, 0, 0); } 100% { transform: translate3d(0, 0, 0); } } .ns-effect-slide-center.ns-show { animation: anim-slide-elastic-center 2000ms linear both; } @keyframes anim-slide-elastic-center { 0% { transform: matrix3d(1, 0, 0, 0, 0, 3, 0, 0, 0, 0, 1, 0, 0, -300, 0, 1); } 2.15% { transform: matrix3d(1, 0, 0, 0, 0, 1.971, 0, 0, 0, 0, 1, 0, 0, -199.378, 0, 1); } 4.1% { transform: matrix3d(1, 0, 0, 0, 0, 1.294, 0, 0, 0, 0, 1, 0, 0, -125.912, 0, 1); } 4.3% { transform: matrix3d(1, 0, 0, 0, 0, 1.243, 0, 0, 0, 0, 1, 0, 0, -119.441, 0, 1); } 6.46% { transform: matrix3d(1, 0, 0, 0, 0, 0.895, 0, 0, 0, 0, 1, 0, 0, -62.014, 0, 1); } 8.11% { transform: matrix3d(1, 0, 0, 0, 0, 0.817, 0, 0, 0, 0, 1, 0, 0, -31.647, 0, 1); } 8.61% { transform: matrix3d(1, 0, 0, 0, 0, 0.813, 0, 0, 0, 0, 1, 0, 0, -24.472, 0, 1); } 12.11% { transform: matrix3d(1, 0, 0, 0, 0, 0.9, 0, 0, 0, 0, 1, 0, 0, 5.53, 0, 1); } 14.16% { transform: matrix3d(1, 0, 0, 0, 0, 0.959, 0, 0, 0, 0, 1, 0, 0, 11.62, 0, 1); } 16.12% { transform: matrix3d(1, 0, 0, 0, 0, 0.994, 0, 0, 0, 0, 1, 0, 0, 13.007, 0, 1); } 19.72% { transform: matrix3d(1, 0, 0, 0, 0, 1.012, 0, 0, 0, 0, 1, 0, 0, 10.247, 0, 1); } 27.23% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 2.352, 0, 1); } 30.83% { transform: matrix3d(1, 0, 0, 0, 0, 0.999, 0, 0, 0, 0, 1, 0, 0, 0.585, 0, 1); } 38.34% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, -0.311, 0, 1); } 41.99% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, -0.244, 0, 1); } 50% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, -0.048, 0, 1); } 60.56% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0.007, 0, 1); } 82.78% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } 100% { transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1); } } .ns-effect-slide-center.ns-hide { animation-name: anim-slide-center; animation-duration: 0.25s; } @keyframes anim-slide-center { 0% { transform: translate3d(0, -30px, 0) translate3d(0, -100%, 0); } 100% { transform: translate3d(0, 0, 0); } } .ns-effect-genie.ns-show, .ns-effect-genie.ns-hide { animation-name: anim-genie; animation-duration: 0.4s; } @keyframes anim-genie { 0% { opacity: 0; transform: translate3d(0, calc(200% + 30px), 0) scale3d(0, 1, 1); animation-timing-function: ease-in; } 40% { opacity: 0.5; transform: translate3d(0, 0, 0) scale3d(0.02, 1.1, 1); animation-timing-function: ease-out; } 70% { opacity: 0.6; transform: translate3d(0, -40px, 0) scale3d(0.8, 1.1, 1); } 100% { opacity: 1; transform: translate3d(0, 0, 0) scale3d(1, 1, 1); } } ================================================ FILE: modules/default/alert/styles/right.css ================================================ .ns-box { margin-left: auto; text-align: right; } ================================================ FILE: modules/default/alert/templates/alert.njk ================================================ {% if imageUrl or imageFA %} {% set imageHeight = imageHeight if imageHeight else "80px" %} {% if imageUrl %} {% else %} {% endif %}
{% endif %} {% if title %} {{ title if titleType == 'text' else title | safe }} {% endif %} {% if message %} {% if title %}
{% endif %} {{ message if messageType == 'text' else message | safe }} {% endif %} ================================================ FILE: modules/default/alert/templates/notification.njk ================================================ {% if title %} {{ title if titleType == 'text' else title | safe }} {% endif %} {% if message %} {% if title %}
{% endif %} {{ message if messageType == 'text' else message | safe }} {% endif %} ================================================ FILE: modules/default/alert/translations/bg.json ================================================ { "sysTitle": "MagicMirror² нотификация", "welcome": "Добре дошли, стартирането беше успешно" } ================================================ FILE: modules/default/alert/translations/da.json ================================================ { "sysTitle": "MagicMirror² Notifikation", "welcome": "Velkommen, modulet er succesfuldt startet!" } ================================================ FILE: modules/default/alert/translations/de.json ================================================ { "sysTitle": "MagicMirror² Benachrichtigung", "welcome": "Willkommen, Start war erfolgreich!" } ================================================ FILE: modules/default/alert/translations/el.json ================================================ { "sysTitle": "MagicMirror² Ειδοποίηση", "welcome": "Καλώς ήρθατε, η εκκίνηση ήταν επιτυχής!" } ================================================ FILE: modules/default/alert/translations/en.json ================================================ { "sysTitle": "MagicMirror² Notification", "welcome": "Welcome, start was successful!" } ================================================ FILE: modules/default/alert/translations/eo.json ================================================ { "sysTitle": "MagicMirror²-sciigo", "welcome": "Bonvenon, lanĉo sukcesis!" } ================================================ FILE: modules/default/alert/translations/es.json ================================================ { "sysTitle": "MagicMirror² Notificaciones", "welcome": "Bienvenido, ¡se iniciado correctamente!" } ================================================ FILE: modules/default/alert/translations/fr.json ================================================ { "sysTitle": "MagicMirror² Notification", "welcome": "Bienvenue, le démarrage a été un succès!" } ================================================ FILE: modules/default/alert/translations/hu.json ================================================ { "sysTitle": "MagicMirror² értesítés", "welcome": "Üdvözöljük, indulás sikeres!" } ================================================ FILE: modules/default/alert/translations/nl.json ================================================ { "sysTitle": "MagicMirror² Notificatie", "welcome": "Welkom, Succesvol gestart!" } ================================================ FILE: modules/default/alert/translations/pt-br.json ================================================ { "sysTitle": "Notificação do MagicMirror²", "welcome": "Bem-vindo, o sistema iniciou com sucesso!" } ================================================ FILE: modules/default/alert/translations/pt.json ================================================ { "sysTitle": "Notificação do MagicMirror²", "welcome": "Bem-vindo, o sistema iniciou com sucesso!" } ================================================ FILE: modules/default/alert/translations/ru.json ================================================ { "sysTitle": "MagicMirror² Уведомление", "welcome": "Добро пожаловать, старт был успешным!" } ================================================ FILE: modules/default/alert/translations/th.json ================================================ { "sysTitle": "การแจ้งเตือน MagicMirror²", "welcome": "ยินดีต้อนรับ การเริ่มต้นสำเร็จแล้ว!" } ================================================ FILE: modules/default/calendar/README.md ================================================ # Module: Calendar The `calendar` module is one of the default modules of the MagicMirror². This module displays events from a public .ical calendar. It can combine multiple calendars. For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/calendar.html). ================================================ FILE: modules/default/calendar/calendar.css ================================================ .calendar .symbol { display: flex; flex-direction: row; justify-content: flex-end; gap: 5px; } .calendar .title { padding: 0 10px; } .calendar .time { padding-left: 20px; text-align: right; } ================================================ FILE: modules/default/calendar/calendar.js ================================================ /* global CalendarUtils */ Module.register("calendar", { // Define module defaults defaults: { maximumEntries: 10, // Total Maximum Entries maximumNumberOfDays: 365, limitDays: 0, // Limit the number of days shown, 0 = no limit pastDaysCount: 0, displaySymbol: true, defaultSymbol: "calendar-days", // Fontawesome Symbol see https://fontawesome.com/search?ic=free&o=r defaultSymbolClassName: "fas fa-fw fa-", showLocation: false, displayRepeatingCountTitle: false, defaultRepeatingCountTitle: "", maxTitleLength: 25, maxLocationTitleLength: 25, wrapEvents: false, // Wrap events to multiple lines breaking at maxTitleLength wrapLocationEvents: false, maxTitleLines: 3, maxEventTitleLines: 3, fetchInterval: 60 * 60 * 1000, // Update every hour animationSpeed: 2000, fade: true, fadePoint: 0.25, // Start on 1/4th of the list. urgency: 7, timeFormat: "relative", dateFormat: "MMM Do", dateEndFormat: "LT", fullDayEventDateFormat: "MMM Do", showEnd: false, showEndsOnlyWithDuration: false, getRelative: 6, hidePrivate: false, hideOngoing: false, hideTime: false, hideDuplicates: true, showTimeToday: false, colored: false, forceUseCurrentTime: false, tableClass: "small", calendars: [ { symbol: "calendar-alt", url: "https://www.calendarlabs.com/templates/ical/US-Holidays.ics" } ], customEvents: [ // Array of {keyword: "", symbol: "", color: "", eventClass: ""} where Keyword is a regexp and symbol/color/eventClass are to be applied for matched { keyword: ".*", transform: { search: "De verjaardag van ", replace: "" } }, { keyword: ".*", transform: { search: "'s birthday", replace: "" } } ], locationTitleReplace: { "street ": "" }, broadcastEvents: true, excludedEvents: [], sliceMultiDayEvents: false, broadcastPastEvents: false, nextDaysRelative: false, selfSignedCert: false, coloredText: false, coloredBorder: false, coloredSymbol: false, coloredBackground: false, limitDaysNeverSkip: false, flipDateHeaderTitle: false, updateOnFetch: true }, // Define required scripts. getStyles () { return ["calendar.css", "font-awesome.css"]; }, // Define required scripts. getScripts () { return ["calendarutils.js", "moment.js", "moment-timezone.js"]; }, // Define required translations. getTranslations () { /* * The translations for the default modules are defined in the core translation files. * 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; }, // Override start method. start () { Log.info(`Starting module: ${this.name}`); if (this.config.colored) { Log.warn("[calendar] Your are using the deprecated config values 'colored'. Please switch to 'coloredSymbol' & 'coloredText'!"); this.config.coloredText = true; this.config.coloredSymbol = true; } if (this.config.coloredSymbolOnly) { Log.warn("[calendar] Your are using the deprecated config values 'coloredSymbolOnly'. Please switch to 'coloredSymbol' & 'coloredText'!"); this.config.coloredText = false; this.config.coloredSymbol = true; } // Set locale. moment.updateLocale(config.language, CalendarUtils.getLocaleSpecification(config.timeFormat)); // clear data holder before start this.calendarData = {}; // indicate no data available yet this.loaded = false; // data holder of calendar url. Avoid fade out/in on updateDom (one for each calendar update) this.calendarDisplayer = {}; this.config.calendars.forEach((calendar) => { calendar.url = calendar.url.replace("webcal://", "http://"); const calendarConfig = { maximumEntries: calendar.maximumEntries, maximumNumberOfDays: calendar.maximumNumberOfDays, pastDaysCount: calendar.pastDaysCount, broadcastPastEvents: calendar.broadcastPastEvents, selfSignedCert: calendar.selfSignedCert, excludedEvents: calendar.excludedEvents, fetchInterval: calendar.fetchInterval }; if (typeof calendar.symbolClass === "undefined" || calendar.symbolClass === null) { calendarConfig.symbolClass = ""; } if (typeof calendar.titleClass === "undefined" || calendar.titleClass === null) { calendarConfig.titleClass = ""; } if (typeof calendar.timeClass === "undefined" || calendar.timeClass === null) { calendarConfig.timeClass = ""; } // we check user and password here for backwards compatibility with old configs if (calendar.user && calendar.pass) { Log.warn("[calendar] Deprecation warning: Please update your calendar authentication configuration."); Log.warn("https://docs.magicmirror.builders/modules/calendar.html#configuration-options"); calendar.auth = { user: calendar.user, pass: calendar.pass }; } /* * tell helper to start a fetcher for this calendar * fetcher till cycle */ this.addCalendar(calendar.url, calendar.auth, calendarConfig); }); // for backward compatibility titleReplace if (typeof this.config.titleReplace !== "undefined") { Log.warn("[calendar] Deprecation warning: Please consider upgrading your calendar titleReplace configuration to customEvents."); for (const [titlesearchstr, titlereplacestr] of Object.entries(this.config.titleReplace)) { this.config.customEvents.push({ keyword: ".*", transform: { search: titlesearchstr, replace: titlereplacestr } }); } } this.selfUpdate(); }, notificationReceived (notification, payload, sender) { if (notification === "FETCH_CALENDAR") { if (this.hasCalendarURL(payload.url)) { this.sendSocketNotification(notification, { url: payload.url, id: this.identifier }); } } }, // Override socket notification handler. socketNotificationReceived (notification, payload) { if (this.identifier !== payload.id) { return; } if (notification === "CALENDAR_EVENTS") { if (this.hasCalendarURL(payload.url)) { // have we received events for this url if (!this.calendarData[payload.url]) { // no, setup the structure to hold the info this.calendarData[payload.url] = { events: null, checksum: null }; } // save the event list this.calendarData[payload.url].events = payload.events; this.error = null; this.loaded = true; if (this.config.broadcastEvents) { this.broadcastEvents(); } // if the checksum is the same if (this.calendarData[payload.url].checksum === payload.checksum) { // then don't update the UI return; } // haven't seen or the checksum is different this.calendarData[payload.url].checksum = payload.checksum; if (!this.config.updateOnFetch) { if (this.calendarDisplayer[payload.url] === undefined) { // calendar will never displayed, so display it this.updateDom(this.config.animationSpeed); // set this calendar as displayed this.calendarDisplayer[payload.url] = true; } else { Log.debug("[calendar] DOM not updated waiting self update()"); } return; } } } else if (notification === "CALENDAR_ERROR") { let error_message = this.translate(payload.error_type); this.error = this.translate("MODULE_CONFIG_ERROR", { MODULE_NAME: this.name, ERROR: error_message }); this.loaded = true; } this.updateDom(this.config.animationSpeed); }, // Override dom generator. getDom () { const events = this.createEventList(true); const wrapper = document.createElement("table"); wrapper.className = this.config.tableClass; if (this.error) { wrapper.innerHTML = this.error; wrapper.className = `${this.config.tableClass} dimmed`; return wrapper; } if (events.length === 0) { wrapper.innerHTML = this.loaded ? this.translate("EMPTY") : this.translate("LOADING"); wrapper.className = `${this.config.tableClass} dimmed`; return wrapper; } let currentFadeStep = 0; let startFade; let fadeSteps; if (this.config.fade && this.config.fadePoint < 1) { if (this.config.fadePoint < 0) { this.config.fadePoint = 0; } startFade = events.length * this.config.fadePoint; fadeSteps = events.length - startFade; } let lastSeenDate = ""; events.forEach((event, index) => { 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"); dateRow.className = "dateheader normal"; if (event.today) dateRow.className += " today"; else if (event.dayBeforeYesterday) dateRow.className += " dayBeforeYesterday"; else if (event.yesterday) dateRow.className += " yesterday"; else if (event.tomorrow) dateRow.className += " tomorrow"; else if (event.dayAfterTomorrow) dateRow.className += " dayAfterTomorrow"; const dateCell = document.createElement("td"); dateCell.colSpan = "3"; dateCell.innerHTML = dateAsString; dateCell.style.paddingTop = "10px"; dateRow.appendChild(dateCell); wrapper.appendChild(dateRow); if (this.config.fade && index >= startFade) { //fading currentFadeStep = index - startFade; dateRow.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep; } lastSeenDate = dateAsString; } } const eventWrapper = document.createElement("tr"); if (this.config.coloredText) { eventWrapper.style.cssText = `color:${this.colorForUrl(event.url, false)}`; } if (this.config.coloredBackground) { eventWrapper.style.backgroundColor = this.colorForUrl(event.url, true); } if (this.config.coloredBorder) { eventWrapper.style.borderColor = this.colorForUrl(event.url, false); } eventWrapper.className = "event-wrapper normal event"; if (event.today) eventWrapper.className += " today"; else if (event.dayBeforeYesterday) eventWrapper.className += " dayBeforeYesterday"; else if (event.yesterday) eventWrapper.className += " yesterday"; else if (event.tomorrow) eventWrapper.className += " tomorrow"; else if (event.dayAfterTomorrow) eventWrapper.className += " dayAfterTomorrow"; const symbolWrapper = document.createElement("td"); if (this.config.displaySymbol) { if (this.config.coloredSymbol) { symbolWrapper.style.cssText = `color:${this.colorForUrl(event.url, false)}`; } const symbolClass = this.symbolClassForUrl(event.url); symbolWrapper.className = `symbol ${symbolClass}`; const symbols = this.symbolsForEvent(event); symbols.forEach((s) => { const symbol = document.createElement("span"); symbol.className = s; symbolWrapper.appendChild(symbol); }); eventWrapper.appendChild(symbolWrapper); } else if (this.config.timeFormat === "dateheaders") { const blankCell = document.createElement("td"); blankCell.innerHTML = "   "; eventWrapper.appendChild(blankCell); } const titleWrapper = document.createElement("td"); let repeatingCountTitle = ""; if (this.config.displayRepeatingCountTitle && event.firstYear !== undefined) { repeatingCountTitle = this.countTitleForUrl(event.url); if (repeatingCountTitle !== "") { const thisYear = eventStartDateMoment.year(), yearDiff = thisYear - event.firstYear; if (yearDiff > 0) { repeatingCountTitle = `, ${yearDiff} ${repeatingCountTitle}`; } } } var transformedTitle = event.title; // Color events if custom color or eventClass are specified, transform title if required if (this.config.customEvents.length > 0) { for (let ev in this.config.customEvents) { let needle = new RegExp(this.config.customEvents[ev].keyword, "gi"); if (needle.test(event.title)) { if (typeof this.config.customEvents[ev].transform === "object") { transformedTitle = CalendarUtils.titleTransform(transformedTitle, [this.config.customEvents[ev].transform]); } if (typeof this.config.customEvents[ev].color !== "undefined" && this.config.customEvents[ev].color !== "") { // Respect parameter ColoredSymbolOnly also for custom events if (this.config.coloredText) { eventWrapper.style.cssText = `color:${this.config.customEvents[ev].color}`; titleWrapper.style.cssText = `color:${this.config.customEvents[ev].color}`; } if (this.config.displaySymbol && this.config.coloredSymbol) { symbolWrapper.style.cssText = `color:${this.config.customEvents[ev].color}`; } } if (typeof this.config.customEvents[ev].eventClass !== "undefined" && this.config.customEvents[ev].eventClass !== "") { eventWrapper.className += ` ${this.config.customEvents[ev].eventClass}`; } } } } titleWrapper.innerHTML = CalendarUtils.shorten(transformedTitle, this.config.maxTitleLength, this.config.wrapEvents, this.config.maxTitleLines) + repeatingCountTitle; const titleClass = this.titleClassForUrl(event.url); if (!this.config.coloredText) { titleWrapper.className = `title bright ${titleClass}`; } else { titleWrapper.className = `title ${titleClass}`; } if (this.config.timeFormat === "dateheaders") { if (this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper); if (event.fullDayEvent) { titleWrapper.colSpan = "2"; titleWrapper.classList.add("align-left"); } else { const timeWrapper = document.createElement("td"); 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 = 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(eventEndDateMoment.format("LT"))}`; } } eventWrapper.appendChild(timeWrapper); if (!this.config.flipDateHeaderTitle) titleWrapper.classList.add("align-right"); } if (!this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper); } else { const timeWrapper = document.createElement("td"); eventWrapper.appendChild(titleWrapper); const now = moment(); if (this.config.timeFormat === "absolute") { // Use dateFormat timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.dateFormat)); // Add end time if showEnd if (this.config.showEnd) { // and has a duration if (event.startDate !== event.endDate) { timeWrapper.innerHTML += "-"; 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 eventEndDateMoment.subtract(1, "second"); timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.fullDayEventDateFormat)); // only show end if requested and allowed and the dates are different if (this.config.showEnd && !this.config.showEndsOnlyWithDuration && !eventStartDateMoment.isSame(eventEndDateMoment, "d")) { timeWrapper.innerHTML += "-"; timeWrapper.innerHTML += CalendarUtils.capFirst(eventEndDateMoment.format(this.config.fullDayEventDateFormat)); } else if (!eventStartDateMoment.isSame(eventEndDateMoment, "d") && eventStartDateMoment.isBefore(now)) { timeWrapper.innerHTML = CalendarUtils.capFirst(now.format(this.config.fullDayEventDateFormat)); } } else if (this.config.getRelative > 0 && eventStartDateMoment.isBefore(now)) { // Ongoing and getRelative is set timeWrapper.innerHTML = CalendarUtils.capFirst( this.translate("RUNNING", { fallback: `${this.translate("RUNNING")} {timeUntilEnd}`, timeUntilEnd: eventEndDateMoment.fromNow(true) }) ); } else if (this.config.urgency > 0 && eventStartDateMoment.diff(now, "d") < this.config.urgency) { // Within urgency days timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.fromNow()); } if (event.fullDayEvent && this.config.nextDaysRelative) { // Full days events within the next two days if (event.today) { timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY")); } else if (event.yesterday) { timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY")); } else if (event.tomorrow) { timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW")); } else if (event.dayAfterTomorrow) { if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") { timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW")); } } } } else { // Show relative times if (eventStartDateMoment.isSameOrAfter(now) || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) { // Use relative time if (!this.config.hideTime && !event.fullDayEvent) { Log.debug("[calendar] event not hidden and not fullday"); timeWrapper.innerHTML = `${CalendarUtils.capFirst(eventStartDateMoment.calendar(null, { sameElse: this.config.dateFormat }))}`; } else { Log.debug("[calendar] event full day or hidden"); timeWrapper.innerHTML = `${CalendarUtils.capFirst( eventStartDateMoment.calendar(null, { sameDay: this.config.showTimeToday ? "LT" : `[${this.translate("TODAY")}]`, nextDay: `[${this.translate("TOMORROW")}]`, nextWeek: "dddd", sameElse: event.fullDayEvent ? this.config.fullDayEventDateFormat : this.config.dateFormat }) )}`; } if (event.fullDayEvent) { // Full days events within the next two days if (event.today || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) { timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY")); } else if (event.dayBeforeYesterday) { if (this.translate("DAYBEFOREYESTERDAY") !== "DAYBEFOREYESTERDAY") { timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYBEFOREYESTERDAY")); } } else if (event.yesterday) { timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY")); } else if (event.tomorrow) { timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW")); } else if (event.dayAfterTomorrow) { if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") { timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW")); } } Log.info("[calendar] event fullday"); } else if (eventStartDateMoment.diff(now, "h") < this.config.getRelative) { Log.info("[calendar] not full day but within getRelative size"); // If event is within getRelative hours, display 'in xxx' time format or moment.fromNow() timeWrapper.innerHTML = `${CalendarUtils.capFirst(eventStartDateMoment.fromNow())}`; } } else { // Ongoing event timeWrapper.innerHTML = CalendarUtils.capFirst( this.translate("RUNNING", { fallback: `${this.translate("RUNNING")} {timeUntilEnd}`, timeUntilEnd: eventEndDateMoment.fromNow(true) }) ); } } timeWrapper.className = `time light ${this.timeClassForUrl(event.url)}`; eventWrapper.appendChild(timeWrapper); } // Create fade effect. if (index >= startFade) { currentFadeStep = index - startFade; eventWrapper.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep; } wrapper.appendChild(eventWrapper); if (this.config.showLocation) { if (event.location !== false) { const locationRow = document.createElement("tr"); locationRow.className = "event-wrapper-location normal xsmall light"; if (event.today) locationRow.className += " today"; else if (event.dayBeforeYesterday) locationRow.className += " dayBeforeYesterday"; else if (event.yesterday) locationRow.className += " yesterday"; else if (event.tomorrow) locationRow.className += " tomorrow"; else if (event.dayAfterTomorrow) locationRow.className += " dayAfterTomorrow"; if (this.config.displaySymbol) { const symbolCell = document.createElement("td"); locationRow.appendChild(symbolCell); } if (this.config.coloredText) { locationRow.style.cssText = `color:${this.colorForUrl(event.url, false)}`; } if (this.config.coloredBackground) { locationRow.style.backgroundColor = this.colorForUrl(event.url, true); } if (this.config.coloredBorder) { locationRow.style.borderColor = this.colorForUrl(event.url, false); } const descCell = document.createElement("td"); descCell.className = "location"; descCell.colSpan = "2"; const transformedTitle = CalendarUtils.titleTransform(event.location, this.config.locationTitleReplace); descCell.innerHTML = CalendarUtils.shorten(transformedTitle, this.config.maxLocationTitleLength, this.config.wrapLocationEvents, this.config.maxEventTitleLines); locationRow.appendChild(descCell); wrapper.appendChild(locationRow); if (index >= startFade) { currentFadeStep = index - startFade; locationRow.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep; } } } }); return wrapper; }, /** * Checks if this config contains the calendar url. * @param {string} url The calendar url * @returns {boolean} True if the calendar config contains the url, False otherwise */ hasCalendarURL (url) { for (const calendar of this.config.calendars) { if (calendar.url === url) { return true; } } 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) { let now = moment(); let future = now.clone().startOf("day").add(this.config.maximumNumberOfDays, "days"); let events = []; for (const calendarUrl in this.calendarData) { const calendar = this.calendarData[calendarUrl].events; let remainingEntries = this.maximumEntriesForUrl(calendarUrl); 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 (eventEndDateMoment.isBefore(maxPastDaysCompare)) { continue; } if (this.config.hideOngoing && eventStartDateMoment.isBefore(now)) { continue; } if (this.config.hideDuplicates && this.listContainsEvent(events, event)) { continue; } } event.url = calendarUrl; 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 = eventEndDateMoment.diff(eventStartDateMoment, "days"); if (this.config.sliceMultiDayEvents && maxCount > 1) { const splitEvents = []; let midnight = eventStartDateMoment .clone() .startOf("day") .add(1, "day") .endOf("day"); let count = 1; while (eventEndDateMoment.isAfter(midnight)) { const thisEvent = JSON.parse(JSON.stringify(event)); // clone object 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.format("x"); count += 1; midnight = midnight.clone().add(1, "day").endOf("day"); // next day } // Last day event.title += ` (${count}/${maxCount})`; 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 (this.timestampToMoment(splitEvent.endDate).isAfter(now) && this.timestampToMoment(splitEvent.endDate).isSameOrBefore(future)) { by_url_calevents.push(splitEvent); } } } else { by_url_calevents.push(event); } } if (limitNumberOfEntries) { // sort entries before clipping by_url_calevents.sort(function (a, b) { return a.startDate - b.startDate; }); Log.debug(`[calendar] pushing ${by_url_calevents.length} events to total with room for ${remainingEntries}`); events = events.concat(by_url_calevents.slice(0, remainingEntries)); Log.debug(`[calendar] events for calendar=${events.length}`); } else { events = events.concat(by_url_calevents); } } Log.info(`[calendar] sorting events count=${events.length}`); events.sort(function (a, b) { return a.startDate - b.startDate; }); if (!limitNumberOfEntries) { return events; } /* * Limit the number of days displayed * If limitDays is set > 0, limit display to that number of days */ if (this.config.limitDays > 0 && events.length > 0) { // watch out for initial display before events arrive from helper // Group all events by date, events on the same date will be in a list with the key being the date. const eventsByDate = Object.groupBy(events, (ev) => this.timestampToMoment(ev.startDate).format("YYYY-MM-DD")); const newEvents = []; let currentDate = moment(); let daysCollected = 0; while (daysCollected < this.config.limitDays) { const dateStr = currentDate.format("YYYY-MM-DD"); // Check if there are events on the currentDate if (eventsByDate[dateStr] && eventsByDate[dateStr].length > 0) { // If there are any events today then get all those events and select the currently active events and the events that are starting later in the day. newEvents.push(...eventsByDate[dateStr].filter((ev) => this.timestampToMoment(ev.endDate).isAfter(moment()))); // Since we found a day with events, increase the daysCollected by 1 daysCollected++; } // Search for the next day currentDate.add(1, "day"); } events = newEvents; } Log.info(`[calendar] slicing events total maxCount=${this.config.maximumEntries}`); return events.slice(0, this.config.maximumEntries); }, listContainsEvent (eventList, event) { for (const evt of eventList) { if (evt.title === event.title && parseInt(evt.startDate) === parseInt(event.startDate) && parseInt(evt.endDate) === parseInt(event.endDate)) { return true; } } return false; }, /** * Requests node helper to add calendar url. * @param {string} url The calendar url to add * @param {object} auth The authentication method and credentials * @param {object} calendarConfig The config of the specific calendar */ addCalendar (url, auth, calendarConfig) { this.sendSocketNotification("ADD_CALENDAR", { id: this.identifier, url: url, excludedEvents: calendarConfig.excludedEvents || this.config.excludedEvents, maximumEntries: calendarConfig.maximumEntries || this.config.maximumEntries, maximumNumberOfDays: calendarConfig.maximumNumberOfDays || this.config.maximumNumberOfDays, pastDaysCount: calendarConfig.pastDaysCount || this.config.pastDaysCount, fetchInterval: calendarConfig.fetchInterval || this.config.fetchInterval, symbolClass: calendarConfig.symbolClass, titleClass: calendarConfig.titleClass, timeClass: calendarConfig.timeClass, auth: auth, broadcastPastEvents: calendarConfig.broadcastPastEvents || this.config.broadcastPastEvents, selfSignedCert: calendarConfig.selfSignedCert || this.config.selfSignedCert }); }, /** * Retrieves the symbols for a specific event. * @param {object} event Event to look for. * @returns {string[]} The symbols */ symbolsForEvent (event) { let symbols = this.getCalendarPropertyAsArray(event.url, "symbol", this.config.defaultSymbol); if (event.recurringEvent === true && this.hasCalendarProperty(event.url, "recurringSymbol")) { symbols = this.mergeUnique(this.getCalendarPropertyAsArray(event.url, "recurringSymbol", this.config.defaultSymbol), symbols); } if (event.fullDayEvent === true && this.hasCalendarProperty(event.url, "fullDaySymbol")) { symbols = this.mergeUnique(this.getCalendarPropertyAsArray(event.url, "fullDaySymbol", this.config.defaultSymbol), symbols); } // If custom symbol is set, replace event symbol for (let ev of this.config.customEvents) { if (typeof ev.symbol !== "undefined" && ev.symbol !== "") { let needle = new RegExp(ev.keyword, "gi"); if (needle.test(event.title)) { // Get the default prefix for this class name and add to the custom symbol provided const className = this.getCalendarProperty(event.url, "symbolClassName", this.config.defaultSymbolClassName); symbols[0] = className + ev.symbol; break; } } } return symbols; }, mergeUnique (arr1, arr2) { return arr1.concat( arr2.filter(function (item) { return arr1.indexOf(item) === -1; }) ); }, /** * Retrieves the symbolClass for a specific calendar url. * @param {string} url The calendar url * @returns {string} The class to be used for the symbols of the calendar */ symbolClassForUrl (url) { return this.getCalendarProperty(url, "symbolClass", ""); }, /** * Retrieves the titleClass for a specific calendar url. * @param {string} url The calendar url * @returns {string} The class to be used for the title of the calendar */ titleClassForUrl (url) { return this.getCalendarProperty(url, "titleClass", ""); }, /** * Retrieves the timeClass for a specific calendar url. * @param {string} url The calendar url * @returns {string} The class to be used for the time of the calendar */ timeClassForUrl (url) { return this.getCalendarProperty(url, "timeClass", ""); }, /** * Retrieves the calendar name for a specific calendar url. * @param {string} url The calendar url * @returns {string} The name of the calendar */ calendarNameForUrl (url) { return this.getCalendarProperty(url, "name", ""); }, /** * Retrieves the color for a specific calendar url. * @param {string} url The calendar url * @param {boolean} isBg Determines if we fetch the bgColor or not * @returns {string} The color */ colorForUrl (url, isBg) { return this.getCalendarProperty(url, isBg ? "bgColor" : "color", "#fff"); }, /** * Retrieves the count title for a specific calendar url. * @param {string} url The calendar url * @returns {string} The title */ countTitleForUrl (url) { return this.getCalendarProperty(url, "repeatingCountTitle", this.config.defaultRepeatingCountTitle); }, /** * Retrieves the maximum entry count for a specific calendar url. * @param {string} url The calendar url * @returns {number} The maximum entry count */ maximumEntriesForUrl (url) { return this.getCalendarProperty(url, "maximumEntries", this.config.maximumEntries); }, /** * Retrieves the maximum count of past days which events of should be displayed for a specific calendar url. * @param {string} url The calendar url * @returns {number} The maximum past days count */ maximumPastDaysForUrl (url) { return this.getCalendarProperty(url, "pastDaysCount", this.config.pastDaysCount); }, /** * Helper method to retrieve the property for a specific calendar url. * @param {string} url The calendar url * @param {string} property The property to look for * @param {string} defaultValue The value if the property is not found * @returns {string} The property */ getCalendarProperty (url, property, defaultValue) { for (const calendar of this.config.calendars) { if (calendar.url === url && calendar.hasOwnProperty(property)) { return calendar[property]; } } return defaultValue; }, getCalendarPropertyAsArray (url, property, defaultValue) { 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) { let t = []; p.forEach((n) => { t.push(className + n); }); p = t; } else p = className + p; } if (!(p instanceof Array)) p = [p]; return p; }, hasCalendarProperty (url, property) { return !!this.getCalendarProperty(url, property, undefined); }, /** * Broadcasts the events to all other modules for reuse. * The all events available in one array, sorted on startDate. */ broadcastEvents () { const eventList = this.createEventList(false); for (const event of eventList) { event.symbol = this.symbolsForEvent(event); event.calendarName = this.calendarNameForUrl(event.url); event.color = this.colorForUrl(event.url, false); delete event.url; } this.sendNotification("CALENDAR_EVENTS", eventList); }, /** * Refresh the DOM every minute if needed: When using relative date format for events that start * or end in less than an hour, the date shows minute granularity and we want to keep that accurate. * -- * When updateOnFetch is not set, it will Avoid fade out/in on updateDom when many calendars are used * and it's allow to refresh The DOM every minute with animation speed too * (because updateDom is not set in CALENDAR_EVENTS for this case) */ selfUpdate () { const ONE_MINUTE = 60 * 1000; setTimeout( () => { setInterval(() => { Log.debug("[calendar] self update"); if (this.config.updateOnFetch) { this.updateDom(1); } else { this.updateDom(this.config.animationSpeed); } }, ONE_MINUTE); }, ONE_MINUTE - (new Date() % ONE_MINUTE) ); } }); ================================================ FILE: modules/default/calendar/calendarfetcher.js ================================================ const https = require("node:https"); const ical = require("node-ical"); const Log = require("logger"); const CalendarFetcherUtils = require("./calendarfetcherutils"); const { getUserAgent } = require("#server_functions"); const FIFTEEN_MINUTES = 15 * 60 * 1000; const THIRTY_MINUTES = 30 * 60 * 1000; const MAX_SERVER_BACKOFF = 3; /** * CalendarFetcher - Fetches and parses iCal calendar data with MagicMirror-focused error handling * @class */ class CalendarFetcher { /** * Creates a new CalendarFetcher instance * @param {string} url - The URL of the calendar to fetch * @param {number} reloadInterval - Time in ms between fetches * @param {string[]} excludedEvents - Event titles to exclude * @param {number} maximumEntries - Maximum number of events to return * @param {number} maximumNumberOfDays - Maximum days in the future to fetch * @param {object} auth - Authentication options {method: 'basic'|'bearer', user, pass} * @param {boolean} includePastEvents - Whether to include past events * @param {boolean} selfSignedCert - Whether to accept self-signed certificates */ constructor (url, reloadInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, includePastEvents, selfSignedCert) { this.url = url; this.reloadInterval = reloadInterval; this.excludedEvents = excludedEvents; this.maximumEntries = maximumEntries; this.maximumNumberOfDays = maximumNumberOfDays; this.auth = auth; this.includePastEvents = includePastEvents; this.selfSignedCert = selfSignedCert; this.events = []; this.reloadTimer = null; this.serverErrorCount = 0; this.lastFetch = null; this.fetchFailedCallback = () => {}; this.eventsReceivedCallback = () => {}; } /** * Clears any pending reload timer */ clearReloadTimer () { if (this.reloadTimer) { clearTimeout(this.reloadTimer); this.reloadTimer = null; } } /** * Schedules the next fetch respecting MagicMirror test mode * @param {number} delay - Delay in milliseconds */ scheduleNextFetch (delay) { const nextDelay = Math.max(delay || this.reloadInterval, this.reloadInterval); if (process.env.mmTestMode === "true") { return; } this.reloadTimer = setTimeout(() => this.fetchCalendar(), nextDelay); } /** * Builds the options object for fetch * @returns {object} Options object containing headers (and agent if needed) */ getRequestOptions () { const headers = { "User-Agent": getUserAgent() }; const options = { headers }; if (this.selfSignedCert) { options.agent = new https.Agent({ rejectUnauthorized: false }); } if (this.auth) { if (this.auth.method === "bearer") { headers.Authorization = `Bearer ${this.auth.pass}`; } else { headers.Authorization = `Basic ${Buffer.from(`${this.auth.user}:${this.auth.pass}`).toString("base64")}`; } } return options; } /** * Parses the Retry-After header value * @param {string} retryAfter - The Retry-After header value * @returns {number|null} Milliseconds to wait or null if parsing failed */ parseRetryAfter (retryAfter) { const seconds = Number(retryAfter); if (!Number.isNaN(seconds) && seconds >= 0) { return seconds * 1000; } const retryDate = Date.parse(retryAfter); if (!Number.isNaN(retryDate)) { return Math.max(0, retryDate - Date.now()); } return null; } /** * Determines the retry delay for a non-ok response * @param {Response} response - The fetch Response object * @returns {{delay: number, error: Error}} Error describing the issue and computed retry delay */ getDelayForResponse (response) { const { status, statusText = "" } = response; let delay = this.reloadInterval; if (status === 401 || status === 403) { delay = Math.max(this.reloadInterval * 5, THIRTY_MINUTES); Log.error(`${this.url} - Authentication failed (${status}). Waiting ${Math.round(delay / 60000)} minutes before retry.`); } else if (status === 429) { const retryAfter = response.headers.get("retry-after"); const parsed = retryAfter ? this.parseRetryAfter(retryAfter) : null; delay = parsed !== null ? Math.max(parsed, this.reloadInterval) : Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES); Log.warn(`${this.url} - Rate limited (429). Retrying in ${Math.round(delay / 60000)} minutes.`); } else if (status >= 500) { this.serverErrorCount = Math.min(this.serverErrorCount + 1, MAX_SERVER_BACKOFF); delay = this.reloadInterval * Math.pow(2, this.serverErrorCount); Log.error(`${this.url} - Server error (${status}). Retry #${this.serverErrorCount} in ${Math.round(delay / 60000)} minutes.`); } else if (status >= 400) { delay = Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES); Log.error(`${this.url} - Client error (${status}). Retrying in ${Math.round(delay / 60000)} minutes.`); } else { Log.error(`${this.url} - Unexpected HTTP status ${status}.`); } return { delay, error: new Error(`HTTP ${status} ${statusText}`.trim()) }; } /** * Fetches and processes calendar data */ async fetchCalendar () { this.clearReloadTimer(); let nextDelay = this.reloadInterval; try { const response = await fetch(this.url, this.getRequestOptions()); if (!response.ok) { const { delay, error } = this.getDelayForResponse(response); nextDelay = delay; this.fetchFailedCallback(this, error); } else { this.serverErrorCount = 0; const responseData = await response.text(); try { const parsed = ical.parseICS(responseData); Log.debug(`Parsed iCal data from ${this.url} with ${Object.keys(parsed).length} entries.`); this.events = CalendarFetcherUtils.filterEvents(parsed, { excludedEvents: this.excludedEvents, includePastEvents: this.includePastEvents, maximumEntries: this.maximumEntries, maximumNumberOfDays: this.maximumNumberOfDays }); this.lastFetch = Date.now(); this.broadcastEvents(); } catch (error) { Log.error(`${this.url} - iCal parsing failed: ${error.message}`); this.fetchFailedCallback(this, error); } } } catch (error) { Log.error(`${this.url} - Fetch failed: ${error.message}`); this.fetchFailedCallback(this, error); } this.scheduleNextFetch(nextDelay); } /** * Check if enough time has passed since the last fetch to warrant a new one. * Uses reloadInterval as the threshold to respect user's configured fetchInterval. * @returns {boolean} True if a new fetch should be performed */ shouldRefetch () { if (!this.lastFetch) { return true; } const timeSinceLastFetch = Date.now() - this.lastFetch; return timeSinceLastFetch >= this.reloadInterval; } /** * Broadcasts the current events to listeners */ broadcastEvents () { Log.info(`Broadcasting ${this.events.length} events from ${this.url}.`); this.eventsReceivedCallback(this); } /** * Sets the callback for successful event fetches * @param {(fetcher: CalendarFetcher) => void} callback - Called when events are received */ onReceive (callback) { this.eventsReceivedCallback = callback; } /** * Sets the callback for fetch failures * @param {(fetcher: CalendarFetcher, error: Error) => void} callback - Called when a fetch fails */ onError (callback) { this.fetchFailedCallback = callback; } } module.exports = CalendarFetcher; ================================================ FILE: modules/default/calendar/calendarfetcherutils.js ================================================ /** * @external Moment */ const moment = require("moment-timezone"); const Log = require("logger"); const CalendarFetcherUtils = { /** * Determine based on the title of an event if it should be excluded from the list of events * @param {object} config the global config * @param {string} title the title of the event * @returns {object} excluded: true if the event should be excluded, false otherwise * until: the date until the event should be excluded. */ shouldEventBeExcluded (config, title) { for (const filterConfig of config.excludedEvents) { const match = CalendarFetcherUtils.checkEventAgainstFilter(title, filterConfig); if (match) { return { excluded: !match.until, until: match.until }; } } return { excluded: false, until: null }; }, /** * Get local timezone. * This method makes it easier to test if different timezones cause problems by changing this implementation. * @returns {string} timezone */ getLocalTimezone () { return moment.tz.guess(); }, /** * This function returns a list of moments for a recurring event. * @param {object} event the current event which is a recurring event * @param {moment.Moment} pastLocalMoment The past date to search for recurring events * @param {moment.Moment} futureLocalMoment The future date to search for recurring events * @param {number} durationInMs the duration of the event, this is used to take into account currently running events * @returns {moment.Moment[]} All moments for the recurring event */ getMomentsFromRecurringEvent (event, pastLocalMoment, futureLocalMoment, durationInMs) { const rule = event.rrule; const isFullDayEvent = CalendarFetcherUtils.isFullDayEvent(event); const eventTimezone = event.start.tz || CalendarFetcherUtils.getLocalTimezone(); // rrule.js interprets years < 1900 as offsets from 1900, causing issues with some birthday calendars if (rule.origOptions?.dtstart?.getFullYear() < 1900) { rule.origOptions.dtstart.setFullYear(1900); } if (rule.options?.dtstart?.getFullYear() < 1900) { rule.options.dtstart.setFullYear(1900); } // Expand search window to include ongoing events const oneDayInMs = 24 * 60 * 60 * 1000; const searchFromDate = pastLocalMoment.clone().subtract(Math.max(durationInMs, oneDayInMs), "milliseconds").toDate(); const searchToDate = futureLocalMoment.clone().add(1, "days").toDate(); // For all-day events, extend "until" to end of day to include the final occurrence if (isFullDayEvent && rule.options?.until) { rule.options.until = moment(rule.options.until).endOf("day").toDate(); } // Clear tzid to prevent rrule.js from double-adjusting times if (rule.options) { rule.options.tzid = null; } const dates = rule.between(searchFromDate, searchToDate, true) || []; // Convert dates to moments in the appropriate timezone // rrule.js returns UTC dates with tzid cleared, so we interpret them in the event's original timezone return dates.map((date) => { if (isFullDayEvent) { // For all-day events, anchor to calendar day in event's timezone return moment.tz(date, eventTimezone).startOf("day"); } // For timed events, preserve the time in the event's original timezone return moment.tz(date, "UTC").tz(eventTimezone, true); }); }, /** * Filter the events from ical according to the given config * @param {object} data the calendar data from ical * @param {object} config The configuration object * @returns {string[]} the filtered events */ filterEvents (data, config) { const newEvents = []; const eventDate = function (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 = 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 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..."); 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. // Otherwise, the recurring events only show the birthday for next year. let isFacebookBirthday = false; if (typeof event.uid !== "undefined") { if (event.uid.indexOf("@facebook.com") !== -1) { isFacebookBirthday = true; } } if (event.type === "VEVENT") { Log.debug(`Event:\n${JSON.stringify(event, null, 2)}`); let eventStartMoment = eventDate(event, "start"); let eventEndMoment; if (typeof event.end !== "undefined") { eventEndMoment = eventDate(event, "end"); } else if (typeof event.duration !== "undefined") { eventEndMoment = eventStartMoment.clone().add(moment.duration(event.duration)); } else { if (!isFacebookBirthday) { // make copy of start date, separate storage area eventEndMoment = eventStartMoment.clone(); } else { eventEndMoment = eventStartMoment.clone().add(1, "days"); } } Log.debug(`start: ${eventStartMoment.toDate()}`); Log.debug(`end: ${eventEndMoment.toDate()}`); // Calculate the duration of the event for use with recurring events. const durationMs = eventEndMoment.valueOf() - eventStartMoment.valueOf(); Log.debug(`duration: ${durationMs}`); const location = event.location || false; const geo = event.geo || false; const description = event.description || false; let instances = []; if (event.rrule && typeof event.rrule !== "undefined" && !isFacebookBirthday) { instances = CalendarFetcherUtils.expandRecurringEvent(event, pastLocalMoment, futureLocalMoment, durationMs); } else { const fullDayEvent = isFacebookBirthday ? true : CalendarFetcherUtils.isFullDayEvent(event); let end = eventEndMoment; if (fullDayEvent && eventStartMoment.valueOf() === end.valueOf()) { end = end.endOf("day"); } instances.push({ event: event, startMoment: eventStartMoment, endMoment: end, isRecurring: false }); } for (const instance of instances) { const { event: instanceEvent, startMoment, endMoment, isRecurring } = instance; // Filter logic if (endMoment.isBefore(pastLocalMoment) || startMoment.isAfter(futureLocalMoment)) { continue; } if (CalendarFetcherUtils.timeFilterApplies(now, endMoment, eventFilterUntil)) { continue; } const title = CalendarFetcherUtils.getTitleFromEvent(instanceEvent); const fullDay = isFacebookBirthday ? true : CalendarFetcherUtils.isFullDayEvent(event); Log.debug(`saving event: ${title}`); newEvents.push({ title: title, startDate: startMoment.format("x"), endDate: endMoment.format("x"), fullDayEvent: fullDay, recurringEvent: isRecurring, class: event.class, firstYear: event.start.getFullYear(), location: instanceEvent.location || location, geo: instanceEvent.geo || geo, description: instanceEvent.description || description }); } } }); newEvents.sort(function (a, b) { return a.startDate - b.startDate; }); return newEvents; }, /** * Gets the title from the event. * @param {object} event The event object to check. * @returns {string} The title of the event, or "Event" if no title is found. */ getTitleFromEvent (event) { let title = "Event"; if (event.summary) { title = typeof event.summary.val !== "undefined" ? event.summary.val : event.summary; } else if (event.description) { title = event.description; } return title; }, /** * Checks if an event is a fullday event. * @param {object} event The event object to check. * @returns {boolean} True if the event is a fullday event, false otherwise */ isFullDayEvent (event) { if (event.start.length === 8 || event.start.dateOnly || event.datetype === "date") { return true; } const start = event.start || 0; const startDate = new Date(start); const end = event.end || 0; if ((end - start) % (24 * 60 * 60 * 1000) === 0 && startDate.getHours() === 0 && startDate.getMinutes() === 0) { // Is 24 hours, and starts on the middle of the night. return true; } return false; }, /** * Determines if the user defined time filter should apply * @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 */ timeFilterApplies (now, endDate, filter) { if (filter) { const until = filter.split(" "), value = parseInt(until[0]), 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; } return false; }, /** * Determines if the user defined title filter should apply * @param {string} title the title of the event * @param {string} filter the string to look for, can be a regex also * @param {boolean} useRegex true if a regex should be used, otherwise it just looks for the filter as a string * @param {string} regexFlags flags that should be applied to the regex * @returns {boolean} True if the title should be filtered out, false otherwise */ titleFilterApplies (title, filter, useRegex, regexFlags) { if (useRegex) { let regexFilter = filter; // Assume if leading slash, there is also trailing slash if (filter[0] === "/") { // Strip leading and trailing slashes regexFilter = filter.substr(1).slice(0, -1); } return new RegExp(regexFilter, regexFlags).test(title); } else { return title.includes(filter); } }, /** * Expands a recurring event into individual event instances. * @param {object} event The recurring event object * @param {moment.Moment} pastLocalMoment The past date limit * @param {moment.Moment} futureLocalMoment The future date limit * @param {number} durationMs The duration of the event in milliseconds * @returns {object[]} Array of event instances */ expandRecurringEvent (event, pastLocalMoment, futureLocalMoment, durationMs) { const moments = CalendarFetcherUtils.getMomentsFromRecurringEvent(event, pastLocalMoment, futureLocalMoment, durationMs); const instances = []; for (const startMoment of moments) { let curEvent = event; let showRecurrence = true; let recurringEventStartMoment = startMoment.clone().tz(CalendarFetcherUtils.getLocalTimezone()); let recurringEventEndMoment = recurringEventStartMoment.clone().add(durationMs, "ms"); const dateKey = recurringEventStartMoment.tz("UTC").format("YYYY-MM-DD"); // Check for overrides if (curEvent.recurrences !== undefined) { if (curEvent.recurrences[dateKey] !== undefined) { curEvent = curEvent.recurrences[dateKey]; // Re-calculate start/end based on override const start = curEvent.start; const end = curEvent.end; const localTimezone = CalendarFetcherUtils.getLocalTimezone(); recurringEventStartMoment = (start.tz ? moment(start).tz(start.tz) : moment(start)).tz(localTimezone); recurringEventEndMoment = (end.tz ? moment(end).tz(end.tz) : moment(end)).tz(localTimezone); } } // Check for exceptions if (curEvent.exdate !== undefined) { if (curEvent.exdate[dateKey] !== undefined) { showRecurrence = false; } } if (recurringEventStartMoment.valueOf() === recurringEventEndMoment.valueOf()) { recurringEventEndMoment = recurringEventEndMoment.endOf("day"); } if (showRecurrence) { instances.push({ event: curEvent, startMoment: recurringEventStartMoment, endMoment: recurringEventEndMoment, isRecurring: true }); } } return instances; }, /** * Checks if an event title matches a specific filter configuration. * @param {string} title The event title to check * @param {string|object} filterConfig The filter configuration (string or object) * @returns {object|null} Object with {until: string|null} if matched, null otherwise */ checkEventAgainstFilter (title, filterConfig) { let filter = filterConfig; let testTitle = title.toLowerCase(); let until = null; let useRegex = false; let regexFlags = "g"; if (filter instanceof Object) { if (typeof filter.until !== "undefined") { until = filter.until; } if (typeof filter.regex !== "undefined") { useRegex = filter.regex; } if (filter.caseSensitive) { filter = filter.filterBy; testTitle = title; } else if (useRegex) { filter = filter.filterBy; testTitle = title; regexFlags += "i"; } else { filter = filter.filterBy.toLowerCase(); } } else { filter = filter.toLowerCase(); } if (CalendarFetcherUtils.titleFilterApplies(testTitle, filter, useRegex, regexFlags)) { return { until }; } return null; } }; if (typeof module !== "undefined") { module.exports = CalendarFetcherUtils; } ================================================ FILE: modules/default/calendar/calendarutils.js ================================================ const CalendarUtils = { /** * Capitalize the first letter of a string * @param {string} string The string to capitalize * @returns {string} The capitalized string */ capFirst (string) { return string.charAt(0).toUpperCase() + string.slice(1); }, /** * This function accepts a number (either 12 or 24) and returns a moment.js LocaleSpecification with the * corresponding time-format to be used in the calendar display. If no number is given (or otherwise invalid input) * it will a localeSpecification object with the system locale time format. * @param {number} timeFormat Specifies either 12 or 24-hour time format * @returns {moment.LocaleSpecification} formatted time */ getLocaleSpecification (timeFormat) { switch (timeFormat) { case 12: { return { longDateFormat: { LT: "h:mm A" } }; } case 24: { return { longDateFormat: { LT: "HH:mm" } }; } default: { return { longDateFormat: { LT: moment.localeData().longDateFormat("LT") } }; } } }, /** * Shortens a string if it's longer than maxLength and add an ellipsis to the end * @param {string} string Text string to shorten * @param {number} maxLength The max length of the string * @param {boolean} wrapEvents Wrap the text after the line has reached maxLength * @param {number} maxTitleLines The max number of vertical lines before cutting event title * @returns {string} The shortened string */ shorten (string, maxLength, wrapEvents, maxTitleLines) { if (typeof string !== "string") { return ""; } if (wrapEvents === true) { const words = string.split(" "); let temp = ""; let currentLine = ""; let line = 0; for (let i = 0; i < words.length; i++) { const word = words[i]; if (currentLine.length + word.length < (typeof maxLength === "number" ? maxLength : 25) - 1) { // max - 1 to account for a space currentLine += `${word} `; } else { line++; if (line > maxTitleLines - 1) { if (i < words.length) { currentLine += "…"; } break; } if (currentLine.length > 0) { temp += `${currentLine}
${word} `; } else { temp += `${word}
`; } currentLine = ""; } } return (temp + currentLine).trim(); } else { if (maxLength && typeof maxLength === "number" && string.length > maxLength) { return `${string.trim().slice(0, maxLength)}…`; } else { return string.trim(); } } }, /** * Transforms the title of an event for usage. * Replaces parts of the text as defined in config.titleReplace. * @param {string} title The title to transform. * @param {object} titleReplace object definition of parts to be replaced in the title * object definition: * search: {string,required} RegEx in format //x or simple string to be searched. For (birthday) year calculation, the element matching the year must be in a RegEx group * replace: {string,required} Replacement string, may contain match group references (latter is required for year calculation) * yearmatchgroup: {number,optional} match group for year element * @returns {string} The transformed title. */ titleTransform (title, titleReplace) { let transformedTitle = title; for (let tr in titleReplace) { let transform = titleReplace[tr]; if (typeof transform === "object") { if (typeof transform.search !== "undefined" && transform.search !== "" && typeof transform.replace !== "undefined") { let regParts = transform.search.match(/^\/(.+)\/([gim]*)$/); let needle = new RegExp(transform.search, "g"); if (regParts) { // the parsed pattern is a regexp with flags. needle = new RegExp(regParts[1], regParts[2]); } let replacement = transform.replace; if (typeof transform.yearmatchgroup !== "undefined" && transform.yearmatchgroup !== "") { const yearmatch = [...title.matchAll(needle)]; if (yearmatch[0].length >= transform.yearmatchgroup + 1 && yearmatch[0][transform.yearmatchgroup] * 1 >= 1900) { let calcage = new Date().getFullYear() - yearmatch[0][transform.yearmatchgroup] * 1; let searchstr = `$${transform.yearmatchgroup}`; replacement = replacement.replace(searchstr, calcage); } } transformedTitle = transformedTitle.replace(needle, replacement); } } } return transformedTitle; } }; if (typeof module !== "undefined") { module.exports = CalendarUtils; } ================================================ FILE: modules/default/calendar/debug.js ================================================ /* * CalendarFetcher Tester * use this script with `node debug.js` to test the fetcher without the need * of starting the MagicMirror² core. Adjust the values below to your desire. */ // Load internal alias resolver require("../../../js/alias-resolver"); const Log = require("logger"); const CalendarFetcher = require("./calendarfetcher"); const url = "https://calendar.google.com/calendar/ical/pkm1t2uedjbp0uvq1o7oj1jouo%40group.calendar.google.com/private-08ba559f89eec70dd74bbd887d0a3598/basic.ics"; // Standard test URL //const url = "https://www.googleapis.com/calendar/v3/calendars/primary/events/"; // URL for Bearer auth (must be configured in Google OAuth2 first) const fetchInterval = 60 * 60 * 1000; const maximumEntries = 10; const maximumNumberOfDays = 365; const user = "magicmirror"; const pass = "MyStrongPass"; const auth = { user: user, pass: pass }; Log.log("Create fetcher ..."); const fetcher = new CalendarFetcher(url, fetchInterval, [], maximumEntries, maximumNumberOfDays, auth); fetcher.onReceive(function (fetcher) { Log.log(fetcher.events); process.exit(0); }); fetcher.onError(function (fetcher, error) { Log.log("Fetcher error:", error); process.exit(1); }); fetcher.startFetch(); Log.log("Create fetcher done! "); ================================================ FILE: modules/default/calendar/node_helper.js ================================================ const zlib = require("node:zlib"); const NodeHelper = require("node_helper"); const Log = require("logger"); const CalendarFetcher = require("./calendarfetcher"); module.exports = NodeHelper.create({ // Override start method. start () { Log.log(`Starting node helper for: ${this.name}`); this.fetchers = []; }, // Override socketNotificationReceived method. socketNotificationReceived (notification, payload) { if (notification === "ADD_CALENDAR") { this.createFetcher(payload.url, payload.fetchInterval, payload.excludedEvents, payload.maximumEntries, payload.maximumNumberOfDays, payload.auth, payload.broadcastPastEvents, payload.selfSignedCert, payload.id); } else if (notification === "FETCH_CALENDAR") { const key = payload.id + payload.url; if (typeof this.fetchers[key] === "undefined") { Log.error("No fetcher exists with key: ", key); this.sendSocketNotification("CALENDAR_ERROR", { error_type: "MODULE_ERROR_UNSPECIFIED" }); return; } this.fetchers[key].fetchCalendar(); } }, /** * Creates a fetcher for a new url if it doesn't exist yet. * Otherwise it reuses the existing one. * @param {string} url The url of the calendar * @param {number} fetchInterval How often does the calendar needs to be fetched in ms * @param {string[]} excludedEvents An array of words / phrases from event titles that will be excluded from being shown. * @param {number} maximumEntries The maximum number of events fetched. * @param {number} maximumNumberOfDays The maximum number of days an event should be in the future. * @param {object} auth The object containing options for authentication against the calendar. * @param {boolean} broadcastPastEvents If true events from the past maximumNumberOfDays will be included in event broadcasts * @param {boolean} selfSignedCert If true, the server certificate is not verified against the list of supplied CAs. * @param {string} identifier ID of the module */ createFetcher (url, fetchInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, broadcastPastEvents, selfSignedCert, identifier) { try { new URL(url); } catch (error) { Log.error("Malformed calendar url: ", url, error); this.sendSocketNotification("CALENDAR_ERROR", { error_type: "MODULE_ERROR_MALFORMED_URL" }); return; } let fetcher; let fetchIntervalCorrected; if (typeof this.fetchers[identifier + url] === "undefined") { if (fetchInterval < 60000) { Log.warn(`fetchInterval for url ${url} must be >= 60000`); fetchIntervalCorrected = 60000; } Log.log(`Create new calendarfetcher for url: ${url} - Interval: ${fetchIntervalCorrected || fetchInterval}`); fetcher = new CalendarFetcher(url, fetchIntervalCorrected || fetchInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, broadcastPastEvents, selfSignedCert); fetcher.onReceive((fetcher) => { this.broadcastEvents(fetcher, identifier); }); fetcher.onError((fetcher, error) => { Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url, error); let error_type = NodeHelper.checkFetchError(error); this.sendSocketNotification("CALENDAR_ERROR", { id: identifier, error_type }); }); this.fetchers[identifier + url] = fetcher; fetcher.fetchCalendar(); } else { Log.log(`Use existing calendarfetcher for url: ${url}`); fetcher = this.fetchers[identifier + url]; // Check if calendar data is stale and needs refresh if (fetcher.shouldRefetch()) { Log.log(`Calendar data is stale, fetching fresh data for url: ${url}`); fetcher.fetchCalendar(); } else { fetcher.broadcastEvents(); } } }, /** * * @param {object} fetcher the fetcher associated with the calendar * @param {string} identifier the identifier of the calendar */ broadcastEvents (fetcher, identifier) { const checksum = zlib.crc32(Buffer.from(JSON.stringify(fetcher.events), "utf8")); this.sendSocketNotification("CALENDAR_EVENTS", { id: identifier, url: fetcher.url, events: fetcher.events, checksum: checksum }); } }); ================================================ FILE: modules/default/calendar/windowsZones.json ================================================ { "Dateline Standard Time": { "iana": ["Etc/GMT+12"] }, "UTC-11": { "iana": ["Etc/GMT+11"] }, "Aleutian Standard Time": { "iana": ["America/Adak"] }, "Hawaiian Standard Time": { "iana": ["Pacific/Honolulu"] }, "Marquesas Standard Time": { "iana": ["Pacific/Marquesas"] }, "Alaskan Standard Time": { "iana": ["America/Anchorage"] }, "UTC-09": { "iana": ["Etc/GMT+9"] }, "Pacific Standard Time (Mexico)": { "iana": ["America/Tijuana"] }, "UTC-08": { "iana": ["Etc/GMT+8"] }, "Pacific Standard Time": { "iana": ["America/Los_Angeles"] }, "US Mountain Standard Time": { "iana": ["America/Phoenix"] }, "Mountain Standard Time (Mexico)": { "iana": ["America/Chihuahua"] }, "Mountain Standard Time": { "iana": ["America/Denver"] }, "Central America Standard Time": { "iana": ["America/Guatemala"] }, "Central Standard Time": { "iana": ["America/Chicago"] }, "Easter Island Standard Time": { "iana": ["Pacific/Easter"] }, "Central Standard Time (Mexico)": { "iana": ["America/Mexico_City"] }, "Canada Central Standard Time": { "iana": ["America/Regina"] }, "SA Pacific Standard Time": { "iana": ["America/Bogota"] }, "Eastern Standard Time (Mexico)": { "iana": ["America/Cancun"] }, "Eastern Standard Time": { "iana": ["America/New_York"] }, "Haiti Standard Time": { "iana": ["America/Port-au-Prince"] }, "Cuba Standard Time": { "iana": ["America/Havana"] }, "US Eastern Standard Time": { "iana": ["America/Indianapolis"] }, "Turks And Caicos Standard Time": { "iana": ["America/Grand_Turk"] }, "Paraguay Standard Time": { "iana": ["America/Asuncion"] }, "Atlantic Standard Time": { "iana": ["America/Halifax"] }, "Venezuela Standard Time": { "iana": ["America/Caracas"] }, "Central Brazilian Standard Time": { "iana": ["America/Cuiaba"] }, "SA Western Standard Time": { "iana": ["America/La_Paz"] }, "Pacific SA Standard Time": { "iana": ["America/Santiago"] }, "Newfoundland Standard Time": { "iana": ["America/St_Johns"] }, "Tocantins Standard Time": { "iana": ["America/Araguaina"] }, "E. South America Standard Time": { "iana": ["America/Sao_Paulo"] }, "SA Eastern Standard Time": { "iana": ["America/Cayenne"] }, "Argentina Standard Time": { "iana": ["America/Buenos_Aires"] }, "Greenland Standard Time": { "iana": ["America/Godthab"] }, "Montevideo Standard Time": { "iana": ["America/Montevideo"] }, "Magallanes Standard Time": { "iana": ["America/Punta_Arenas"] }, "Saint Pierre Standard Time": { "iana": ["America/Miquelon"] }, "Bahia Standard Time": { "iana": ["America/Bahia"] }, "UTC-02": { "iana": ["Etc/GMT+2"] }, "Azores Standard Time": { "iana": ["Atlantic/Azores"] }, "Cape Verde Standard Time": { "iana": ["Atlantic/Cape_Verde"] }, "UTC": { "iana": ["Etc/GMT"] }, "GMT Standard Time": { "iana": ["Europe/London"] }, "Greenwich Standard Time": { "iana": ["Atlantic/Reykjavik"] }, "Sao Tome Standard Time": { "iana": ["Africa/Sao_Tome"] }, "Morocco Standard Time": { "iana": ["Africa/Casablanca"] }, "W. Europe Standard Time": { "iana": ["Europe/Berlin"] }, "Central Europe Standard Time": { "iana": ["Europe/Budapest"] }, "Romance Standard Time": { "iana": ["Europe/Paris"] }, "Central European Standard Time": { "iana": ["Europe/Warsaw"] }, "W. Central Africa Standard Time": { "iana": ["Africa/Lagos"] }, "Jordan Standard Time": { "iana": ["Asia/Amman"] }, "GTB Standard Time": { "iana": ["Europe/Bucharest"] }, "Middle East Standard Time": { "iana": ["Asia/Beirut"] }, "Egypt Standard Time": { "iana": ["Africa/Cairo"] }, "E. Europe Standard Time": { "iana": ["Europe/Chisinau"] }, "Syria Standard Time": { "iana": ["Asia/Damascus"] }, "West Bank Standard Time": { "iana": ["Asia/Hebron"] }, "South Africa Standard Time": { "iana": ["Africa/Johannesburg"] }, "FLE Standard Time": { "iana": ["Europe/Kiev"] }, "Israel Standard Time": { "iana": ["Asia/Jerusalem"] }, "Kaliningrad Standard Time": { "iana": ["Europe/Kaliningrad"] }, "Sudan Standard Time": { "iana": ["Africa/Khartoum"] }, "Libya Standard Time": { "iana": ["Africa/Tripoli"] }, "Namibia Standard Time": { "iana": ["Africa/Windhoek"] }, "Arabic Standard Time": { "iana": ["Asia/Baghdad"] }, "Turkey Standard Time": { "iana": ["Europe/Istanbul"] }, "Arab Standard Time": { "iana": ["Asia/Riyadh"] }, "Belarus Standard Time": { "iana": ["Europe/Minsk"] }, "Russian Standard Time": { "iana": ["Europe/Moscow"] }, "E. Africa Standard Time": { "iana": ["Africa/Nairobi"] }, "Iran Standard Time": { "iana": ["Asia/Tehran"] }, "Arabian Standard Time": { "iana": ["Asia/Dubai"] }, "Astrakhan Standard Time": { "iana": ["Europe/Astrakhan"] }, "Azerbaijan Standard Time": { "iana": ["Asia/Baku"] }, "Russia Time Zone 3": { "iana": ["Europe/Samara"] }, "Mauritius Standard Time": { "iana": ["Indian/Mauritius"] }, "Saratov Standard Time": { "iana": ["Europe/Saratov"] }, "Georgian Standard Time": { "iana": ["Asia/Tbilisi"] }, "Volgograd Standard Time": { "iana": ["Europe/Volgograd"] }, "Caucasus Standard Time": { "iana": ["Asia/Yerevan"] }, "Afghanistan Standard Time": { "iana": ["Asia/Kabul"] }, "West Asia Standard Time": { "iana": ["Asia/Tashkent"] }, "Ekaterinburg Standard Time": { "iana": ["Asia/Yekaterinburg"] }, "Pakistan Standard Time": { "iana": ["Asia/Karachi"] }, "Qyzylorda Standard Time": { "iana": ["Asia/Qyzylorda"] }, "India Standard Time": { "iana": ["Asia/Calcutta"] }, "Sri Lanka Standard Time": { "iana": ["Asia/Colombo"] }, "Nepal Standard Time": { "iana": ["Asia/Katmandu"] }, "Central Asia Standard Time": { "iana": ["Asia/Almaty"] }, "Bangladesh Standard Time": { "iana": ["Asia/Dhaka"] }, "Omsk Standard Time": { "iana": ["Asia/Omsk"] }, "Myanmar Standard Time": { "iana": ["Asia/Rangoon"] }, "SE Asia Standard Time": { "iana": ["Asia/Bangkok"] }, "Altai Standard Time": { "iana": ["Asia/Barnaul"] }, "W. Mongolia Standard Time": { "iana": ["Asia/Hovd"] }, "North Asia Standard Time": { "iana": ["Asia/Krasnoyarsk"] }, "N. Central Asia Standard Time": { "iana": ["Asia/Novosibirsk"] }, "Tomsk Standard Time": { "iana": ["Asia/Tomsk"] }, "China Standard Time": { "iana": ["Asia/Shanghai"] }, "North Asia East Standard Time": { "iana": ["Asia/Irkutsk"] }, "Singapore Standard Time": { "iana": ["Asia/Singapore"] }, "W. Australia Standard Time": { "iana": ["Australia/Perth"] }, "Taipei Standard Time": { "iana": ["Asia/Taipei"] }, "Ulaanbaatar Standard Time": { "iana": ["Asia/Ulaanbaatar"] }, "Aus Central W. Standard Time": { "iana": ["Australia/Eucla"] }, "Transbaikal Standard Time": { "iana": ["Asia/Chita"] }, "Tokyo Standard Time": { "iana": ["Asia/Tokyo"] }, "North Korea Standard Time": { "iana": ["Asia/Pyongyang"] }, "Korea Standard Time": { "iana": ["Asia/Seoul"] }, "Yakutsk Standard Time": { "iana": ["Asia/Yakutsk"] }, "Cen. Australia Standard Time": { "iana": ["Australia/Adelaide"] }, "AUS Central Standard Time": { "iana": ["Australia/Darwin"] }, "E. Australia Standard Time": { "iana": ["Australia/Brisbane"] }, "AUS Eastern Standard Time": { "iana": ["Australia/Sydney"] }, "West Pacific Standard Time": { "iana": ["Pacific/Port_Moresby"] }, "Tasmania Standard Time": { "iana": ["Australia/Hobart"] }, "Vladivostok Standard Time": { "iana": ["Asia/Vladivostok"] }, "Lord Howe Standard Time": { "iana": ["Australia/Lord_Howe"] }, "Bougainville Standard Time": { "iana": ["Pacific/Bougainville"] }, "Russia Time Zone 10": { "iana": ["Asia/Srednekolymsk"] }, "Magadan Standard Time": { "iana": ["Asia/Magadan"] }, "Norfolk Standard Time": { "iana": ["Pacific/Norfolk"] }, "Sakhalin Standard Time": { "iana": ["Asia/Sakhalin"] }, "Central Pacific Standard Time": { "iana": ["Pacific/Guadalcanal"] }, "Russia Time Zone 11": { "iana": ["Asia/Kamchatka"] }, "New Zealand Standard Time": { "iana": ["Pacific/Auckland"] }, "UTC+12": { "iana": ["Etc/GMT-12"] }, "Fiji Standard Time": { "iana": ["Pacific/Fiji"] }, "Chatham Islands Standard Time": { "iana": ["Pacific/Chatham"] }, "UTC+13": { "iana": ["Etc/GMT-13"] }, "Tonga Standard Time": { "iana": ["Pacific/Tongatapu"] }, "Samoa Standard Time": { "iana": ["Pacific/Apia"] }, "Line Islands Standard Time": { "iana": ["Pacific/Kiritimati"] }, "(UTC-12:00) International Date Line West": { "iana": ["Etc/GMT+12"] }, "(UTC-11:00) Midway Island, Samoa": { "iana": ["Pacific/Apia"] }, "(UTC-10:00) Hawaii": { "iana": ["Pacific/Honolulu"] }, "(UTC-09:00) Alaska": { "iana": ["America/Anchorage"] }, "(UTC-08:00) Pacific Time (US & Canada); Tijuana": { "iana": ["America/Los_Angeles"] }, "(UTC-08:00) Pacific Time (US and Canada); Tijuana": { "iana": ["America/Los_Angeles"] }, "(UTC-07:00) Mountain Time (US & Canada)": { "iana": ["America/Denver"] }, "(UTC-07:00) Mountain Time (US and Canada)": { "iana": ["America/Denver"] }, "(UTC-07:00) Chihuahua, La Paz, Mazatlan": { "iana": [null] }, "(UTC-07:00) Arizona": { "iana": ["America/Phoenix"] }, "(UTC-06:00) Central Time (US & Canada)": { "iana": ["America/Chicago"] }, "(UTC-06:00) Central Time (US and Canada)": { "iana": ["America/Chicago"] }, "(UTC-06:00) Saskatchewan": { "iana": ["America/Regina"] }, "(UTC-06:00) Guadalajara, Mexico City, Monterrey": { "iana": [null] }, "(UTC-06:00) Central America": { "iana": ["America/Guatemala"] }, "(UTC-05:00) Eastern Time (US & Canada)": { "iana": ["America/New_York"] }, "(UTC-05:00) Eastern Time (US and Canada)": { "iana": ["America/New_York"] }, "(UTC-05:00) Indiana (East)": { "iana": ["America/Indianapolis"] }, "(UTC-05:00) Bogota, Lima, Quito": { "iana": ["America/Bogota"] }, "(UTC-04:00) Atlantic Time (Canada)": { "iana": ["America/Halifax"] }, "(UTC-04:00) Georgetown, La Paz, San Juan": { "iana": ["America/La_Paz"] }, "(UTC-04:00) Santiago": { "iana": ["America/Santiago"] }, "(UTC-03:30) Newfoundland": { "iana": [null] }, "(UTC-03:00) Brasilia": { "iana": ["America/Sao_Paulo"] }, "(UTC-03:00) Georgetown": { "iana": ["America/Cayenne"] }, "(UTC-03:00) Greenland": { "iana": ["America/Godthab"] }, "(UTC-02:00) Mid-Atlantic": { "iana": [null] }, "(UTC-01:00) Azores": { "iana": ["Atlantic/Azores"] }, "(UTC-01:00) Cape Verde Islands": { "iana": ["Atlantic/Cape_Verde"] }, "(UTC) Greenwich Mean Time: Dublin, Edinburgh, Lisbon, London": { "iana": [null] }, "(UTC) Monrovia, Reykjavik": { "iana": ["Atlantic/Reykjavik"] }, "(UTC+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague": { "iana": ["Europe/Budapest"] }, "(UTC+01:00) Sarajevo, Skopje, Warsaw, Zagreb": { "iana": ["Europe/Warsaw"] }, "(UTC+01:00) Brussels, Copenhagen, Madrid, Paris": { "iana": ["Europe/Paris"] }, "(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna": { "iana": ["Europe/Berlin"] }, "(UTC+01:00) West Central Africa": { "iana": ["Africa/Lagos"] }, "(UTC+02:00) Minsk": { "iana": ["Europe/Chisinau"] }, "(UTC+02:00) Cairo": { "iana": ["Africa/Cairo"] }, "(UTC+02:00) Helsinki, Kiev, Riga, Sofia, Tallinn, Vilnius": { "iana": ["Europe/Kiev"] }, "(UTC+02:00) Athens, Bucharest, Istanbul": { "iana": ["Europe/Bucharest"] }, "(UTC+02:00) Jerusalem": { "iana": ["Asia/Jerusalem"] }, "(UTC+02:00) Harare, Pretoria": { "iana": ["Africa/Johannesburg"] }, "(UTC+03:00) Moscow, St. Petersburg, Volgograd": { "iana": ["Europe/Moscow"] }, "(UTC+03:00) Kuwait, Riyadh": { "iana": ["Asia/Riyadh"] }, "(UTC+03:00) Nairobi": { "iana": ["Africa/Nairobi"] }, "(UTC+03:00) Baghdad": { "iana": ["Asia/Baghdad"] }, "(UTC+03:30) Tehran": { "iana": ["Asia/Tehran"] }, "(UTC+04:00) Abu Dhabi, Muscat": { "iana": ["Asia/Dubai"] }, "(UTC+04:00) Baku, Tbilisi, Yerevan": { "iana": ["Asia/Yerevan"] }, "(UTC+04:30) Kabul": { "iana": [null] }, "(UTC+05:00) Ekaterinburg": { "iana": ["Asia/Yekaterinburg"] }, "(UTC+05:00) Tashkent": { "iana": ["Asia/Tashkent"] }, "(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi": { "iana": ["Asia/Calcutta"] }, "(UTC+05:45) Kathmandu": { "iana": ["Asia/Katmandu"] }, "(UTC+06:00) Astana, Dhaka": { "iana": ["Asia/Almaty"] }, "(UTC+06:00) Sri Jayawardenepura": { "iana": ["Asia/Colombo"] }, "(UTC+06:00) Almaty, Novosibirsk": { "iana": ["Asia/Novosibirsk"] }, "(UTC+06:30) Yangon (Rangoon)": { "iana": ["Asia/Rangoon"] }, "(UTC+07:00) Bangkok, Hanoi, Jakarta": { "iana": ["Asia/Bangkok"] }, "(UTC+07:00) Krasnoyarsk": { "iana": ["Asia/Krasnoyarsk"] }, "(UTC+08:00) Beijing, Chongqing, Hong Kong, Urumqi": { "iana": ["Asia/Shanghai"] }, "(UTC+08:00) Kuala Lumpur, Singapore": { "iana": ["Asia/Singapore"] }, "(UTC+08:00) Taipei": { "iana": ["Asia/Taipei"] }, "(UTC+08:00) Perth": { "iana": ["Australia/Perth"] }, "(UTC+08:00) Irkutsk, Ulaanbaatar": { "iana": ["Asia/Irkutsk"] }, "(UTC+09:00) Seoul": { "iana": ["Asia/Seoul"] }, "(UTC+09:00) Osaka, Sapporo, Tokyo": { "iana": ["Asia/Tokyo"] }, "(UTC+09:00) Yakutsk": { "iana": ["Asia/Yakutsk"] }, "(UTC+09:30) Darwin": { "iana": ["Australia/Darwin"] }, "(UTC+09:30) Adelaide": { "iana": ["Australia/Adelaide"] }, "(UTC+10:00) Canberra, Melbourne, Sydney": { "iana": ["Australia/Sydney"] }, "(GMT+10:00) Canberra, Melbourne, Sydney": { "iana": ["Australia/Sydney"] }, "(UTC+10:00) Brisbane": { "iana": ["Australia/Brisbane"] }, "(UTC+10:00) Hobart": { "iana": ["Australia/Hobart"] }, "(UTC+10:00) Vladivostok": { "iana": ["Asia/Vladivostok"] }, "(UTC+10:00) Guam, Port Moresby": { "iana": ["Pacific/Port_Moresby"] }, "(UTC+11:00) Magadan, Solomon Islands, New Caledonia": { "iana": ["Pacific/Guadalcanal"] }, "(UTC+12:00) Fiji, Kamchatka, Marshall Is.": { "iana": [null] }, "(UTC+12:00) Auckland, Wellington": { "iana": ["Pacific/Auckland"] }, "(UTC+13:00) Nuku'alofa": { "iana": ["Pacific/Tongatapu"] }, "(UTC-03:00) Buenos Aires": { "iana": ["America/Buenos_Aires"] }, "(UTC+02:00) Beirut": { "iana": ["Asia/Beirut"] }, "(UTC+02:00) Amman": { "iana": ["Asia/Amman"] }, "(UTC-06:00) Guadalajara, Mexico City, Monterrey - New": { "iana": ["America/Mexico_City"] }, "(UTC-07:00) Chihuahua, La Paz, Mazatlan - New": { "iana": ["America/Chihuahua"] }, "(UTC-08:00) Tijuana, Baja California": { "iana": ["America/Tijuana"] }, "(UTC+02:00) Windhoek": { "iana": ["Africa/Windhoek"] }, "(UTC+03:00) Tbilisi": { "iana": ["Asia/Tbilisi"] }, "(UTC-04:00) Manaus": { "iana": ["America/Cuiaba"] }, "(UTC-03:00) Montevideo": { "iana": ["America/Montevideo"] }, "(UTC+04:00) Yerevan": { "iana": [null] }, "(UTC-04:30) Caracas": { "iana": ["America/Caracas"] }, "(UTC) Casablanca": { "iana": ["Africa/Casablanca"] }, "(UTC+05:00) Islamabad, Karachi": { "iana": ["Asia/Karachi"] }, "(UTC+04:00) Port Louis": { "iana": ["Indian/Mauritius"] }, "(UTC) Coordinated Universal Time": { "iana": ["Etc/GMT"] }, "(UTC-04:00) Asuncion": { "iana": ["America/Asuncion"] }, "(UTC+12:00) Petropavlovsk-Kamchatsky": { "iana": [null] } } ================================================ FILE: modules/default/clock/README.md ================================================ # Module: Clock The `clock` module is one of the default modules of the MagicMirror². This module displays the current date and time. The information will be updated realtime. For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/clock.html). ================================================ FILE: modules/default/clock/clock.js ================================================ /* global SunCalc, formatTime */ Module.register("clock", { // Module config defaults. defaults: { displayType: "digital", // options: digital, analog, both timeFormat: config.timeFormat, timezone: null, displaySeconds: true, showPeriod: true, showPeriodUpper: false, clockBold: false, showDate: true, showTime: true, showWeek: false, // options: true, false, 'short' dateFormat: "dddd, LL", sendNotifications: false, /* specific to the analog clock */ analogSize: "200px", 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", // DEPRECATED, use CSS instead. Class "clock-second-digital" for digital clock, "clock-second" for analog clock. 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 }, // Define required scripts. getScripts () { return ["moment.js", "moment-timezone.js", "suncalc.js"]; }, // Define styles. getStyles () { return ["clock_styles.css", "font-awesome.css"]; }, // Define start sequence. start () { Log.info(`Starting module: ${this.name}`); // Schedule update interval. this.second = moment().second(); this.minute = moment().minute(); // Calculate how many ms should pass until next update depending on if seconds is displayed or not const delayCalculator = (reducedSeconds) => { const EXTRA_DELAY = 50; // Deliberate imperceptible delay to prevent off-by-one timekeeping errors if (this.config.displaySeconds) { return 1000 - moment().milliseconds() + EXTRA_DELAY; } else { return (60 - reducedSeconds) * 1000 - moment().milliseconds() + EXTRA_DELAY; } }; // A recursive timeout function instead of interval to avoid drifting const notificationTimer = () => { this.updateDom(); if (this.config.sendNotifications) { // If seconds is displayed CLOCK_SECOND-notification should be sent (but not when CLOCK_MINUTE-notification is sent) if (this.config.displaySeconds) { this.second = moment().second(); if (this.second !== 0) { this.sendNotification("CLOCK_SECOND", this.second); setTimeout(notificationTimer, delayCalculator(0)); return; } } // If minute changed or seconds isn't displayed send CLOCK_MINUTE-notification this.minute = moment().minute(); this.sendNotification("CLOCK_MINUTE", this.minute); } setTimeout(notificationTimer, delayCalculator(0)); }; // Set the initial timeout with the amount of seconds elapsed as // reducedSeconds, so it will trigger when the minute changes setTimeout(notificationTimer, delayCalculator(this.second)); // Set locale. moment.locale(config.language); }, // Override dom generator. getDom () { const wrapper = document.createElement("div"); wrapper.classList.add("clock-grid"); /************************************ * Create wrappers for analog and digital clock */ const analogWrapper = document.createElement("div"); analogWrapper.className = "clock-circle"; const digitalWrapper = document.createElement("div"); digitalWrapper.className = "digital"; /************************************ * Create wrappers for DIGITAL 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"); const moonWrapper = document.createElement("div"); const weekWrapper = document.createElement("div"); // Style Wrappers dateWrapper.className = "date normal medium"; timeWrapper.className = "time bright large light"; 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. const now = moment(); if (this.config.timezone) { now.tz(this.config.timezone); } if (this.config.showDate) { dateWrapper.innerHTML = now.format(this.config.dateFormat); digitalWrapper.appendChild(dateWrapper); } if (this.config.displayType !== "analog" && this.config.showTime) { 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"); } else { periodWrapper.innerHTML = now.format("a"); } if (this.config.displaySeconds) { timeWrapper.appendChild(secondsWrapper); } if (this.config.showPeriod && this.config.timeFormat !== 24) { timeWrapper.appendChild(periodWrapper); } digitalWrapper.appendChild(timeWrapper); } /**************************************************************** * Create wrappers for Sun Times, only if specified in config */ if (this.config.showSunTimes) { const sunTimes = SunCalc.getTimes(now, this.config.lat, this.config.lon); const isVisible = now.isBetween(sunTimes.sunrise, sunTimes.sunset); 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 = ` ${untilNextEventString}`; } sunWrapperInnerHTML += ` ${formatTime(this.config, sunTimes.sunrise)}` + ` ${formatTime(this.config, sunTimes.sunset)}`; sunWrapper.innerHTML = sunWrapperInnerHTML; digitalWrapper.appendChild(sunWrapper); } /**************************************************************** * Create wrappers for Moon Times, only if specified in config */ if (this.config.showMoonTimes) { const moonIllumination = SunCalc.getMoonIllumination(now.toDate()); const moonTimes = SunCalc.getMoonTimes(now, this.config.lat, this.config.lon); const moonRise = moonTimes.rise; let moonSet; if (moment(moonTimes.set).isAfter(moonTimes.rise)) { moonSet = moonTimes.set; } else { const nextMoonTimes = SunCalc.getMoonTimes(now.clone().add(1, "day"), this.config.lat, this.config.lon); moonSet = nextMoonTimes.set; } const isVisible = now.isBetween(moonRise, moonSet) || moonTimes.alwaysUp === true; const showFraction = ["both", "percent"].includes(this.config.showMoonTimes); const showUnicode = ["both", "phase"].includes(this.config.showMoonTimes); const illuminatedFractionString = `${Math.round(moonIllumination.fraction * 100)}%`; const image = showUnicode ? [..."🌑🌒🌓🌔🌕🌖🌗🌘"][Math.floor(moonIllumination.phase * 8)] : ""; moonWrapper.innerHTML = `${image} ${showFraction ? illuminatedFractionString : ""}` + ` ${moonRise ? formatTime(this.config, moonRise) : "..."}` + ` ${moonSet ? formatTime(this.config, moonSet) : "..."}`; digitalWrapper.appendChild(moonWrapper); } if (this.config.showWeek) { 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); } /**************************************************************** * Create wrappers for ANALOG clock, only if specified in config */ if (this.config.displayType !== "digital") { // If it isn't 'digital', then an 'analog' clock was also requested // Calculate the degree offset for each hand of the clock if (this.config.timezone) { now.tz(this.config.timezone); } const second = now.seconds() * 6, minute = now.minute() * 6 + second / 60, hour = ((now.hours() % 12) / 12) * 360 + 90 + minute / 12; // Create wrappers analogWrapper.style.width = this.config.analogSize; analogWrapper.style.height = this.config.analogSize; if (this.config.analogFace !== "" && this.config.analogFace !== "simple" && this.config.analogFace !== "none") { analogWrapper.style.background = `url(${this.data.path}faces/${this.config.analogFace}.svg)`; analogWrapper.style.backgroundSize = "100%"; // The following line solves issue: https://github.com/MagicMirrorOrg/MagicMirror/issues/611 // analogWrapper.style.border = "1px solid black"; analogWrapper.style.border = "rgba(0, 0, 0, 0.1)"; //Updated fix for Issue 611 where non-black backgrounds are used } else if (this.config.analogFace !== "none") { analogWrapper.style.border = "2px solid white"; } const clockFace = document.createElement("div"); clockFace.className = "clock-face"; const clockHour = document.createElement("div"); clockHour.id = "clock-hour"; clockHour.style.transform = `rotate(${hour}deg)`; clockHour.className = "clock-hour"; const clockMinute = document.createElement("div"); clockMinute.id = "clock-minute"; clockMinute.style.transform = `rotate(${minute}deg)`; clockMinute.className = "clock-minute"; // Combine analog wrappers clockFace.appendChild(clockHour); clockFace.appendChild(clockMinute); if (this.config.displaySeconds) { const clockSecond = document.createElement("div"); clockSecond.id = "clock-second"; clockSecond.style.transform = `rotate(${second}deg)`; clockSecond.className = "clock-second"; clockSecond.style.backgroundColor = this.config.secondsColor; /* DEPRECATED, to be removed in a future version , use CSS instead */ clockFace.appendChild(clockSecond); } analogWrapper.appendChild(clockFace); } /******************************************* * Update placement, respect old analogShowDate even if it's not needed anymore */ if (this.config.displayType === "analog") { // Display only an analog clock if (this.config.showDate) { // Add date to the analog clock dateWrapper.innerHTML = now.format(this.config.dateFormat); wrapper.appendChild(dateWrapper); } if (this.config.analogShowDate === "bottom") { wrapper.classList.add("clock-grid-bottom"); } else if (this.config.analogShowDate === "top") { wrapper.classList.add("clock-grid-top"); } wrapper.appendChild(analogWrapper); } else if (this.config.displayType === "digital") { wrapper.appendChild(digitalWrapper); } else if (this.config.displayType === "both") { wrapper.classList.add(`clock-grid-${this.config.analogPlacement}`); wrapper.appendChild(analogWrapper); wrapper.appendChild(digitalWrapper); } // Return the wrapper to the dom. return wrapper; } }); ================================================ FILE: modules/default/clock/clock_styles.css ================================================ .clock-grid { display: inline-flex; gap: 15px; } .clock-grid-left { flex-direction: row; } .clock-grid-right { flex-direction: row-reverse; } .clock-grid-top { flex-direction: column; } .clock-grid-bottom { flex-direction: column-reverse; } .clock-circle { place-self: center; position: relative; border-radius: 50%; background-size: 100%; } .clock-face { width: 100%; height: 100%; } .clock-face::after { position: absolute; top: 50%; left: 50%; width: 6px; height: 6px; margin: -3px 0 0 -3px; background: var(--color-text-bright); border-radius: 3px; content: ""; display: block; } .clock-hour { width: 0; height: 0; position: absolute; top: 50%; left: 50%; margin: -2px 0 -2px -25%; /* numbers must match negative length & thickness */ padding: 2px 0 2px 25%; /* indicator length & thickness */ background: var(--color-text-bright); transform-origin: 100% 50%; border-radius: 3px 0 0 3px; } .clock-minute { width: 0; height: 0; position: absolute; top: 50%; left: 50%; margin: -35% -2px 0; /* numbers must match negative length & thickness */ padding: 35% 2px 0; /* indicator length & thickness */ background: var(--color-text-bright); transform-origin: 50% 100%; border-radius: 3px 0 0 3px; } .clock-second { width: 0; height: 0; position: absolute; top: 50%; left: 50%; margin: -38% -1px 0 0; /* numbers must match negative length & thickness */ padding: 38% 1px 0 0; /* indicator length & thickness */ /* background: #888888 !important; */ /* use this instead of secondsColor */ /* have to use !important, because the code explicitly sets the color currently */ transform-origin: 50% 100%; } .module.clock .digital { display: flex; flex-direction: column; gap: 3px; } .module.clock .sun, .module.clock .moon { display: flex; white-space: nowrap; gap: 10px; } .module.clock .sun > *, .module.clock .moon > * { flex: 1; } .module.clock .clock-hour-digital { color: var(--color-text-bright); } .module.clock .clock-minute-digital { color: var(--color-text-bright); } .module.clock .clock-second-digital { color: var(--color-text-dimmed); } ================================================ FILE: modules/default/compliments/README.md ================================================ # Module: Compliments The `compliments` module is one of the default modules of the MagicMirror². This module displays a random compliment. For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/compliments.html). ================================================ FILE: modules/default/compliments/compliments.js ================================================ /* global Cron */ Module.register("compliments", { // Module config defaults. defaults: { compliments: { anytime: ["Hey there sexy!"], morning: ["Good morning, handsome!", "Enjoy your day!", "How was your sleep?"], afternoon: ["Hello, beauty!", "You look sexy!", "Looking good today!"], evening: ["Wow, you look hot!", "You look nice!", "Hi, sexy!"], "....-01-01": ["Happy new year!"] }, updateInterval: 30000, remoteFile: null, remoteFileRefreshInterval: 0, fadeSpeed: 4000, morningStartTime: 3, morningEndTime: 12, afternoonStartTime: 12, afternoonEndTime: 17, random: true, specialDayUnique: false }, compliments_new: null, refreshMinimumDelay: 15 * 60 * 1000, // 15 minutes lastIndexUsed: -1, // Set currentweather from module currentWeatherType: "", cron_regex: /^(((\d+,)+\d+|((\d+|[*])[/]\d+|((JAN|FEB|APR|MA[RY]|JU[LN]|AUG|SEP|OCT|NOV|DEC)(-(JAN|FEB|APR|MA[RY]|JU[LN]|AUG|SEP|OCT|NOV|DEC))?))|(\d+-\d+)|\d+(-\d+)?[/]\d+(-\d+)?|\d+|[*]|(MON|TUE|WED|THU|FRI|SAT|SUN)(-(MON|TUE|WED|THU|FRI|SAT|SUN))?) ?){5}$/i, date_regex: "[1-9.][0-9.][0-9.]{2}-([0][1-9]|[1][0-2])-([1-2][0-9]|[0][1-9]|[3][0-1])", pre_defined_types: ["anytime", "morning", "afternoon", "evening"], // Define required scripts. getScripts () { return ["croner.js", "moment.js"]; }, // Define start sequence. async start () { Log.info(`Starting module: ${this.name}`); this.lastComplimentIndex = -1; if (this.config.remoteFile !== null) { const response = await this.loadComplimentFile(); this.config.compliments = JSON.parse(response); this.updateDom(); if (this.config.remoteFileRefreshInterval !== 0) { if ((this.config.remoteFileRefreshInterval >= this.refreshMinimumDelay) || window.mmTestMode === "true") { setInterval(async () => { const response = await this.loadComplimentFile(); if (response) { this.compliments_new = JSON.parse(response); } else { Log.error(`[compliments] ${this.name} remoteFile refresh failed`); } }, this.config.remoteFileRefreshInterval); } else { Log.error(`[compliments] ${this.name} remoteFileRefreshInterval less than minimum`); } } } let minute_sync_delay = 1; // loop thru all the configured when events for (let m of Object.keys(this.config.compliments)) { // if it is a cron entry if (this.isCronEntry(m)) { // we need to synch our interval cycle to the minute minute_sync_delay = (60 - (moment().second())) * 1000; break; } } // Schedule update timer. sync to the minute start (if needed), so minute based events happen on the minute start setTimeout(() => { setInterval(() => { this.updateDom(this.config.fadeSpeed); }, this.config.updateInterval); }, minute_sync_delay); }, // check to see if this entry could be a cron entry which contains spaces isCronEntry (entry) { return entry.includes(" "); }, /** * @param {string} cronExpression The cron expression. See https://croner.56k.guru/usage/pattern/ * @param {Date} [timestamp] The timestamp to check. Defaults to the current time. * @returns {number} The number of seconds until the next cron run. */ getSecondsUntilNextCronRun (cronExpression, timestamp = new Date()) { // Required for seconds precision const adjustedTimestamp = new Date(timestamp.getTime() - 1000); // https://www.npmjs.com/package/croner const cronJob = new Cron(cronExpression); const nextRunTime = cronJob.nextRun(adjustedTimestamp); const secondsDelta = (nextRunTime - adjustedTimestamp) / 1000; return secondsDelta; }, /** * Generate a random index for a list of compliments. * @param {string[]} compliments Array with compliments. * @returns {number} a random index of given array */ randomIndex (compliments) { if (compliments.length <= 1) { return 0; } const generate = function () { return Math.floor(Math.random() * compliments.length); }; let complimentIndex = generate(); while (complimentIndex === this.lastComplimentIndex) { complimentIndex = generate(); } this.lastComplimentIndex = complimentIndex; return complimentIndex; }, /** * Retrieve an array of compliments for the time of the day. * @returns {string[]} array with compliments for the time of the day. */ complimentArray () { const now = moment(); const hour = now.hour(); const date = now.format("YYYY-MM-DD"); let compliments = []; // Add time of day compliments 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 if (this.currentWeatherType in this.config.compliments) { Array.prototype.push.apply(compliments, this.config.compliments[this.currentWeatherType]); // if the predefine list doesn't include it (yet) if (!this.pre_defined_types.includes(this.currentWeatherType)) { // add it this.pre_defined_types.push(this.currentWeatherType); } } // Add compliments for anytime Array.prototype.push.apply(compliments, this.config.compliments.anytime); // get the list of just date entry keys let temp_list = Object.keys(this.config.compliments).filter((k) => { if (this.pre_defined_types.includes(k)) return false; else return true; }); let date_compliments = []; // Add compliments for special day/times for (let entry of temp_list) { // check if this could be a cron type entry if (this.isCronEntry(entry)) { // make sure the regex is valid if (new RegExp(this.cron_regex).test(entry)) { // check if we are in the time range for the cron entry if (this.getSecondsUntilNextCronRun(entry, now.set("seconds", 0).toDate()) <= 1) { // if so, use its notice entries Array.prototype.push.apply(date_compliments, this.config.compliments[entry]); } } else Log.error(`[compliments] cron syntax invalid=${JSON.stringify(entry)}`); } else if (new RegExp(entry).test(date)) { Array.prototype.push.apply(date_compliments, this.config.compliments[entry]); } } // if we found any date compliments if (date_compliments.length) { // and the special flag is true if (this.config.specialDayUnique) { // clear the non-date compliments if any compliments.length = 0; } // put the date based compliments on the list Array.prototype.push.apply(compliments, date_compliments); } return compliments; }, /** * Retrieve a file from the local filesystem * @returns {Promise} Resolved with file content or null on error */ async loadComplimentFile () { const { remoteFile, remoteFileRefreshInterval } = this.config; const isRemote = remoteFile.startsWith("http://") || remoteFile.startsWith("https://"); let url = isRemote ? remoteFile : this.file(remoteFile); try { // Validate URL const urlObj = new URL(url); // Add cache-busting parameter to remote URLs to prevent cached responses if (isRemote && remoteFileRefreshInterval !== 0) { urlObj.searchParams.set("dummy", Date.now()); } url = urlObj.toString(); } catch { Log.warn(`[compliments] Invalid URL: ${url}`); } try { const response = await fetch(url); if (!response.ok) { Log.error(`[compliments] HTTP error: ${response.status} ${response.statusText}`); return null; } return await response.text(); } catch (error) { Log.info("[compliments] fetch failed:", error.message); return null; } }, /** * Retrieve a random compliment. * @returns {string} a compliment */ getRandomCompliment () { // get the current time of day compliments list const compliments = this.complimentArray(); // variable for index to next message to display let index; // are we randomizing if (this.config.random) { // yes index = this.randomIndex(compliments); } else { // no, sequential // if doing sequential, don't fall off the end index = this.lastIndexUsed >= compliments.length - 1 ? 0 : ++this.lastIndexUsed; } return compliments[index] || ""; }, // Override dom generator. getDom () { const wrapper = document.createElement("div"); wrapper.className = this.config.classes ? this.config.classes : "thin xlarge bright pre-line"; // get the compliment text const complimentText = this.getRandomCompliment(); // split it into parts on newline text const parts = complimentText.split("\n"); // create a span to hold the compliment const compliment = document.createElement("span"); // process all the parts of the compliment text for (const part of parts) { if (part !== "") { // create a text element for each part compliment.appendChild(document.createTextNode(part)); // add a break compliment.appendChild(document.createElement("BR")); } } // only add compliment to wrapper if there is actual text in there if (compliment.children.length > 0) { // remove the last break compliment.lastElementChild.remove(); wrapper.appendChild(compliment); } // if a new set of compliments was loaded from the refresh task // we do this here to make sure no other function is using the compliments list if (this.compliments_new) { // use them if (JSON.stringify(this.config.compliments) !== JSON.stringify(this.compliments_new)) { // only reset if the contents changes this.config.compliments = this.compliments_new; // reset the index this.lastIndexUsed = -1; } // clear new file list so we don't waste cycles comparing between refreshes this.compliments_new = null; } // only in test mode if (window.mmTestMode === "true") { // check for (undocumented) remoteFile2 to test new file load if (this.config.remoteFile2 !== null && this.config.remoteFileRefreshInterval !== 0) { // switch the file so that next time it will be loaded from a changed file this.config.remoteFile = this.config.remoteFile2; } } return wrapper; }, // Override notification handler. notificationReceived (notification, payload, sender) { if (notification === "CURRENTWEATHER_TYPE") { this.currentWeatherType = payload.type; } } }); ================================================ FILE: modules/default/defaultmodules.js ================================================ /* * Default Modules List * Modules listed below can be loaded without the 'default/' prefix. Omitting the default folder name. */ const defaultModules = ["alert", "calendar", "clock", "compliments", "helloworld", "newsfeed", "updatenotification", "weather"]; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = defaultModules; } ================================================ FILE: modules/default/helloworld/README.md ================================================ # Module: Hello World The `helloworld` module is one of the default modules of the MagicMirror². It is a simple way to display a static text on the mirror. For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/helloworld.html). ================================================ FILE: modules/default/helloworld/helloworld.js ================================================ Module.register("helloworld", { // Default module config. defaults: { text: "Hello World!" }, getTemplate () { return "helloworld.njk"; }, getTemplateData () { return this.config; } }); ================================================ FILE: modules/default/helloworld/helloworld.njk ================================================
{{ text | safe }}
================================================ FILE: modules/default/newsfeed/README.md ================================================ # Module: News Feed The `newsfeed` module is one of the default modules of the MagicMirror². This module displays news headlines based on an RSS feed. Scrolling through news headlines happens time-based (`updateInterval`), but can also be controlled by sending news feed specific notifications to the module. For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/newsfeed.html). ================================================ FILE: modules/default/newsfeed/fullarticle.njk ================================================
================================================ FILE: modules/default/newsfeed/newsfeed.css ================================================ iframe.newsfeed-fullarticle { width: 100vw; /* very large height value to allow scrolling */ height: 3000px; top: 0; left: 0; border: none; z-index: 1; } .region.bottom.bar.newsfeed-fullarticle { bottom: inherit; top: -90px; } .newsfeed-list { list-style: none; } .newsfeed-list li { text-align: justify; margin-bottom: 0.5em; } ================================================ FILE: modules/default/newsfeed/newsfeed.js ================================================ Module.register("newsfeed", { // Default module config. defaults: { feeds: [ { title: "New York Times", url: "https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml", encoding: "UTF-8" //ISO-8859-1 } ], showAsList: false, showSourceTitle: true, showPublishDate: true, broadcastNewsFeeds: true, broadcastNewsUpdates: true, showDescription: false, showTitleAsUrl: false, wrapTitle: true, wrapDescription: true, truncDescription: true, lengthDescription: 400, hideLoading: false, reloadInterval: 5 * 60 * 1000, // every 5 minutes updateInterval: 10 * 1000, animationSpeed: 2.5 * 1000, maxNewsItems: 0, // 0 for unlimited ignoreOldItems: false, ignoreOlderThan: 24 * 60 * 60 * 1000, // 1 day removeStartTags: "", removeEndTags: "", startTags: [], endTags: [], prohibitedWords: [], scrollLength: 500, logFeedWarnings: false, dangerouslyDisableAutoEscaping: false }, getUrlPrefix (item) { if (item.useCorsProxy) { return `${location.protocol}//${location.host}${config.basePath}cors?url=`; } else { return ""; } }, // Define required scripts. getScripts () { return ["moment.js"]; }, //Define required styles. getStyles () { return ["newsfeed.css"]; }, // Define required translations. getTranslations () { // The translations for the default modules are defined in the core translation files. // 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; }, // Define start sequence. start () { Log.info(`Starting module: ${this.name}`); // Set locale. moment.locale(config.language); this.newsItems = []; this.loaded = false; this.error = null; this.activeItem = 0; this.scrollPosition = 0; this.registerFeeds(); this.isShowingDescription = this.config.showDescription; }, // Override socket notification handler. socketNotificationReceived (notification, payload) { if (notification === "NEWS_ITEMS") { this.generateFeed(payload); if (!this.loaded) { if (this.config.hideLoading) { this.show(); } this.scheduleUpdateInterval(); } this.loaded = true; this.error = null; } else if (notification === "NEWSFEED_ERROR") { this.error = this.translate(payload.error_type); this.scheduleUpdateInterval(); } }, //Override fetching of template name getTemplate () { if (this.config.feedUrl) { return "oldconfig.njk"; } else if (this.config.showFullArticle) { return "fullarticle.njk"; } return "newsfeed.njk"; }, //Override template data and return whats used for the current template getTemplateData () { if (this.activeItem >= this.newsItems.length) { this.activeItem = 0; } this.activeItemCount = this.newsItems.length; // this.config.showFullArticle is a run-time configuration, triggered by optional notifications if (this.config.showFullArticle) { this.activeItemHash = this.newsItems[this.activeItem]?.hash; return { url: this.getActiveItemURL() }; } if (this.error) { this.activeItemHash = undefined; return { error: this.error }; } if (this.newsItems.length === 0) { this.activeItemHash = undefined; return { empty: true }; } const item = this.newsItems[this.activeItem]; this.activeItemHash = item.hash; const items = this.newsItems.map(function (item) { item.publishDate = moment(new Date(item.pubdate)).fromNow(); return item; }); return { loaded: true, config: this.config, sourceTitle: item.sourceTitle, publishDate: moment(new Date(item.pubdate)).fromNow(), title: item.title, url: this.getActiveItemURL(), description: item.description, items: items }; }, getActiveItemURL () { const item = this.newsItems[this.activeItem]; if (item) { return typeof item.url === "string" ? this.getUrlPrefix(item) + item.url : this.getUrlPrefix(item) + item.url.href; } else { return ""; } }, /** * Registers the feeds to be used by the backend. */ registerFeeds () { for (let feed of this.config.feeds) { this.sendSocketNotification("ADD_FEED", { feed: feed, config: this.config }); } }, /** * Gets a feed property by name * @param {object} feed A feed object. * @param {string} property The name of the property. * @returns {string} The value of the specified property for the feed. */ getFeedProperty (feed, property) { let res = this.config[property]; const f = this.config.feeds.find((feedItem) => feedItem.url === feed); if (f && f[property]) res = f[property]; return res; }, /** * Generate an ordered list of items for this configured module. * @param {object} feeds An object with feeds returned by the node helper. */ generateFeed (feeds) { let newsItems = []; for (let feed in feeds) { const feedItems = feeds[feed]; if (this.subscribedToFeed(feed)) { for (let item of feedItems) { item.sourceTitle = this.titleForFeed(feed); if (!(this.getFeedProperty(feed, "ignoreOldItems") && Date.now() - new Date(item.pubdate) > this.getFeedProperty(feed, "ignoreOlderThan"))) { newsItems.push(item); } } } } newsItems.sort(function (a, b) { const dateA = new Date(a.pubdate); const dateB = new Date(b.pubdate); return dateB - dateA; }); if (this.config.maxNewsItems > 0) { newsItems = newsItems.slice(0, this.config.maxNewsItems); } if (this.config.prohibitedWords.length > 0) { newsItems = newsItems.filter(function (item) { for (let word of this.config.prohibitedWords) { if (item.title.toLowerCase().indexOf(word.toLowerCase()) > -1) { return false; } } return true; }, this); } newsItems.forEach((item) => { //Remove selected tags from the beginning of rss feed items (title or description) if (this.config.removeStartTags === "title" || this.config.removeStartTags === "both") { for (let startTag of this.config.startTags) { if (item.title.slice(0, startTag.length) === startTag) { item.title = item.title.slice(startTag.length, item.title.length); } } } if (this.config.removeStartTags === "description" || this.config.removeStartTags === "both") { if (this.isShowingDescription) { for (let startTag of this.config.startTags) { if (item.description.slice(0, startTag.length) === startTag) { item.description = item.description.slice(startTag.length, item.description.length); } } } } //Remove selected tags from the end of rss feed items (title or description) if (this.config.removeEndTags) { for (let endTag of this.config.endTags) { if (item.title.slice(-endTag.length) === endTag) { item.title = item.title.slice(0, -endTag.length); } } if (this.isShowingDescription) { for (let endTag of this.config.endTags) { if (item.description.slice(-endTag.length) === endTag) { item.description = item.description.slice(0, -endTag.length); } } } } }); // get updated news items and broadcast them const updatedItems = []; newsItems.forEach((value) => { if (this.newsItems.findIndex((value1) => value1 === value) === -1) { // Add item to updated items list updatedItems.push(value); } }); // check if updated items exist, if so and if we should broadcast these updates, then lets do so if (this.config.broadcastNewsUpdates && updatedItems.length > 0) { this.sendNotification("NEWS_FEED_UPDATE", { items: updatedItems }); } this.newsItems = newsItems; }, /** * Check if this module is configured to show this feed. * @param {string} feedUrl Url of the feed to check. * @returns {boolean} True if it is subscribed, false otherwise */ subscribedToFeed (feedUrl) { for (let feed of this.config.feeds) { if (feed.url === feedUrl) { return true; } } return false; }, /** * Returns title for the specific feed url. * @param {string} feedUrl Url of the feed * @returns {string} The title of the feed */ titleForFeed (feedUrl) { for (let feed of this.config.feeds) { if (feed.url === feedUrl) { return feed.title || ""; } } return ""; }, /** * Schedule visual update. */ scheduleUpdateInterval () { this.updateDom(this.config.animationSpeed); // Broadcast NewsFeed if needed if (this.config.broadcastNewsFeeds) { this.sendNotification("NEWS_FEED", { items: this.newsItems }); } // #2638 Clear timer if it already exists if (this.timer) clearInterval(this.timer); this.timer = setInterval(() => { /* * When animations are enabled, don't update the DOM unless we are actually changing what we are displaying. * (Animating from a headline to itself is unsightly.) * Cases: * * Number of items | Number of items | Display * at last update | right now | Behaviour * ---------------------------------------------------- * 0 | 0 | do not update * 0 | >0 | update * 1 | 0 or >1 | update * 1 | 1 | update only if item details (hash value) changed * >1 | any | update * * (N.B. We set activeItemCount and activeItemHash in getTemplateData().) */ if (this.newsItems.length > 1 || this.newsItems.length !== this.activeItemCount || this.activeItemHash !== this.newsItems[0]?.hash) { this.activeItem++; // this is OK if newsItems.Length==1; getTemplateData will wrap it around this.updateDom(this.config.animationSpeed); } // Broadcast NewsFeed if needed if (this.config.broadcastNewsFeeds) { this.sendNotification("NEWS_FEED", { items: this.newsItems }); } }, this.config.updateInterval); }, resetDescrOrFullArticleAndTimer () { this.isShowingDescription = this.config.showDescription; this.config.showFullArticle = false; this.scrollPosition = 0; // reset bottom bar alignment document.getElementsByClassName("region bottom bar")[0].classList.remove("newsfeed-fullarticle"); if (!this.timer) { this.scheduleUpdateInterval(); } }, notificationReceived (notification, payload, sender) { const before = this.activeItem; if (notification === "MODULE_DOM_CREATED" && this.config.hideLoading) { this.hide(); } else if (notification === "ARTICLE_NEXT") { this.activeItem++; if (this.activeItem >= this.newsItems.length) { this.activeItem = 0; } this.resetDescrOrFullArticleAndTimer(); Log.debug(`[newsfeed] going from article #${before} to #${this.activeItem} (of ${this.newsItems.length})`); this.updateDom(100); } else if (notification === "ARTICLE_PREVIOUS") { this.activeItem--; if (this.activeItem < 0) { this.activeItem = this.newsItems.length - 1; } this.resetDescrOrFullArticleAndTimer(); Log.debug(`[newsfeed] going from article #${before} to #${this.activeItem} (of ${this.newsItems.length})`); this.updateDom(100); } // if "more details" is received the first time: show article summary, on second time show full article else if (notification === "ARTICLE_MORE_DETAILS") { // full article is already showing, so scrolling down if (this.config.showFullArticle === true) { this.scrollPosition += this.config.scrollLength; window.scrollTo(0, this.scrollPosition); Log.debug("[newsfeed] scrolling down"); Log.debug(`[newsfeed] ARTICLE_MORE_DETAILS, scroll position: ${this.config.scrollLength}`); } else { this.showFullArticle(); } } else if (notification === "ARTICLE_SCROLL_UP") { if (this.config.showFullArticle === true) { this.scrollPosition -= this.config.scrollLength; window.scrollTo(0, this.scrollPosition); Log.debug("[newsfeed] scrolling up"); Log.debug(`[newsfeed] ARTICLE_SCROLL_UP, scroll position: ${this.config.scrollLength}`); } } else if (notification === "ARTICLE_LESS_DETAILS") { this.resetDescrOrFullArticleAndTimer(); Log.debug("[newsfeed] showing only article titles again"); this.updateDom(100); } else if (notification === "ARTICLE_TOGGLE_FULL") { if (this.config.showFullArticle) { this.activeItem++; this.resetDescrOrFullArticleAndTimer(); } else { this.showFullArticle(); } } else if (notification === "ARTICLE_INFO_REQUEST") { this.sendNotification("ARTICLE_INFO_RESPONSE", { title: this.newsItems[this.activeItem].title, source: this.newsItems[this.activeItem].sourceTitle, date: this.newsItems[this.activeItem].pubdate, desc: this.newsItems[this.activeItem].description, url: this.getActiveItemURL() }); } }, showFullArticle () { this.isShowingDescription = !this.isShowingDescription; this.config.showFullArticle = !this.isShowingDescription; // make bottom bar align to top to allow scrolling if (this.config.showFullArticle === true) { document.getElementsByClassName("region bottom bar")[0].classList.add("newsfeed-fullarticle"); } clearInterval(this.timer); this.timer = null; Log.debug(`[newsfeed] showing ${this.isShowingDescription ? "article description" : "full article"}`); this.updateDom(100); } }); ================================================ FILE: modules/default/newsfeed/newsfeed.njk ================================================ {% macro escapeText(text, dangerouslyDisableAutoEscaping=false) %} {% if dangerouslyDisableAutoEscaping -%} {{ text | safe }} {%- else -%} {{ text }} {%- endif %} {% endmacro %} {% macro escapeTitle(title, url, dangerouslyDisableAutoEscaping=false, showTitleAsUrl=false) %} {% if dangerouslyDisableAutoEscaping %} {% if showTitleAsUrl %} {{ title | safe }} {% else %} {{ title | safe }} {% endif %} {% else %} {% if showTitleAsUrl %} {{ title }} {% else %} {{ title }} {% endif %} {% endif %} {% endmacro %} {% if loaded %} {% if config.showAsList %}
    {% for item in items %}
  • {% if (config.showSourceTitle and item.sourceTitle) or config.showPublishDate %}
    {% if item.sourceTitle and config.showSourceTitle %} {{ item.sourceTitle }}{% if config.showPublishDate %}, {% else %}:{% endif %} {% endif %} {% if config.showPublishDate %}{{ item.publishDate }}:{% endif %}
    {% endif %}
    {{ escapeTitle(item.title, item.url, config.dangerouslyDisableAutoEscaping, config.showTitleAsUrl) }}
    {% if config.showDescription %}
    {% if config.truncDescription %} {{ escapeText(item.description | truncate(config.lengthDescription) , config.dangerouslyDisableAutoEscaping) }} {% else %} {{ escapeText(item.description, config.dangerouslyDisableAutoEscaping) }} {% endif %}
    {% endif %}
  • {% endfor %}
{% else %}
{% if (config.showSourceTitle and sourceTitle) or config.showPublishDate %}
{% if sourceTitle and config.showSourceTitle %} {{ escapeText(sourceTitle, config.dangerouslyDisableAutoEscaping) }}{% if config.showPublishDate %}, {% else %}:{% endif %} {% endif %} {% if config.showPublishDate %}{{ publishDate }}:{% endif %}
{% endif %}
{{ escapeTitle(title, url, config.dangerouslyDisableAutoEscaping, config.showTitleAsUrl) }}
{% if config.showDescription %}
{% if config.truncDescription %} {{ escapeText(description | truncate(config.lengthDescription) , config.dangerouslyDisableAutoEscaping) }} {% else %} {{ escapeText(description, config.dangerouslyDisableAutoEscaping) }} {% endif %}
{% endif %}
{% endif %} {% elseif empty %}
{{ "NEWSFEED_NO_ITEMS" | translate | safe }}
{% elseif error %}
{{ "MODULE_CONFIG_ERROR" | translate({MODULE_NAME: "Newsfeed", ERROR: error}) | safe }}
{% else %}
{{ "LOADING" | translate | safe }}
{% endif %} ================================================ FILE: modules/default/newsfeed/newsfeedfetcher.js ================================================ const crypto = require("node:crypto"); const stream = require("node:stream"); const FeedMe = require("feedme"); const iconv = require("iconv-lite"); const { htmlToText } = require("html-to-text"); const Log = require("logger"); const NodeHelper = require("node_helper"); const { getUserAgent } = require("#server_functions"); const { scheduleTimer } = require("#module_functions"); /** * Responsible for requesting an update on the set interval and broadcasting the data. * @param {string} url URL of the news feed. * @param {number} reloadInterval Reload interval in milliseconds. * @param {string} encoding Encoding of the feed. * @param {boolean} logFeedWarnings If true log warnings when there is an error parsing a news article. * @param {boolean} useCorsProxy If true cors proxy is used for article url's. * @class */ const NewsfeedFetcher = function (url, reloadInterval, encoding, logFeedWarnings, useCorsProxy) { let reloadTimer = null; let items = []; let reloadIntervalMS = reloadInterval; let fetchFailedCallback = function () {}; let itemsReceivedCallback = function () {}; if (reloadIntervalMS < 1000) { reloadIntervalMS = 1000; } /* private methods */ /** * Request the new items. */ const fetchNews = () => { clearTimeout(reloadTimer); reloadTimer = null; items = []; const parser = new FeedMe(); parser.on("item", (item) => { const title = item.title; let description = item.description || item.summary || item.content || ""; const pubdate = item.pubdate || item.published || item.updated || item["dc:date"] || item["a10:updated"]; const url = item.url || item.link || ""; if (title && pubdate) { // Convert HTML entities, codes and tag description = htmlToText(description, { wordwrap: false, selectors: [ { selector: "a", options: { ignoreHref: true, noAnchorUrl: true } }, { selector: "br", format: "inlineSurround", options: { prefix: " " } }, { selector: "img", format: "skip" } ] }); items.push({ title: title, description: description, pubdate: pubdate, url: url, useCorsProxy: useCorsProxy, hash: crypto.createHash("sha256").update(`${pubdate} :: ${title} :: ${url}`).digest("hex") }); } else if (logFeedWarnings) { Log.warn("Can't parse feed item:", item); Log.warn(`Title: ${title}`); Log.warn(`Description: ${description}`); Log.warn(`Pubdate: ${pubdate}`); } }); parser.on("end", () => { this.broadcastItems(); }); parser.on("error", (error) => { fetchFailedCallback(this, error); scheduleTimer(reloadTimer, reloadIntervalMS, fetchNews); }); //"end" event is not broadcast if the feed is empty but "finish" is used for both parser.on("finish", () => { scheduleTimer(reloadTimer, reloadIntervalMS, fetchNews); }); parser.on("ttl", (minutes) => { try { // 86400000 = 24 hours is mentioned in the docs as maximum value: const ttlms = Math.min(minutes * 60 * 1000, 86400000); if (ttlms > reloadIntervalMS) { reloadIntervalMS = ttlms; Log.info(`reloadInterval set to ttl=${reloadIntervalMS} for url ${url}`); } } catch (error) { Log.warn(`feed ttl is no valid integer=${minutes} for url ${url}`); } }); const headers = { "User-Agent": getUserAgent(), "Cache-Control": "max-age=0, no-cache, no-store, must-revalidate", Pragma: "no-cache" }; fetch(url, { headers: headers }) .then(NodeHelper.checkFetchStatus) .then((response) => { let nodeStream; if (response.body instanceof stream.Readable) { nodeStream = response.body; } else { nodeStream = stream.Readable.fromWeb(response.body); } nodeStream.pipe(iconv.decodeStream(encoding)).pipe(parser); }) .catch((error) => { fetchFailedCallback(this, error); scheduleTimer(reloadTimer, reloadIntervalMS, fetchNews); }); }; /* public methods */ /** * Update the reload interval, but only if we need to increase the speed. * @param {number} interval Interval for the update in milliseconds. */ this.setReloadInterval = function (interval) { if (interval > 1000 && interval < reloadIntervalMS) { reloadIntervalMS = interval; } }; /** * Initiate fetchNews(); */ this.startFetch = function () { fetchNews(); }; /** * Broadcast the existing items. */ this.broadcastItems = function () { if (items.length <= 0) { Log.info("No items to broadcast yet."); return; } Log.info(`Broadcasting ${items.length} items.`); itemsReceivedCallback(this); }; this.onReceive = function (callback) { itemsReceivedCallback = callback; }; this.onError = function (callback) { fetchFailedCallback = callback; }; this.url = function () { return url; }; this.items = function () { return items; }; }; module.exports = NewsfeedFetcher; ================================================ FILE: modules/default/newsfeed/node_helper.js ================================================ const NodeHelper = require("node_helper"); const Log = require("logger"); const NewsfeedFetcher = require("./newsfeedfetcher"); module.exports = NodeHelper.create({ // Override start method. start () { Log.log(`Starting node helper for: ${this.name}`); this.fetchers = []; }, // Override socketNotificationReceived received. socketNotificationReceived (notification, payload) { if (notification === "ADD_FEED") { this.createFetcher(payload.feed, payload.config); } }, /** * Creates a fetcher for a new feed if it doesn't exist yet. * Otherwise it reuses the existing one. * @param {object} feed The feed object * @param {object} config The configuration object */ createFetcher (feed, config) { const url = feed.url || ""; const encoding = feed.encoding || "UTF-8"; const reloadInterval = feed.reloadInterval || config.reloadInterval || 5 * 60 * 1000; let useCorsProxy = feed.useCorsProxy; if (useCorsProxy === undefined) useCorsProxy = true; try { new URL(url); } catch (error) { Log.error("Error: Malformed newsfeed url: ", url, error); this.sendSocketNotification("NEWSFEED_ERROR", { error_type: "MODULE_ERROR_MALFORMED_URL" }); return; } let fetcher; if (typeof this.fetchers[url] === "undefined") { Log.log(`Create new newsfetcher for url: ${url} - Interval: ${reloadInterval}`); fetcher = new NewsfeedFetcher(url, reloadInterval, encoding, config.logFeedWarnings, useCorsProxy); fetcher.onReceive(() => { this.broadcastFeeds(); }); fetcher.onError((fetcher, error) => { Log.error("Error: Could not fetch newsfeed: ", url, error); let error_type = NodeHelper.checkFetchError(error); this.sendSocketNotification("NEWSFEED_ERROR", { error_type }); }); this.fetchers[url] = fetcher; } else { Log.log(`Use existing newsfetcher for url: ${url}`); fetcher = this.fetchers[url]; fetcher.setReloadInterval(reloadInterval); fetcher.broadcastItems(); } fetcher.startFetch(); }, /** * Creates an object with all feed items of the different registered feeds, * and broadcasts these using sendSocketNotification. */ broadcastFeeds () { const feeds = {}; for (let f in this.fetchers) { feeds[f] = this.fetchers[f].items(); } this.sendSocketNotification("NEWS_ITEMS", feeds); } }); ================================================ FILE: modules/default/newsfeed/oldconfig.njk ================================================
{{ "MODULE_CONFIG_CHANGED" | translate({MODULE_NAME: "Newsfeed"}) | safe }}
================================================ FILE: modules/default/updatenotification/README.md ================================================ # Module: Update Notification The `updatenotification` module is one of the default modules of the MagicMirror². This will display a message whenever a new version of the MagicMirror² application is available. For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/updatenotification.html). ================================================ FILE: modules/default/updatenotification/git_helper.js ================================================ const util = require("node:util"); const exec = util.promisify(require("node:child_process").exec); const fs = require("node:fs"); const path = require("node:path"); const Log = require("logger"); class GitHelper { constructor () { this.gitRepos = []; this.gitResultList = []; } getRefRegex (branch) { return new RegExp(`s*([a-z,0-9]+[.][.][a-z,0-9]+) ${branch}`, "g"); } async execShell (command) { const { stdout = "", stderr = "" } = await exec(command); return { stdout, stderr }; } async isGitRepo (moduleFolder) { const { stderr } = await this.execShell(`cd ${moduleFolder} && git remote -v`); if (stderr) { Log.error(`Failed to fetch git data for ${moduleFolder}: ${stderr}`); return false; } return true; } async add (moduleName) { let moduleFolder = `${global.root_path}`; if (moduleName !== "MagicMirror") { moduleFolder = `${moduleFolder}/modules/${moduleName}`; } try { Log.info(`Checking git for module: ${moduleName}`); // Throws error if file doesn't exist fs.statSync(path.join(moduleFolder, ".git")); // Fetch the git or throw error if no remotes const isGitRepo = await this.isGitRepo(moduleFolder); if (isGitRepo) { // Folder has .git and has at least one git remote, watch this folder this.gitRepos.push({ module: moduleName, folder: moduleFolder }); } } catch (err) { // Error when directory .git doesn't exist or doesn't have any remotes // This module is not managed with git, skip } } async getStatusInfo (repo) { let gitInfo = { module: repo.module, behind: 0, // commits behind current: "", // branch name hash: "", // current hash tracking: "", // remote branch isBehindInStatus: false }; if (repo.module === "MagicMirror") { // the hash is only needed for the mm repo const { stderr, stdout } = await this.execShell(`cd ${repo.folder} && git rev-parse HEAD`); if (stderr) { Log.error(`Failed to get current commit hash for ${repo.module}: ${stderr}`); } gitInfo.hash = stdout; } const { stderr, stdout } = await this.execShell(`cd ${repo.folder} && git status -sb`); if (stderr) { Log.error(`Failed to get git status for ${repo.module}: ${stderr}`); // exit without git status info return; } // only the first line of stdout is evaluated let status = stdout.split("\n")[0]; // examples for status: // ## develop...origin/develop // ## master...origin/master [behind 8] // ## master...origin/master [ahead 8, behind 1] // ## HEAD (no branch) status = status.match(/## (.*)\.\.\.([^ ]*)(?: .*behind (\d+))?/); // examples for status: // [ '## develop...origin/develop', 'develop', 'origin/develop' ] // [ '## master...origin/master [behind 8]', 'master', 'origin/master', '8' ] // [ '## master...origin/master [ahead 8, behind 1]', 'master', 'origin/master', '1' ] if (status) { gitInfo.current = status[1]; gitInfo.tracking = status[2]; if (status[3]) { // git fetch was already called before so `git status -sb` delivers already the behind number gitInfo.behind = parseInt(status[3]); gitInfo.isBehindInStatus = true; } } return gitInfo; } async getRepoInfo (repo) { const gitInfo = await this.getStatusInfo(repo); if (!gitInfo || !gitInfo.current) { return; } if (gitInfo.isBehindInStatus && (gitInfo.module !== "MagicMirror" || gitInfo.current !== "master")) { return gitInfo; } const { stderr } = await this.execShell(`cd ${repo.folder} && git fetch -n --dry-run`); // example output: // From https://github.com/MagicMirrorOrg/MagicMirror // e40ddd4..06389e3 develop -> origin/develop // here the result is in stderr (this is a git default, don't ask why ...) const matches = stderr.match(this.getRefRegex(gitInfo.current)); // this is the default if there was no match from "git fetch -n --dry-run". // Its a fallback because if there was a real "git fetch", the above "git fetch -n --dry-run" would deliver nothing. let refDiff = `${gitInfo.current}..origin/${gitInfo.current}`; if (matches && matches[0]) { refDiff = matches[0]; } // get behind with refs try { const { stdout } = await this.execShell(`cd ${repo.folder} && git rev-list --ancestry-path --count ${refDiff}`); gitInfo.behind = parseInt(stdout); // for MagicMirror-Repo and "master" branch avoid getting notified when no tag is in refDiff // so only releases are reported and we can change e.g. the README.md without sending notifications if (gitInfo.behind > 0 && gitInfo.module === "MagicMirror" && gitInfo.current === "master") { let tagList = ""; try { const { stdout } = await this.execShell(`cd ${repo.folder} && git ls-remote -q --tags --refs`); tagList = stdout.trim(); } catch (err) { Log.error(`Failed to get tag list for ${repo.module}: ${err}`); } // check if tag is between commits and only report behind > 0 if so try { const { stdout } = await this.execShell(`cd ${repo.folder} && git rev-list --ancestry-path ${refDiff}`); let cnt = 0; for (const ref of stdout.trim().split("\n")) { if (tagList.includes(ref)) cnt++; // tag found } if (cnt === 0) gitInfo.behind = 0; } catch (err) { Log.error(`Failed to get git revisions for ${repo.module}: ${err}`); } } return gitInfo; } catch (err) { Log.error(`Failed to get git revisions for ${repo.module}: ${err}`); } } async getRepos () { this.gitResultList = []; for (const repo of this.gitRepos) { try { const gitInfo = await this.getRepoInfo(repo); if (gitInfo) { this.gitResultList.push(gitInfo); } } catch (e) { // Only log errors in non-test environments to keep test output clean if (process.env.mmTestMode !== "true") { Log.error(`Failed to retrieve repo info for ${repo.module}: ${e}`); } } } return this.gitResultList; } async checkUpdates () { var updates = []; const allRepos = await this.gitResultList.map((module) => { return new Promise((resolve) => { if (module.behind > 0 && module.module !== "MagicMirror") { Log.info(`Update found for module: ${module.module}`); updates.push(module); } resolve(module); }); }); await Promise.all(allRepos); return updates; } } module.exports = GitHelper; ================================================ FILE: modules/default/updatenotification/node_helper.js ================================================ const fs = require("node:fs"); const path = require("node:path"); const NodeHelper = require("node_helper"); const defaultModules = require(`${global.root_path}/modules/default/defaultmodules`); const GitHelper = require("./git_helper"); const UpdateHelper = require("./update_helper"); const ONE_MINUTE = 60 * 1000; module.exports = NodeHelper.create({ config: {}, updateTimer: null, updateProcessStarted: false, gitHelper: new GitHelper(), updateHelper: null, getModules (modules) { if (this.config.useModulesFromConfig) { return modules; } else { // get modules from modules-directory const moduleDir = path.normalize(`${global.root_path}/modules`); const getDirectories = (source) => { return fs.readdirSync(source, { withFileTypes: true }) .filter((dirent) => dirent.isDirectory() && dirent.name !== "default") .map((dirent) => dirent.name); }; return getDirectories(moduleDir); } }, async configureModules (modules) { for (const moduleName of this.getModules(modules)) { if (!this.ignoreUpdateChecking(moduleName)) { await this.gitHelper.add(moduleName); } } if (!this.ignoreUpdateChecking("MagicMirror")) { await this.gitHelper.add("MagicMirror"); } }, async socketNotificationReceived (notification, payload) { switch (notification) { case "CONFIG": this.config = payload; this.updateHelper = new UpdateHelper(this.config); await this.updateHelper.check_PM2_Process(); break; case "MODULES": // if this is the 1st time thru the update check process if (!this.updateProcessStarted) { this.updateProcessStarted = true; await this.configureModules(payload); await this.performFetch(); } break; case "SCAN_UPDATES": // 1st time of check allows to force new scan if (this.updateProcessStarted) { clearTimeout(this.updateTimer); await this.performFetch(); } break; } }, async performFetch () { const repos = await this.gitHelper.getRepos(); for (const repo of repos) { this.sendSocketNotification("REPO_STATUS", repo); } const updates = await this.gitHelper.checkUpdates(); if (this.config.sendUpdatesNotifications && updates.length) { this.sendSocketNotification("UPDATES", updates); } if (updates.length) { const updateResult = await this.updateHelper.parse(updates); for (const update of updateResult) { if (update.inProgress) { this.sendSocketNotification("UPDATE_STATUS", update); } } } this.scheduleNextFetch(this.config.updateInterval); }, scheduleNextFetch (delay) { clearTimeout(this.updateTimer); this.updateTimer = setTimeout( () => { this.performFetch(); }, Math.max(delay, ONE_MINUTE) ); }, ignoreUpdateChecking (moduleName) { // Should not check for updates for default modules if (defaultModules.includes(moduleName)) { return true; } // Should not check for updates for ignored modules if (this.config.ignoreModules.includes(moduleName)) { return true; } // The rest of the modules that passes should check for updates return false; } }); ================================================ FILE: modules/default/updatenotification/update_helper.js ================================================ const Exec = require("node:child_process").exec; const Spawn = require("node:child_process").spawn; const fs = require("node:fs"); const Log = require("logger"); /* * class Updater * Allow to self updating 3rd party modules from command defined in config * * [constructor] read value in config: * updates: [ // array of modules update commands * { * : * }, * { * ... * } * ], * updateTimeout: 2 * 60 * 1000, // max update duration * updateAutorestart: false // autoRestart MM when update done ? * * [main command]: parse(): * parse if module update is needed * --> Apply ONLY one update (first of the module list) * --> auto-restart MagicMirror or wait manual restart by user * return array with modules update state information for `updatenotification` module displayer information * [ * { * name = , // name of the module * updateCommand = , // update command (if found) * inProgress = , // an update if in progress for this module * error = , // an error if detected when updating * updated = , // updated successfully * needRestart = // manual restart of MagicMirror is required by user * }, * { * ... * } * ] */ class Updater { constructor (config) { this.updates = config.updates; this.timeout = config.updateTimeout; this.autoRestart = config.updateAutorestart; this.moduleList = {}; this.updating = false; this.usePM2 = false; // don't use pm2 by default this.PM2Id = null; // pm2 process number this.version = global.version; this.root_path = global.root_path; Log.info("Updater Class Loaded!"); } // [main command] parse if module update is needed async parse (modules) { var parser = modules.map(async (module) => { if (this.moduleList[module.module] === undefined) { this.moduleList[module.module] = {}; this.moduleList[module.module].name = module.module; this.moduleList[module.module].updateCommand = await this.applyCommand(module.module); this.moduleList[module.module].inProgress = false; this.moduleList[module.module].error = null; this.moduleList[module.module].updated = false; this.moduleList[module.module].needRestart = false; } if (!this.moduleList[module.module].inProgress) { if (!this.updating) { if (!this.moduleList[module.module].updateCommand) { this.updating = false; } else { this.updating = true; this.moduleList[module.module].inProgress = true; Object.assign(this.moduleList[module.module], await this.updateProcess(this.moduleList[module.module])); } } } }); await Promise.all(parser); let updater = Object.values(this.moduleList); Log.debug("Update Result:", updater); return updater; } /* * module updater with his proper command * return object as result * { * error: , // if error detected * updated: , // if updated successfully * needRestart: // if magicmirror restart required * }; */ updateProcess (module) { let Result = { error: false, updated: false, needRestart: false }; let Command = null; const Path = `${this.root_path}/modules/`; const modulePath = Path + module.name; if (module.updateCommand) { Command = module.updateCommand; } else { Log.warn(`Update of ${module.name} is not supported.`); return Result; } Log.info(`Updating ${module.name}...`); return new Promise((resolve) => { Exec(Command, { cwd: modulePath, timeout: this.timeout }, (error, stdout, stderr) => { if (error) { Log.error(`exec error: ${error}`); Result.error = true; } else { Log.info(`Update logs of ${module.name}: ${stdout}`); Result.updated = true; if (this.autoRestart) { Log.info("Update done"); setTimeout(() => this.restart(), 3000); } else { Log.info("Update done, don't forget to restart MagicMirror!"); Result.needRestart = true; } } resolve(Result); }); }); } // restart rules (pm2 or node --run start) restart () { if (this.usePM2) this.pm2Restart(); else this.nodeRestart(); } // restart MagicMirror with "pm2": use PM2Id for restart it pm2Restart () { Log.info("[PM2] restarting MagicMirror..."); const pm2 = require("pm2"); pm2.restart(this.PM2Id, (err, proc) => { if (err) { Log.error("[PM2] restart Error", err); } }); } // restart MagicMirror with "node --run start" nodeRestart () { Log.info("Restarting MagicMirror..."); const out = process.stdout; const err = process.stderr; const subprocess = Spawn("node --run start", { cwd: this.root_path, shell: true, detached: true, stdio: ["ignore", out, err] }); subprocess.unref(); // detach the newly launched process from the master process process.exit(); } // Check using pm2 check_PM2_Process () { Log.info("Checking PM2 using..."); return new Promise((resolve) => { if (fs.existsSync("/.dockerenv")) { Log.info("[PM2] Running in docker container, not using PM2 ..."); resolve(false); return; } if (process.env.unique_id === undefined) { Log.info("[PM2] You are not using pm2"); resolve(false); return; } Log.debug(`[PM2] Search for pm2 id: ${process.env.pm_id} -- name: ${process.env.name} -- unique_id: ${process.env.unique_id}`); const pm2 = require("pm2"); pm2.connect((err) => { if (err) { Log.error("[PM2]", err); resolve(false); return; } pm2.list((err, list) => { if (err) { Log.error("[PM2] Can't get process List!"); resolve(false); return; } list.forEach((pm) => { Log.debug(`[PM2] found pm2 process id: ${pm.pm_id} -- name: ${pm.name} -- unique_id: ${pm.pm2_env.unique_id}`); if (pm.pm2_env.status === "online" && process.env.name === pm.name && +process.env.pm_id === +pm.pm_id && process.env.unique_id === pm.pm2_env.unique_id) { this.PM2Id = pm.pm_id; this.usePM2 = true; Log.info(`[PM2] You are using pm2 with id: ${this.PM2Id} (${pm.name})`); resolve(true); } else { Log.debug(`[PM2] pm2 process id: ${pm.pm_id} don't match...`); } }); pm2.disconnect(); if (!this.usePM2) { Log.info("[PM2] You are not using pm2"); resolve(false); } }); }); }); } // check if module is MagicMirror isMagicMirror (module) { if (module === "MagicMirror") return true; return false; } // search update module command applyCommand (module) { if (this.isMagicMirror(module.module) || !this.updates.length) return null; let command = null; this.updates.forEach((updater) => { if (updater[module]) command = updater[module]; }); return command; } } module.exports = Updater; ================================================ FILE: modules/default/updatenotification/updatenotification.css ================================================ .module.updatenotification a.difflink { text-decoration: none; } ================================================ FILE: modules/default/updatenotification/updatenotification.js ================================================ Module.register("updatenotification", { defaults: { updateInterval: 10 * 60 * 1000, // every 10 minutes refreshInterval: 24 * 60 * 60 * 1000, // one day ignoreModules: [], sendUpdatesNotifications: false, updates: [], updateTimeout: 2 * 60 * 1000, // max update duration updateAutorestart: false, // autoRestart MM when update done ? useModulesFromConfig: true // if `false` iterate over modules directory }, suspended: false, moduleList: {}, needRestart: false, updates: [], start () { Log.info(`Starting module: ${this.name}`); this.addFilters(); setInterval(() => { this.moduleList = {}; this.updateDom(2); }, this.config.refreshInterval); }, suspend () { this.suspended = true; }, resume () { this.suspended = false; this.updateDom(2); }, notificationReceived (notification) { switch (notification) { case "DOM_OBJECTS_CREATED": this.sendSocketNotification("CONFIG", this.config); this.sendSocketNotification("MODULES", Object.keys(Module.definitions)); break; case "SCAN_UPDATES": this.sendSocketNotification("SCAN_UPDATES"); break; } }, socketNotificationReceived (notification, payload) { switch (notification) { case "REPO_STATUS": this.updateUI(payload); break; case "UPDATES": this.sendNotification("UPDATES", payload); break; case "UPDATE_STATUS": this.updatesNotifier(payload); break; } }, getStyles () { return [`${this.name}.css`]; }, getTemplate () { return `${this.name}.njk`; }, getTemplateData () { return { moduleList: this.moduleList, updatesList: this.updates, suspended: this.suspended, needRestart: this.needRestart }; }, updateUI (payload) { if (payload && payload.behind > 0) { // if we haven't seen info for this module if (this.moduleList[payload.module] === undefined) { // save it this.moduleList[payload.module] = payload; this.updateDom(2); } } else if (payload && payload.behind === 0) { // if the module WAS in the list, but shouldn't be if (this.moduleList[payload.module] !== undefined) { // remove it delete this.moduleList[payload.module]; this.updateDom(2); } } }, addFilters () { this.nunjucksEnvironment().addFilter("diffLink", (text, status) => { if (status.module !== "MagicMirror") { return text; } const localRef = status.hash; const remoteRef = status.tracking.replace(/.*\//, ""); return `${text}`; }); }, updatesNotifier (payload, done = true) { if (this.updates[payload.name] === undefined) { this.updates[payload.name] = { name: payload.name, done: done }; if (payload.error) { this.sendSocketNotification("UPDATE_ERROR", payload.name); this.updates[payload.name].done = false; } else { if (payload.updated) { delete this.moduleList[payload.name]; this.updates[payload.name].done = true; } if (payload.needRestart) { this.needRestart = true; } } this.updateDom(2); } } }); ================================================ FILE: modules/default/updatenotification/updatenotification.njk ================================================ {% if not suspended %} {% if needRestart %}
{% set restartTextLabel = "UPDATE_NOTIFICATION_NEED-RESTART" %} {{ restartTextLabel | translate() | safe }}
{% endif %} {% for name, status in moduleList %}
{% set mainTextLabel = "UPDATE_NOTIFICATION" if name === "MagicMirror" else "UPDATE_NOTIFICATION_MODULE" %} {{ mainTextLabel | translate({MODULE_NAME: name}) }}
{% set subTextLabel = "UPDATE_INFO_SINGLE" if status.behind === 1 else "UPDATE_INFO_MULTIPLE" %} {{ subTextLabel | translate({COMMIT_COUNT: status.behind, BRANCH_NAME: status.current}) | diffLink(status) | safe }}
{% endfor %} {% for name, status in updatesList %}
{% if status.done %} {% set updateTextLabel = "UPDATE_NOTIFICATION_DONE" %} {{ updateTextLabel | translate({MODULE_NAME: name}) | safe }} {% else %} {% set updateTextLabel = "UPDATE_NOTIFICATION_ERROR" %} {{ updateTextLabel | translate({MODULE_NAME: name}) | safe }} {% endif %}
{% endfor %} {% endif %} ================================================ FILE: modules/default/utils.js ================================================ /** * A function to make HTTP requests via the server to avoid CORS-errors. * @param {string} url the url to fetch from * @param {string} type what content-type to expect in the response, can be "json" or "xml" * @param {boolean} useCorsProxy A flag to indicate * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send * @param {Array.} expectedResponseHeaders the expected HTTP headers to receive * @param {string} basePath The base path, default is "/" * @returns {Promise} resolved when the fetch is done. The response headers is placed in a headers-property (provided the response does not already contain a headers-property). */ async function performWebRequest (url, type = "json", useCorsProxy = false, requestHeaders = undefined, expectedResponseHeaders = undefined, basePath = "/") { const request = {}; let requestUrl; if (useCorsProxy) { requestUrl = getCorsUrl(url, requestHeaders, expectedResponseHeaders, basePath); } else { requestUrl = url; request.headers = getHeadersToSend(requestHeaders); } try { const response = await fetch(requestUrl, request); if (response.ok) { const data = await response.text(); if (type === "xml") { return new DOMParser().parseFromString(data, "text/html"); } else { if (!data || !data.length > 0) return undefined; const dataResponse = JSON.parse(data); if (!dataResponse.headers) { dataResponse.headers = getHeadersFromResponse(expectedResponseHeaders, response); } return dataResponse; } } else { throw new Error(`Response status: ${response.status}`); } } catch (error) { Log.error(`Error fetching data from ${url}: ${error}`); return undefined; } } /** * Gets a URL that will be used when calling the CORS-method on the server. * @param {string} url the url to fetch from * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send * @param {Array.} expectedResponseHeaders the expected HTTP headers to receive * @param {string} basePath The base path, default is "/" * @returns {string} to be used as URL when calling CORS-method on server. */ const getCorsUrl = function (url, requestHeaders, expectedResponseHeaders, basePath = "/") { if (!url || url.length < 1) { throw new Error(`Invalid URL: ${url}`); } else { let corsUrl = `${location.protocol}//${location.host}${basePath}cors?`; const requestHeaderString = getRequestHeaderString(requestHeaders); if (requestHeaderString) corsUrl = `${corsUrl}sendheaders=${requestHeaderString}`; const expectedResponseHeadersString = getExpectedResponseHeadersString(expectedResponseHeaders); if (requestHeaderString && expectedResponseHeadersString) { corsUrl = `${corsUrl}&expectedheaders=${expectedResponseHeadersString}`; } else if (expectedResponseHeadersString) { corsUrl = `${corsUrl}expectedheaders=${expectedResponseHeadersString}`; } if (requestHeaderString || expectedResponseHeadersString) { return `${corsUrl}&url=${url}`; } return `${corsUrl}url=${url}`; } }; /** * Gets the part of the CORS URL that represents the HTTP headers to send. * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send * @returns {string} to be used as request-headers component in CORS URL. */ const getRequestHeaderString = function (requestHeaders) { let requestHeaderString = ""; if (requestHeaders) { for (const header of requestHeaders) { if (requestHeaderString.length === 0) { requestHeaderString = `${header.name}:${encodeURIComponent(header.value)}`; } else { requestHeaderString = `${requestHeaderString},${header.name}:${encodeURIComponent(header.value)}`; } } return requestHeaderString; } return undefined; }; /** * Gets headers and values to attach to the web request. * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send * @returns {object} An object specifying name and value of the headers. */ const getHeadersToSend = (requestHeaders) => { const headersToSend = {}; if (requestHeaders) { for (const header of requestHeaders) { headersToSend[header.name] = header.value; } } return headersToSend; }; /** * Gets the part of the CORS URL that represents the expected HTTP headers to receive. * @param {Array.} expectedResponseHeaders the expected HTTP headers to receive * @returns {string} to be used as the expected HTTP-headers component in CORS URL. */ const getExpectedResponseHeadersString = function (expectedResponseHeaders) { let expectedResponseHeadersString = ""; if (expectedResponseHeaders) { for (const header of expectedResponseHeaders) { if (expectedResponseHeadersString.length === 0) { expectedResponseHeadersString = `${header}`; } else { expectedResponseHeadersString = `${expectedResponseHeadersString},${header}`; } } return expectedResponseHeaders; } return undefined; }; /** * Gets the values for the expected headers from the response. * @param {Array.} expectedResponseHeaders the expected HTTP headers to receive * @param {Response} response the HTTP response * @returns {string} to be used as the expected HTTP-headers component in CORS URL. */ const getHeadersFromResponse = (expectedResponseHeaders, response) => { const responseHeaders = []; if (expectedResponseHeaders) { for (const header of expectedResponseHeaders) { const headerValue = response.headers.get(header); responseHeaders.push({ name: header, value: headerValue }); } } return responseHeaders; }; /** * Format the time according to the config * @param {object} config The config of the module * @param {object} time time to format * @returns {string} The formatted time string */ const formatTime = (config, time) => { let date = moment(time); if (config.timezone) { date = date.tz(config.timezone); } if (config.timeFormat !== 24) { if (config.showPeriod) { if (config.showPeriodUpper) { return date.format("h:mm A"); } else { return date.format("h:mm a"); } } else { return date.format("h:mm"); } } return date.format("HH:mm"); }; if (typeof module !== "undefined") module.exports = { performWebRequest, formatTime }; ================================================ FILE: modules/default/weather/README.md ================================================ # Weather Module This module will be configurable to be used as a current weather view, or to show the forecast. This way the module can be used twice to fulfill both purposes. For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/weather.html). ================================================ FILE: modules/default/weather/current.njk ================================================ {% macro humidity() %} {% if current.humidity %} {{ current.humidity | decimalSymbol }}  {% endif %} {% endmacro %} {% if current %} {% if not config.onlyTemp %}
{{ current.windSpeed | unit("wind") | round }} {% if config.showWindDirection %} {% if config.showWindDirectionAsArrow %} {% else %} {{ current.cardinalWindDirection() | translate }} {% endif %}   {% endif %} {% if config.showHumidity === "wind" %} {{ humidity() }} {% endif %} {% if config.showSun %} {% if current.nextSunAction() === "sunset" %} {{ current.sunset | formatTime }} {% else %} {{ current.sunrise | formatTime }} {% endif %} {% endif %} {% if config.showUVIndex %}
{{ current.uv_index }} {% endif %}
{% endif %}
{% if config.showIndoorTemperature and indoor.temperature or config.showIndoorHumidity and indoor.humidity %} {% if config.showIndoorTemperature and indoor.temperature %} {{ indoor.temperature | roundValue | unit("temperature") | decimalSymbol }} {% endif %} {% if config.showIndoorHumidity and indoor.humidity %} {{ indoor.humidity | roundValue | unit("humidity") | decimalSymbol }} {% endif %} {% endif %} {% if current.weatherType %} {% endif %} {{ current.temperature | roundValue | unit("temperature") | decimalSymbol }} {% if config.showHumidity === "temp" %} {{ humidity() }} {% endif %}
{% if (config.showFeelsLike or config.showPrecipitationAmount or config.showPrecipitationProbability) and not config.onlyTemp %}
{% if config.showFeelsLike %} {% if config.showHumidity === "feelslike" %} {{ humidity() }} {% endif %} {{ "FEELS" | translate({DEGREE: current.feelsLike() | roundValue | unit("temperature") | decimalSymbol }) }}
{% endif %} {% if config.showPrecipitationAmount and current.precipitationAmount %} {{ "PRECIP_AMOUNT" | translate }} {{ current.precipitationAmount | unit("precip", current.precipitationUnits) }}
{% endif %} {% if config.showPrecipitationProbability and current.precipitationProbability %} {{ "PRECIP_POP" | translate }} {{ current.precipitationProbability }}% {% endif %}
{% endif %} {% if config.showHumidity === "below" %} {{ humidity() }} {% endif %} {% else %}
{{ "LOADING" | translate }}
{% endif %} ================================================ FILE: modules/default/weather/forecast.njk ================================================ {% if forecast %} {% set numSteps = forecast | calcNumSteps %} {% set currentStep = 0 %} {% if config.ignoreToday %} {% set forecast = forecast.splice(1) %} {% endif %} {% set forecast = forecast.slice(0, numSteps) %} {% for f in forecast %} {% if (currentStep == 0) and config.ignoreToday == false and config.absoluteDates == false %} {% elif (currentStep == 1) and config.ignoreToday == false and config.absoluteDates == false %} {% else %} {% endif %} {% if config.showPrecipitationAmount %} {% endif %} {% if config.showPrecipitationProbability %} {% endif %} {% if config.showUVIndex %} {% endif %} {% set currentStep = currentStep + 1 %} {% endfor %}
{{ "TODAY" | translate }}{{ "TOMORROW" | translate }}{{ f.date.format(config.forecastDateFormat) }} {{ f.maxTemperature | roundValue | unit("temperature") | decimalSymbol }} {{ f.minTemperature | roundValue | unit("temperature") | decimalSymbol }}{{ f.precipitationAmount | unit("precip", f.precipitationUnits) }}{{ f.precipitationProbability | unit('precip', '%') }} {{ f.uv_index }}
{% else %}
{{ "LOADING" | translate }}
{% endif %} ================================================ FILE: modules/default/weather/hourly.njk ================================================ {% if hourly %} {% set numSteps = hourly | calcNumEntries %} {% set currentStep = 0 %} {% set hours = hourly.slice(0, numSteps) %} {% for hour in hours %} {% if config.showUVIndex %} {% endif %} {% if config.showHumidity != "none" %} {% endif %} {% if config.showPrecipitationAmount %} {% if (not config.hideZeroes or hour.precipitationAmount>0) %} {% endif %} {% endif %} {% if config.showPrecipitationProbability %} {% if (not config.hideZeroes or hour.precipitationAmount>0) %} {% endif %} {% endif %} {% set currentStep = currentStep + 1 %} {% endfor %}
{{ hour.date | formatTime }} {{ hour.temperature | roundValue | unit("temperature") }} {% if hour.uv_index!=0 %} {{ hour.uv_index }} {% endif %} {{ hour.humidity }} {{ hour.precipitationAmount | unit("precip", hour.precipitationUnits) }}{{ hour.precipitationProbability | unit('precip', '%') }}
{% else %}
{{ "LOADING" | translate }}
{% endif %} ================================================ FILE: modules/default/weather/providers/README.md ================================================ # Weather Module Weather Provider Development Documentation For how to develop your own weather provider, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/development/weather-provider.html). ================================================ FILE: modules/default/weather/providers/envcanada.js ================================================ /* global WeatherProvider, WeatherObject, WeatherUtils */ /* * This class is a provider for Environment Canada MSC Datamart * Note that this is only for Canadian locations and does not require an API key (access is anonymous) * * EC Documentation at following links: * https://dd.weather.gc.ca/citypage_weather/schema/ * https://eccc-msc.github.io/open-data/msc-datamart/readme_en/ * * This module supports Canadian locations only and requires 2 additional config parameters: * * siteCode - the city/town unique identifier for which weather is to be displayed. Format is 's0000000'. * * provCode - the 2-character province code for the selected city/town. * * Example: for Toronto, Ontario, the following parameters would be used * * siteCode: 's0000458', * provCode: 'ON' * * To determine the siteCode and provCode values for a Canadian city/town, look at the Environment Canada document * at https://dd.weather.gc.ca/citypage_weather/docs/site_list_en.csv (or site_list_fr.csv). There you will find a table * with locations you can search under column B (English Names), with the corresponding siteCode under * column A (Codes) and provCode under column C (Province). * * Acknowledgement: Some logic and code for parsing Environment Canada web pages is based on material from MMM-EnvCanada * * License to use Environment Canada (EC) data is detailed here: * https://eccc-msc.github.io/open-data/licence/readme_en/ */ WeatherProvider.register("envcanada", { // Set the name of the provider for debugging and alerting purposes (eg. provide eye-catcher) providerName: "Environment Canada", // Set the default config properties that is specific to this provider defaults: { useCorsProxy: true, siteCode: "s1234567", provCode: "ON" }, /* * Set config values (equates to weather module config values). Also set values pertaining to caching of * Today's temperature forecast (for use in the Forecast functions below) */ setConfig (config) { this.config = config; this.todayTempCacheMin = 0; this.todayTempCacheMax = 0; this.todayCached = false; this.cacheCurrentTemp = 999; this.lastCityPageCurrent = " "; this.lastCityPageForecast = " "; this.lastCityPageHourly = " "; }, /* * Called when the weather provider is started */ start () { Log.info(`[weatherprovider.envcanada] ${this.providerName} started.`); this.setFetchedLocation(this.config.location); }, /* * Override the fetchCurrentWeather method to query EC and construct a Current weather object */ fetchCurrentWeather () { this.fetchCommon("Current"); }, /* * Override the fetchWeatherForecast method to query EC and construct Forecast/Daily weather objects */ fetchWeatherForecast () { this.fetchCommon("Forecast"); }, /* * Override the fetchWeatherHourly method to query EC and construct Hourly weather objects */ fetchWeatherHourly () { this.fetchCommon("Hourly"); }, /* * Because the process to fetch weather data is virtually the same for Current, Forecast/Daily, and Hourly weather, * a common module is used to access the EC weather data. The only customization (based on the caller of this routine) * is how the data will be parsed to satisfy the Weather module config in Config.js * * Accessing EC weather data is accomplished in 2 steps: * * 1. Query the MSC Datamart Index page, which returns a list of all the filenames for all the cities that have * weather data currently available. * * 2. With the city filename identified, build the appropriate URL and get the weather data (XML document) for the * city specified in the Weather module Config information */ fetchCommon (target) { const forecastURL = this.getUrl(); // Get the appropriate URL for the MSC Datamart Index page Log.debug(`[weatherprovider.envcanada] ${target} Index url: ${forecastURL}`); this.fetchData(forecastURL, "xml") // Query the Index page URL .then((indexData) => { if (!indexData) { // Did not receive usable new data. Log.info(`[weatherprovider.envcanada] ${target} - did not receive usable index data`); this.updateAvailable(); // If there were issues, update anyways to reset timer return; } /** * With the Index page read, we must locate the filename/link for the specified city (aka Sitecode). * This is done by building the city filename and searching for it on the Index page. Once found, * extract the full filename (a unique name that includes dat/time, filename, etc.) and then add it * to the Index page URL to create the proper URL pointing to the city's weather data. Finally, read the * URL to pull in the city's XML document so that weather data can be parsed and displayed. */ let forecastFile = ""; let forecastFileURL = ""; const fileSuffix = `${this.config.siteCode}_en.xml`; // Build city filename const nextFile = indexData.body.innerHTML.split(fileSuffix); // Find filename on Index page if (nextFile.length > 1) { // Parse out the full unique file city filename // Find the last occurrence forecastFile = nextFile[nextFile.length - 2].slice(-41) + fileSuffix; forecastFileURL = forecastURL + forecastFile; // Create full URL to the city's weather data } Log.debug(`[weatherprovider.envcanada] ${target} Citypage url: ${forecastFileURL}`); /* * If the Citypage filename has not changed since the last Weather refresh, the forecast has not changed and * and therefore we can skip reading the Citypage URL. */ if (target === "Current" && this.lastCityPageCurrent === forecastFileURL) { Log.debug(`[weatherprovider.envcanada] ${target} - Newest Citypage has already been seen - skipping!`); this.updateAvailable(); // Update anyways to reset refresh timer return; } if (target === "Forecast" && this.lastCityPageForecast === forecastFileURL) { Log.debug(`[weatherprovider.envcanada] ${target} - Newest Citypage has already been seen - skipping!`); this.updateAvailable(); // Update anyways to reset refresh timer return; } if (target === "Hourly" && this.lastCityPageHourly === forecastFileURL) { Log.debug(`[weatherprovider.envcanada] ${target} - Newest Citypage has already been seen - skipping!`); this.updateAvailable(); // Update anyways to reset refresh timer return; } this.fetchData(forecastFileURL, "xml") // Read city's URL to get weather data .then((cityData) => { if (!cityData) { // Did not receive usable new data. Log.info(`[weatherprovider.envcanada] ${target} - did not receive usable citypage data`); return; } /* * With the city's weather data read, parse the resulting XML document for the appropriate weather data * elements to create a weather object. Next, set Weather modules details from that object. */ Log.debug(`[weatherprovider.envcanada] ${target} - Citypage has been read and will be processed for updates`); if (target === "Current") { const currentWeather = this.generateWeatherObjectFromCurrentWeather(cityData); this.setCurrentWeather(currentWeather); this.lastCityPageCurrent = forecastFileURL; } if (target === "Forecast") { const forecastWeather = this.generateWeatherObjectsFromForecast(cityData); this.setWeatherForecast(forecastWeather); this.lastCityPageForecast = forecastFileURL; } if (target === "Hourly") { const hourlyWeather = this.generateWeatherObjectsFromHourly(cityData); this.setWeatherHourly(hourlyWeather); this.lastCityPageHourly = forecastFileURL; } }) .catch(function (cityRequest) { Log.info(`[weatherprovider.envcanada] ${target} - could not load citypage data from: ${forecastFileURL}`); }) .finally(() => this.updateAvailable()); // Update no matter what to reset weather refresh timer }) .catch(function (indexRequest) { Log.error(`[weatherprovider.envcanada] ${target} - could not load index data ... `, indexRequest); this.updateAvailable(); // If there were issues, update anyways to reset timer }); }, /* * Build the EC Index page URL based on current GMT hour. The Index page will provide a list of links for each city * that will, in turn, provide actual weather data. The URL is comprised of 3 parts: * * Fixed value + Prov code specified in Weather module Config.js + current hour as GMT */ getUrl () { let forecastURL = `https://dd.weather.gc.ca/today/citypage_weather/${this.config.provCode}`; const hour = this.getCurrentHourGMT(); forecastURL += `/${hour}/`; return forecastURL; }, /* * Get current hour-of-day in GMT context */ getCurrentHourGMT () { const now = new Date(); return now.toISOString().substring(11, 13); // "HH" in GMT }, /* * Generate a WeatherObject based on current EC weather conditions */ generateWeatherObjectFromCurrentWeather (ECdoc) { const currentWeather = new WeatherObject(); /* * There are instances where EC will update weather data and current temperature will not be * provided. While this is a defect in the EC systems, we need to accommodate to avoid a current temp * of NaN being displayed. Therefore... whenever we get a valid current temp from EC, we will cache * the value. Whenever EC data is missing current temp, we will provide the cached value * instead. This is reasonable since the cached value will typically be accurate within the previous * hour. The only time this does not work as expected is when MM is restarted and the first query to * EC finds no current temp. In this scenario, MM will end up displaying a current temp of null; */ if (ECdoc.querySelector("siteData currentConditions temperature").textContent) { currentWeather.temperature = ECdoc.querySelector("siteData currentConditions temperature").textContent; this.cacheCurrentTemp = currentWeather.temperature; } else { currentWeather.temperature = this.cacheCurrentTemp; } if (ECdoc.querySelector("siteData currentConditions wind speed").textContent === "calm") { currentWeather.windSpeed = "0"; } else { currentWeather.windSpeed = WeatherUtils.convertWindToMs(ECdoc.querySelector("siteData currentConditions wind speed").textContent); } currentWeather.windFromDirection = ECdoc.querySelector("siteData currentConditions wind bearing").textContent; currentWeather.humidity = ECdoc.querySelector("siteData currentConditions relativeHumidity").textContent; /* * Ensure showPrecipitationAmount is forced to false. EC does not really provide POP for current day * and this feature for the weather module (current only) is sort of broken in that it wants * to say POP but will display precip as an accumulated amount vs. a percentage. */ this.config.showPrecipitationAmount = false; /* * If the module config wants to showFeelsLike... default to the current temperature. * Check for EC wind chill and humidex values and overwrite the feelsLikeTemp value. * This assumes that the EC current conditions will never contain both a wind chill * and humidex temperature. */ if (this.config.showFeelsLike) { currentWeather.feelsLikeTemp = currentWeather.temperature; if (ECdoc.querySelector("siteData currentConditions windChill")) { currentWeather.feelsLikeTemp = ECdoc.querySelector("siteData currentConditions windChill").textContent; } if (ECdoc.querySelector("siteData currentConditions humidex")) { currentWeather.feelsLikeTemp = ECdoc.querySelector("siteData currentConditions humidex").textContent; } } // Need to map EC weather icon to MM weatherType values currentWeather.weatherType = this.convertWeatherType(ECdoc.querySelector("siteData currentConditions iconCode").textContent); // Capture the sunrise and sunset values from EC data const sunList = ECdoc.querySelectorAll("siteData riseSet dateTime"); currentWeather.sunrise = moment(sunList[1].querySelector("timeStamp").textContent, "YYYYMMDDhhmmss"); currentWeather.sunset = moment(sunList[3].querySelector("timeStamp").textContent, "YYYYMMDDhhmmss"); return currentWeather; }, /* * Generate an array of WeatherObjects based on EC weather forecast */ generateWeatherObjectsFromForecast (ECdoc) { // Declare an array to hold each day's forecast object const days = []; const weather = new WeatherObject(); const foreBaseDates = ECdoc.querySelectorAll("siteData forecastGroup dateTime"); const baseDate = foreBaseDates[1].querySelector("timeStamp").textContent; weather.date = moment(baseDate, "YYYYMMDDhhmmss"); const foreGroup = ECdoc.querySelectorAll("siteData forecastGroup forecast"); weather.precipitationAmount = null; /* * The EC forecast is held in a 12-element array - Elements 0 to 11 - with each day encompassing * 2 elements. the first element for a day details the Today (daytime) forecast while the second * element details the Tonight (nighttime) forecast. Element 0 is always for the current day. * * However... the forecast is somewhat 'rolling'. * * If the EC forecast is queried in the morning, then Element 0 will contain Current * Today and Element 1 will contain Current Tonight. From there, the next 5 days of forecast will be * contained in Elements 2/3, 4/5, 6/7, 8/9, and 10/11. This module will create a 6-day forecast using * all of these Elements. * * But, if the EC forecast is queried in late afternoon, the Current Today forecast will be rolled * off and Element 0 will contain Current Tonight. From there, the next 5 days will be contained in * Elements 1/2, 3/4, 5/6, 7/8, and 9/10. As well, Element 11 will contain a forecast for a 6th day, * but only for the Today portion (not Tonight). This module will create a 6-day forecast using * Elements 0 to 11, and will ignore the additional Today forecast in Element 11. * * We need to determine if Element 0 is showing the forecast for Current Today or Current Tonight. * This is required to understand how Min and Max temperature will be determined, and to understand * where the next day's (aka Tomorrow's) forecast is located in the forecast array. */ let nextDay = 0; let lastDay = 0; const currentTemp = ECdoc.querySelector("siteData currentConditions temperature").textContent; // If the first Element is Current Today, look at Current Today and Current Tonight for the current day. if (foreGroup[0].querySelector("period[textForecastName='Today']")) { this.todaytempCacheMin = 0; this.todaytempCacheMax = 0; this.todayCached = true; this.setMinMaxTemps(weather, foreGroup, 0, true, currentTemp); this.setPrecipitation(weather, foreGroup, 0); /* * Set the Element number that will reflect where the next day's forecast is located. Also set * the Element number where the end of the forecast will be. This is important because of the * rolling nature of the EC forecast. In the current scenario (Today and Tonight are present * in elements 0 and 11, we know that we will have 6 full days of forecasts and we will use * them. We will set lastDay such that we iterate through all 12 elements of the forecast. */ nextDay = 2; lastDay = 12; } // If the first Element is Current Tonight, look at Tonight only for the current day. if (foreGroup[0].querySelector("period[textForecastName='Tonight']")) { this.setMinMaxTemps(weather, foreGroup, 0, false, currentTemp); this.setPrecipitation(weather, foreGroup, 0); /* * Set the Element number that will reflect where the next day's forecast is located. Also set * the Element number where the end of the forecast will be. This is important because of the * rolling nature of the EC forecast. In the current scenario (only Current Tonight is present * in Element 0, we know that we will have 6 full days of forecasts PLUS a half-day and * forecast in the final element. Because we will only use full day forecasts, we set the * lastDay number to ensure we ignore that final half-day (in forecast Element 11). */ nextDay = 1; lastDay = 11; } /* * Need to map EC weather icon to MM weatherType values. Always pick the first Element's icon to * reflect either Today or Tonight depending on what the forecast is showing in Element 0. */ weather.weatherType = this.convertWeatherType(foreGroup[0].querySelector("abbreviatedForecast iconCode").textContent); // Push the weather object into the forecast array. days.push(weather); /* * Now do the rest of the forecast starting at nextDay. We will process each day using 2 EC * forecast Elements. This will address the fact that the EC forecast always includes Today and * Tonight for each day. This is why we iterate through the forecast by a a count of 2, with each * iteration looking at the current Element and the next Element. */ let lastDate = moment(baseDate, "YYYYMMDDhhmmss"); for (let stepDay = nextDay; stepDay < lastDay; stepDay += 2) { let weather = new WeatherObject(); // Add 1 to the date to reflect the current forecast day we are building lastDate = lastDate.add(1, "day"); weather.date = moment(lastDate); /* * Capture the temperatures for the current Element and the next Element in order to set * the Min and Max temperatures for the forecast */ this.setMinMaxTemps(weather, foreGroup, stepDay, true, currentTemp); weather.precipitationAmount = null; this.setPrecipitation(weather, foreGroup, stepDay); // Need to map EC weather icon to MM weatherType values. Always pick the first Element icon. weather.weatherType = this.convertWeatherType(foreGroup[stepDay].querySelector("abbreviatedForecast iconCode").textContent); // Push the weather object into the forecast array. days.push(weather); } return days; }, /* * Generate an array of WeatherObjects based on EC hourly weather forecast */ generateWeatherObjectsFromHourly (ECdoc) { // Declare an array to hold each hour's forecast object const hours = []; // Get local timezone UTC offset so that each hourly time can be calculated properly const baseHours = ECdoc.querySelectorAll("siteData hourlyForecastGroup dateTime"); const hourOffset = baseHours[1].getAttribute("UTCOffset"); /* * The EC hourly forecast is held in a 24-element array - Elements 0 to 23 - with Element 0 holding * the forecast for the next 'on the hour' time slot. This means the array is a rolling 24 hours. */ const hourGroup = ECdoc.querySelectorAll("siteData hourlyForecastGroup hourlyForecast"); for (let stepHour = 0; stepHour < 24; stepHour += 1) { const weather = new WeatherObject(); // Determine local time by applying UTC offset to the forecast timestamp const foreTime = moment(hourGroup[stepHour].getAttribute("dateTimeUTC"), "YYYYMMDDhhmmss"); const currTime = foreTime.add(hourOffset, "hours"); weather.date = moment(currTime); // Capture the temperature weather.temperature = hourGroup[stepHour].querySelector("temperature").textContent; // Capture Likelihood of Precipitation (LOP) and unit-of-measure values const precipLOP = hourGroup[stepHour].querySelector("lop").textContent * 1.0; if (precipLOP > 0) { weather.precipitationProbability = precipLOP; } // Need to map EC weather icon to MM weatherType values. Always pick the first Element icon. weather.weatherType = this.convertWeatherType(hourGroup[stepHour].querySelector("iconCode").textContent); // Push the weather object into the forecast array. hours.push(weather); } return hours; }, /* * Determine Min and Max temp based on a supplied Forecast Element index and a boolean that denotes if * the next Forecast element should be considered - i.e. look at Today *and* Tonight vs.Tonight-only */ setMinMaxTemps (weather, foreGroup, today, fullDay, currentTemp) { const todayTemp = foreGroup[today].querySelector("temperatures temperature").textContent; const todayClass = foreGroup[today].querySelector("temperatures temperature").getAttribute("class"); /* * The following logic is largely aimed at accommodating the Current day's forecast whereby we * can have either Current Today+Current Tonight or only Current Tonight. * * If fullDay is false, then we only have Tonight for the current day's forecast - meaning we have * lost a min or max temp value for the day. Therefore, we will see if we were able to cache the the * Today forecast for the current day. If we have, we will use them. If we do not have the cached values, * it means that MM or the Computer has been restarted since the time EC rolled off Today from the * forecast. In this scenario, we will simply default to the Current Conditions temperature and then * check the Tonight temperature.x */ if (fullDay === false) { if (this.todayCached === true) { weather.minTemperature = this.todayTempCacheMin; weather.maxTemperature = this.todayTempCacheMax; } else { weather.minTemperature = currentTemp; weather.maxTemperature = weather.minTemperature; } } /* * We will check to see if the current Element's temperature is Low or High and set weather values * accordingly. We will also check the condition where fullDay is true *and* we are looking at forecast * element 0. This is a special case where we will cache temperature values so that we have them later * in the current day when the Current Today element rolls off and we have Current Tonight only. */ if (todayClass === "low") { weather.minTemperature = todayTemp; if (today === 0 && fullDay === true) { this.todayTempCacheMin = weather.minTemperature; } } if (todayClass === "high") { weather.maxTemperature = todayTemp; if (today === 0 && fullDay === true) { this.todayTempCacheMax = weather.maxTemperature; } } const nextTemp = foreGroup[today + 1].querySelector("temperatures temperature").textContent; const nextClass = foreGroup[today + 1].querySelector("temperatures temperature").getAttribute("class"); if (fullDay === true) { if (nextClass === "low") { weather.minTemperature = nextTemp; } if (nextClass === "high") { weather.maxTemperature = nextTemp; } } }, /* * Check for a Precipitation forecast. EC can provide a forecast in 2 ways: either an accumulation figure * or a POP percentage. If there is a POP, then that is what the module will show. If there is an accumulation, * then it will be displayed ONLY if no POP is present. * * POP Logic: By default, we want to show the POP for 'daytime' since we are presuming that is what * people are more interested in seeing. While EC provides a separate POP for daytime and nighttime portions * of each day, the weather module does not really allow for that view of a daily forecast. There we will * ignore any nighttime portion. There is an exception however! For the Current day, the EC data will only show * the nighttime forecast after a certain point in the afternoon. As such, we will be showing the nighttime POP * (if one exists) in that specific scenario. * * Accumulation Logic: Similar to POP, we want to show accumulation for 'daytime' since we presume that is what * people are interested in seeing. While EC provides a separate accumulation for daytime and nighttime portions * of each day, the weather module does not really allow for that view of a daily forecast. There we will * ignore any nighttime portion. There is an exception however! For the Current day, the EC data will only show * the nighttime forecast after a certain point in that specific scenario. */ setPrecipitation (weather, foreGroup, today) { if (foreGroup[today].querySelector("precipitation accumulation")) { weather.precipitationAmount = foreGroup[today].querySelector("precipitation accumulation amount").textContent * 1.0; weather.precipitationUnits = foreGroup[today].querySelector("precipitation accumulation amount").getAttribute("units"); } // Check Today element for POP const precipPOP = foreGroup[today].querySelector("abbreviatedForecast pop").textContent * 1.0; if (precipPOP > 0) { weather.precipitationProbability = precipPOP; } }, /* * Convert the icons to a more usable name. */ convertWeatherType (weatherType) { const weatherTypes = { "00": "day-sunny", "01": "day-sunny", "02": "day-sunny-overcast", "03": "day-cloudy", "04": "day-cloudy", "05": "day-cloudy", "06": "day-sprinkle", "07": "day-showers", "08": "day-snow", "09": "day-thunderstorm", 10: "cloud", 11: "showers", 12: "rain", 13: "rain", 14: "sleet", 15: "sleet", 16: "snow", 17: "snow", 18: "snow", 19: "thunderstorm", 20: "cloudy", 21: "cloudy", 22: "day-cloudy", 23: "day-haze", 24: "fog", 25: "snow-wind", 26: "sleet", 27: "sleet", 28: "rain", 29: "na", 30: "night-clear", 31: "night-clear", 32: "night-partly-cloudy", 33: "night-alt-cloudy", 34: "night-alt-cloudy", 35: "night-partly-cloudy", 36: "night-alt-showers", 37: "night-rain-mix", 38: "night-alt-snow", 39: "night-thunderstorm", 40: "snow-wind", 41: "tornado", 42: "tornado", 43: "windy", 44: "smoke", 45: "sandstorm", 46: "thunderstorm", 47: "thunderstorm", 48: "tornado" }; return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; } }); ================================================ FILE: modules/default/weather/providers/openmeteo.js ================================================ /* global WeatherProvider, WeatherObject */ /* * This class is a provider for Open-Meteo, * see https://open-meteo.com/ */ // https://www.bigdatacloud.com/docs/api/free-reverse-geocode-to-city-api const GEOCODE_BASE = "https://api.bigdatacloud.net/data/reverse-geocode-client"; const OPEN_METEO_BASE = "https://api.open-meteo.com/v1"; WeatherProvider.register("openmeteo", { /* * Set the name of the provider. * Not strictly required but helps for debugging. */ providerName: "Open-Meteo", // Set the default config properties that is specific to this provider defaults: { apiBase: OPEN_METEO_BASE, lat: 0, lon: 0, pastDays: 0, type: "current" }, // https://open-meteo.com/en/docs hourlyParams: [ // Air temperature at 2 meters above ground "temperature_2m", // Relative humidity at 2 meters above ground "relativehumidity_2m", // Dew point temperature at 2 meters above ground "dewpoint_2m", // Apparent temperature is the perceived feels-like temperature combining wind chill factor, relative humidity and solar radiation "apparent_temperature", // Atmospheric air pressure reduced to mean sea level (msl) or pressure at surface. Typically pressure on mean sea level is used in meteorology. Surface pressure gets lower with increasing elevation. "pressure_msl", "surface_pressure", // Total cloud cover as an area fraction "cloudcover", // Low level clouds and fog up to 3 km altitude "cloudcover_low", // Mid level clouds from 3 to 8 km altitude "cloudcover_mid", // High level clouds from 8 km altitude "cloudcover_high", // Wind speed at 10, 80, 120 or 180 meters above ground. Wind speed on 10 meters is the standard level. "windspeed_10m", "windspeed_80m", "windspeed_120m", "windspeed_180m", // Wind direction at 10, 80, 120 or 180 meters above ground "winddirection_10m", "winddirection_80m", "winddirection_120m", "winddirection_180m", // Gusts at 10 meters above ground as a maximum of the preceding hour "windgusts_10m", // Shortwave solar radiation as average of the preceding hour. This is equal to the total global horizontal irradiation "shortwave_radiation", // Direct solar radiation as average of the preceding hour on the horizontal plane and the normal plane (perpendicular to the sun) "direct_radiation", "direct_normal_irradiance", // Diffuse solar radiation as average of the preceding hour "diffuse_radiation", // Vapor Pressure Deificit (VPD) in kilopascal (kPa). For high VPD (>1.6), water transpiration of plants increases. For low VPD (<0.4), transpiration decreases "vapor_pressure_deficit", // Evapotranspration from land surface and plants that weather models assumes for this location. Available soil water is considered. 1 mm evapotranspiration per hour equals 1 liter of water per spare meter. "evapotranspiration", // ET₀ Reference Evapotranspiration of a well watered grass field. Based on FAO-56 Penman-Monteith equations ET₀ is calculated from temperature, wind speed, humidity and solar radiation. Unlimited soil water is assumed. ET₀ is commonly used to estimate the required irrigation for plants. "et0_fao_evapotranspiration", // Total precipitation (rain, showers, snow) sum of the preceding hour "precipitation", // Precipitation Probability "precipitation_probability", // UV index "uv_index", // Snowfall amount of the preceding hour in centimeters. For the water equivalent in millimeter, divide by 7. E.g. 7 cm snow = 10 mm precipitation water equivalent "snowfall", // Rain from large scale weather systems of the preceding hour in millimeter "rain", // Showers from convective precipitation in millimeters from the preceding hour "showers", // Weather condition as a numeric code. Follow WMO weather interpretation codes. "weathercode", // Snow depth on the ground "snow_depth", // Altitude above sea level of the 0°C level "freezinglevel_height", // Temperature in the soil at 0, 6, 18 and 54 cm depths. 0 cm is the surface temperature on land or water surface temperature on water. "soil_temperature_0cm", "soil_temperature_6cm", "soil_temperature_18cm", "soil_temperature_54cm", // Average soil water content as volumetric mixing ratio at 0-1, 1-3, 3-9, 9-27 and 27-81 cm depths. "soil_moisture_0_1cm", "soil_moisture_1_3cm", "soil_moisture_3_9cm", "soil_moisture_9_27cm", "soil_moisture_27_81cm" ], dailyParams: [ // Maximum and minimum daily air temperature at 2 meters above ground "temperature_2m_max", "temperature_2m_min", // Maximum and minimum daily apparent temperature "apparent_temperature_min", "apparent_temperature_max", // Sum of daily precipitation (including rain, showers and snowfall) "precipitation_sum", // Sum of daily rain "rain_sum", // Sum of daily showers "showers_sum", // Sum of daily snowfall "snowfall_sum", // The number of hours with rain "precipitation_hours", // The most severe weather condition on a given day "weathercode", // Sun rise and set times "sunrise", "sunset", // Maximum wind speed and gusts on a day "windspeed_10m_max", "windgusts_10m_max", // Dominant wind direction "winddirection_10m_dominant", // The sum of solar radiation on a given day in Megajoules "shortwave_radiation_sum", //UV Index "uv_index_max", // Daily sum of ET₀ Reference Evapotranspiration of a well watered grass field "et0_fao_evapotranspiration" ], fetchedLocation () { return this.fetchedLocationName || ""; }, fetchCurrentWeather () { this.fetchData(this.getUrl()) .then((data) => this.parseWeatherApiResponse(data)) .then((parsedData) => { if (!parsedData) { // No usable data? return; } const currentWeather = this.generateWeatherDayFromCurrentWeather(parsedData); this.setCurrentWeather(currentWeather); }) .catch(function (request) { Log.error("[weatherprovider.openmeteo] Could not load data ... ", request); }) .finally(() => this.updateAvailable()); }, fetchWeatherForecast () { this.fetchData(this.getUrl()) .then((data) => this.parseWeatherApiResponse(data)) .then((parsedData) => { if (!parsedData) { // No usable data? return; } const dailyForecast = this.generateWeatherObjectsFromForecast(parsedData); this.setWeatherForecast(dailyForecast); }) .catch(function (request) { Log.error("[weatherprovider.openmeteo] Could not load data ... ", request); }) .finally(() => this.updateAvailable()); }, fetchWeatherHourly () { this.fetchData(this.getUrl()) .then((data) => this.parseWeatherApiResponse(data)) .then((parsedData) => { if (!parsedData) { // No usable data? return; } const hourlyForecast = this.generateWeatherObjectsFromHourly(parsedData); this.setWeatherHourly(hourlyForecast); }) .catch(function (request) { Log.error("[weatherprovider.openmeteo] Could not load data ... ", request); }) .finally(() => this.updateAvailable()); }, /** * Overrides method for setting config to check if endpoint is correct for hourly * @param {object} config The configuration object */ setConfig (config) { this.config = { lang: config.lang ?? "en", ...this.defaults, ...config }; // Set properly maxNumberOfDays and max Entries properties according to config and value ranges allowed in the documentation const maxEntriesLimit = ["daily", "forecast"].includes(this.config.type) ? 7 : this.config.type === "hourly" ? 48 : 0; if (this.config.hasOwnProperty("maxNumberOfDays") && !isNaN(parseFloat(this.config.maxNumberOfDays))) { const daysFactor = ["daily", "forecast"].includes(this.config.type) ? 1 : this.config.type === "hourly" ? 24 : 0; this.config.maxEntries = Math.max(1, Math.min(Math.round(parseFloat(this.config.maxNumberOfDays)) * daysFactor, maxEntriesLimit)); this.config.maxNumberOfDays = Math.ceil(this.config.maxEntries / Math.max(1, daysFactor)); } this.config.maxEntries = Math.max(1, Math.min(this.config.maxEntries, maxEntriesLimit)); if (!this.config.type) { Log.error("[weatherprovider.openmeteo] type not configured and could not resolve it"); } this.fetchLocation(); }, // Generate valid query params to perform the request getQueryParameters () { let params = { latitude: this.config.lat, longitude: this.config.lon, timeformat: "unixtime", timezone: "auto", past_days: this.config.pastDays ?? 0, daily: this.dailyParams, hourly: this.hourlyParams, // Fixed units as metric temperature_unit: "celsius", windspeed_unit: "ms", precipitation_unit: "mm" }; const startDate = moment().startOf("day"); const endDate = moment(startDate) .add(Math.max(0, Math.min(7, this.config.maxNumberOfDays)), "days") .endOf("day"); params.start_date = startDate.format("YYYY-MM-DD"); switch (this.config.type) { case "hourly": case "daily": case "forecast": params.end_date = endDate.format("YYYY-MM-DD"); break; case "current": params.current_weather = true; params.end_date = params.start_date; break; default: // Failsafe return ""; } return Object.keys(params) .filter((key) => (!!params[key])) .map((key) => { switch (key) { case "hourly": case "daily": return `${encodeURIComponent(key)}=${params[key].join(",")}`; default: return `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`; } }) .join("&"); }, // Create a URL from the config and base URL. getUrl () { return `${this.config.apiBase}/forecast?${this.getQueryParameters()}`; }, // fix daylight-saving-time differences checkDST (dt) { const uxdt = moment.unix(dt); const nowDST = moment().isDST(); if (nowDST === moment(uxdt).isDST()) { return uxdt; } else { return uxdt.add(nowDST ? +1 : -1, "hour"); } }, // Transpose hourly and daily data matrices transposeDataMatrix (data) { return data.time.map((_, index) => Object.keys(data).reduce((row, key) => { return { ...row, // Parse time values as moment.js instances [key]: ["time", "sunrise", "sunset"].includes(key) ? this.checkDST(data[key][index]) : data[key][index] }; }, {})); }, // Sanitize and validate API response parseWeatherApiResponse (data) { const validByType = { current: data.current_weather && data.current_weather.time, hourly: data.hourly && data.hourly.time && Array.isArray(data.hourly.time) && data.hourly.time.length > 0, daily: data.daily && data.daily.time && Array.isArray(data.daily.time) && data.daily.time.length > 0 }; // backwards compatibility const type = ["daily", "forecast"].includes(this.config.type) ? "daily" : this.config.type; if (!validByType[type]) return; switch (type) { case "current": if (!validByType.daily && !validByType.hourly) { return; } break; case "hourly": case "daily": break; default: return; } for (const key of ["hourly", "daily"]) { if (typeof data[key] === "object") { data[key] = this.transposeDataMatrix(data[key]); } } if (data.current_weather) { data.current_weather.time = moment.unix(data.current_weather.time); } return data; }, // Reverse geocoding from latitude and longitude provided fetchLocation () { this.fetchData(`${GEOCODE_BASE}?latitude=${this.config.lat}&longitude=${this.config.lon}&localityLanguage=${this.config.lang}`) .then((data) => { if (!data || !data.city) { // No usable data? return; } this.fetchedLocationName = `${data.city}, ${data.principalSubdivisionCode}`; }) .catch((request) => { Log.error("[weatherprovider.openmeteo] Could not load data ... ", request); }); }, // Implement WeatherDay generator. generateWeatherDayFromCurrentWeather (weather) { /** * Since some units come from API response "splitted" into daily, hourly and current_weather * every time you request it, you have to ensure to get the data from the right place every time. * For the current weather case, the response have the following structure (after transposing): * ``` * { * current_weather: { ... }, * hourly: [ * 0: {... }, * 1: {... }, * ... * ], * daily: [ * {... }, * ] * } * ``` * Some data should be returned from `hourly` array data when the index matches the current hour, * some data from the first and only one object received in `daily` array and some from the * `current_weather` object. */ const h = moment().hour(); const currentWeather = new WeatherObject(); currentWeather.date = weather.current_weather.time; currentWeather.windSpeed = weather.current_weather.windspeed; currentWeather.windFromDirection = weather.current_weather.winddirection; currentWeather.sunrise = weather.daily[0].sunrise; currentWeather.sunset = weather.daily[0].sunset; currentWeather.temperature = parseFloat(weather.current_weather.temperature); currentWeather.minTemperature = parseFloat(weather.daily[0].temperature_2m_min); currentWeather.maxTemperature = parseFloat(weather.daily[0].temperature_2m_max); currentWeather.weatherType = this.convertWeatherType(weather.current_weather.weathercode, currentWeather.isDayTime()); currentWeather.humidity = parseFloat(weather.hourly[h].relativehumidity_2m); currentWeather.feelsLikeTemp = parseFloat(weather.hourly[h].apparent_temperature); currentWeather.rain = parseFloat(weather.hourly[h].rain); currentWeather.snow = parseFloat(weather.hourly[h].snowfall * 10); currentWeather.precipitationAmount = parseFloat(weather.hourly[h].precipitation); currentWeather.precipitationProbability = parseFloat(weather.hourly[h].precipitation_probability); currentWeather.uv_index = parseFloat(weather.hourly[h].uv_index); return currentWeather; }, // Implement WeatherForecast generator. generateWeatherObjectsFromForecast (weathers) { const days = []; weathers.daily.forEach((weather) => { const currentWeather = new WeatherObject(); currentWeather.date = weather.time; currentWeather.windSpeed = weather.windspeed_10m_max; currentWeather.windFromDirection = weather.winddirection_10m_dominant; currentWeather.sunrise = weather.sunrise; currentWeather.sunset = weather.sunset; currentWeather.temperature = parseFloat((weather.temperature_2m_max + weather.temperature_2m_min) / 2); currentWeather.minTemperature = parseFloat(weather.temperature_2m_min); currentWeather.maxTemperature = parseFloat(weather.temperature_2m_max); currentWeather.weatherType = this.convertWeatherType(weather.weathercode, true); currentWeather.rain = parseFloat(weather.rain_sum); currentWeather.snow = parseFloat(weather.snowfall_sum * 10); currentWeather.precipitationAmount = parseFloat(weather.precipitation_sum); currentWeather.precipitationProbability = parseFloat(weather.precipitation_hours * 100 / 24); currentWeather.uv_index = parseFloat(weather.uv_index_max); days.push(currentWeather); }); return days; }, // Implement WeatherHourly generator. generateWeatherObjectsFromHourly (weathers) { const hours = []; const now = moment(); weathers.hourly.forEach((weather, i) => { if ((hours.length === 0 && weather.time <= now) || hours.length >= this.config.maxEntries) { return; } const currentWeather = new WeatherObject(); const h = Math.ceil((i + 1) / 24) - 1; currentWeather.date = weather.time; currentWeather.windSpeed = weather.windspeed_10m; currentWeather.windFromDirection = weather.winddirection_10m; currentWeather.sunrise = weathers.daily[h].sunrise; currentWeather.sunset = weathers.daily[h].sunset; currentWeather.temperature = parseFloat(weather.temperature_2m); currentWeather.minTemperature = parseFloat(weathers.daily[h].temperature_2m_min); currentWeather.maxTemperature = parseFloat(weathers.daily[h].temperature_2m_max); currentWeather.weatherType = this.convertWeatherType(weather.weathercode, currentWeather.isDayTime()); currentWeather.humidity = parseFloat(weather.relativehumidity_2m); currentWeather.rain = parseFloat(weather.rain); currentWeather.snow = parseFloat(weather.snowfall * 10); currentWeather.precipitationAmount = parseFloat(weather.precipitation); currentWeather.precipitationProbability = parseFloat(weather.precipitation_probability); currentWeather.uv_index = parseFloat(weather.uv_index); hours.push(currentWeather); }); return hours; }, // Map icons from Dark Sky to our icons. convertWeatherType (weathercode, isDayTime) { const weatherConditions = { 0: "clear", 1: "mainly-clear", 2: "partly-cloudy", 3: "overcast", 45: "fog", 48: "depositing-rime-fog", 51: "drizzle-light-intensity", 53: "drizzle-moderate-intensity", 55: "drizzle-dense-intensity", 56: "freezing-drizzle-light-intensity", 57: "freezing-drizzle-dense-intensity", 61: "rain-slight-intensity", 63: "rain-moderate-intensity", 65: "rain-heavy-intensity", 66: "freezing-rain-light-intensity", 67: "freezing-rain-heavy-intensity", 71: "snow-fall-slight-intensity", 73: "snow-fall-moderate-intensity", 75: "snow-fall-heavy-intensity", 77: "snow-grains", 80: "rain-showers-slight", 81: "rain-showers-moderate", 82: "rain-showers-violent", 85: "snow-showers-slight", 86: "snow-showers-heavy", 95: "thunderstorm", 96: "thunderstorm-slight-hail", 99: "thunderstorm-heavy-hail" }; if (!Object.keys(weatherConditions).includes(`${weathercode}`)) return null; switch (weatherConditions[`${weathercode}`]) { case "clear": return isDayTime ? "day-sunny" : "night-clear"; case "mainly-clear": case "partly-cloudy": return isDayTime ? "day-cloudy" : "night-alt-cloudy"; case "overcast": return isDayTime ? "day-sunny-overcast" : "night-alt-partly-cloudy"; case "fog": case "depositing-rime-fog": return isDayTime ? "day-fog" : "night-fog"; case "drizzle-light-intensity": case "rain-slight-intensity": case "rain-showers-slight": return isDayTime ? "day-sprinkle" : "night-sprinkle"; case "drizzle-moderate-intensity": case "rain-moderate-intensity": case "rain-showers-moderate": return isDayTime ? "day-showers" : "night-showers"; case "drizzle-dense-intensity": case "rain-heavy-intensity": case "rain-showers-violent": return isDayTime ? "day-thunderstorm" : "night-thunderstorm"; case "freezing-rain-light-intensity": return isDayTime ? "day-rain-mix" : "night-rain-mix"; case "freezing-drizzle-light-intensity": case "freezing-drizzle-dense-intensity": return "snowflake-cold"; case "snow-grains": return isDayTime ? "day-sleet" : "night-sleet"; case "snow-fall-slight-intensity": case "snow-fall-moderate-intensity": return isDayTime ? "day-snow-wind" : "night-snow-wind"; case "snow-fall-heavy-intensity": case "freezing-rain-heavy-intensity": return isDayTime ? "day-snow-thunderstorm" : "night-snow-thunderstorm"; case "snow-showers-slight": case "snow-showers-heavy": return isDayTime ? "day-rain-mix" : "night-rain-mix"; case "thunderstorm": return isDayTime ? "day-thunderstorm" : "night-thunderstorm"; case "thunderstorm-slight-hail": return isDayTime ? "day-sleet" : "night-sleet"; case "thunderstorm-heavy-hail": return isDayTime ? "day-sleet-storm" : "night-sleet-storm"; default: return "na"; } }, // Define required scripts. getScripts () { return ["moment.js"]; } }); ================================================ FILE: modules/default/weather/providers/openweathermap.js ================================================ /* global WeatherProvider, WeatherObject */ /* * This class is a provider for Openweathermap, * see https://openweathermap.org/ */ WeatherProvider.register("openweathermap", { /* * Set the name of the provider. * This isn't strictly necessary, since it will fallback to the provider identifier * But for debugging (and future alerts) it would be nice to have the real name. */ providerName: "OpenWeatherMap", // Set the default config properties that is specific to this provider defaults: { apiVersion: "3.0", apiBase: "https://api.openweathermap.org/data/", // weatherEndpoint is "/onecall" since API 3.0 // "/onecall", "/forecast" or "/weather" only for pro customers weatherEndpoint: "/onecall", locationID: false, location: false, // the /onecall endpoint needs lat / lon values, it doesn't support the locationId lat: 0, lon: 0, apiKey: "" }, // Overwrite the fetchCurrentWeather method. fetchCurrentWeather () { this.fetchData(this.getUrl()) .then((data) => { let currentWeather; if (this.config.weatherEndpoint === "/onecall") { currentWeather = this.generateWeatherObjectsFromOnecall(data).current; this.setFetchedLocation(`${data.timezone}`); } else { currentWeather = this.generateWeatherObjectFromCurrentWeather(data); } this.setCurrentWeather(currentWeather); }) .catch(function (request) { Log.error("[weatherprovider.openweathermap] Could not load data ... ", request); }) .finally(() => this.updateAvailable()); }, // Overwrite the fetchWeatherForecast method. fetchWeatherForecast () { this.fetchData(this.getUrl()) .then((data) => { let forecast; let location; if (this.config.weatherEndpoint === "/onecall") { forecast = this.generateWeatherObjectsFromOnecall(data).days; location = `${data.timezone}`; } else { forecast = this.generateWeatherObjectsFromForecast(data.list); location = `${data.city.name}, ${data.city.country}`; } this.setWeatherForecast(forecast); this.setFetchedLocation(location); }) .catch(function (request) { Log.error("[weatherprovider.openweathermap] Could not load data ... ", request); }) .finally(() => this.updateAvailable()); }, // Overwrite the fetchWeatherHourly method. fetchWeatherHourly () { this.fetchData(this.getUrl()) .then((data) => { if (!data) { /* * Did not receive usable new data. * Maybe this needs a better check? */ return; } this.setFetchedLocation(`(${data.lat},${data.lon})`); const weatherData = this.generateWeatherObjectsFromOnecall(data); this.setWeatherHourly(weatherData.hours); }) .catch(function (request) { Log.error("[weatherprovider.openweathermap] Could not load data ... ", request); }) .finally(() => this.updateAvailable()); }, /** OpenWeatherMap Specific Methods - These are not part of the default provider methods */ /* * Gets the complete url for the request */ getUrl () { return this.config.apiBase + this.config.apiVersion + this.config.weatherEndpoint + this.getParams(); }, /* * Generate a WeatherObject based on currentWeatherInformation */ generateWeatherObjectFromCurrentWeather (currentWeatherData) { const currentWeather = new WeatherObject(); currentWeather.date = moment.unix(currentWeatherData.dt); currentWeather.humidity = currentWeatherData.main.humidity; currentWeather.temperature = currentWeatherData.main.temp; currentWeather.feelsLikeTemp = currentWeatherData.main.feels_like; currentWeather.windSpeed = currentWeatherData.wind.speed; currentWeather.windFromDirection = currentWeatherData.wind.deg; currentWeather.weatherType = this.convertWeatherType(currentWeatherData.weather[0].icon); currentWeather.sunrise = moment.unix(currentWeatherData.sys.sunrise); currentWeather.sunset = moment.unix(currentWeatherData.sys.sunset); return currentWeather; }, /* * Generate WeatherObjects based on forecast information */ generateWeatherObjectsFromForecast (forecasts) { if (this.config.weatherEndpoint === "/forecast") { return this.generateForecastHourly(forecasts); } else if (this.config.weatherEndpoint === "/forecast/daily") { return this.generateForecastDaily(forecasts); } // if weatherEndpoint does not match forecast or forecast/daily, what should be returned? return [new WeatherObject()]; }, /* * Generate WeatherObjects based on One Call forecast information */ generateWeatherObjectsFromOnecall (data) { if (this.config.weatherEndpoint === "/onecall") { return this.fetchOnecall(data); } // if weatherEndpoint does not match onecall, what should be returned? return { current: new WeatherObject(), hours: [], days: [] }; }, /* * Generate forecast information for 3-hourly forecast (available for free * subscription). */ generateForecastHourly (forecasts) { // initial variable declaration const days = []; // variables for temperature range and rain let minTemp = []; let maxTemp = []; let rain = 0; let snow = 0; // variable for date let date = ""; let weather = new WeatherObject(); for (const forecast of forecasts) { if (date !== moment.unix(forecast.dt).format("YYYY-MM-DD")) { // calculate minimum/maximum temperature, specify rain amount weather.minTemperature = Math.min.apply(null, minTemp); weather.maxTemperature = Math.max.apply(null, maxTemp); weather.rain = rain; weather.snow = snow; weather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0); // push weather information to days array days.push(weather); // create new weather-object weather = new WeatherObject(); minTemp = []; maxTemp = []; rain = 0; snow = 0; // set new date date = moment.unix(forecast.dt).format("YYYY-MM-DD"); // specify date weather.date = moment.unix(forecast.dt); // If the first value of today is later than 17:00, we have an icon at least! weather.weatherType = this.convertWeatherType(forecast.weather[0].icon); } if (moment.unix(forecast.dt).format("H") >= 8 && moment.unix(forecast.dt).format("H") <= 17) { weather.weatherType = this.convertWeatherType(forecast.weather[0].icon); } /* * the same day as before * add values from forecast to corresponding variables */ minTemp.push(forecast.main.temp_min); maxTemp.push(forecast.main.temp_max); if (forecast.hasOwnProperty("rain") && !isNaN(forecast.rain["3h"])) { rain += forecast.rain["3h"]; } if (forecast.hasOwnProperty("snow") && !isNaN(forecast.snow["3h"])) { snow += forecast.snow["3h"]; } } /* * last day * calculate minimum/maximum temperature, specify rain amount */ weather.minTemperature = Math.min.apply(null, minTemp); weather.maxTemperature = Math.max.apply(null, maxTemp); weather.rain = rain; weather.snow = snow; weather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0); // push weather information to days array days.push(weather); return days.slice(1); }, /* * Generate forecast information for daily forecast (available for paid * subscription or old apiKey). */ generateForecastDaily (forecasts) { // initial variable declaration const days = []; for (const forecast of forecasts) { const weather = new WeatherObject(); weather.date = moment.unix(forecast.dt); weather.minTemperature = forecast.temp.min; weather.maxTemperature = forecast.temp.max; weather.weatherType = this.convertWeatherType(forecast.weather[0].icon); weather.rain = 0; weather.snow = 0; /* * forecast.rain not available if amount is zero * The API always returns in millimeters */ if (forecast.hasOwnProperty("rain") && !isNaN(forecast.rain)) { weather.rain = forecast.rain; } /* * forecast.snow not available if amount is zero * The API always returns in millimeters */ if (forecast.hasOwnProperty("snow") && !isNaN(forecast.snow)) { weather.snow = forecast.snow; } weather.precipitationAmount = weather.rain + weather.snow; weather.precipitationProbability = forecast.pop ? forecast.pop * 100 : undefined; days.push(weather); } return days; }, /* * Fetch One Call forecast information (available for free subscription). * Factors in timezone offsets. * Minutely forecasts are excluded for the moment, see getParams(). */ fetchOnecall (data) { let precip = false; // get current weather, if requested const current = new WeatherObject(); if (data.hasOwnProperty("current")) { current.date = moment.unix(data.current.dt).utcOffset(data.timezone_offset / 60); current.windSpeed = data.current.wind_speed; current.windFromDirection = data.current.wind_deg; current.sunrise = moment.unix(data.current.sunrise).utcOffset(data.timezone_offset / 60); current.sunset = moment.unix(data.current.sunset).utcOffset(data.timezone_offset / 60); current.temperature = data.current.temp; current.weatherType = this.convertWeatherType(data.current.weather[0].icon); current.humidity = data.current.humidity; current.uv_index = data.current.uvi; if (data.current.hasOwnProperty("rain") && !isNaN(data.current.rain["1h"])) { current.rain = data.current.rain["1h"]; precip = true; } if (data.current.hasOwnProperty("snow") && !isNaN(data.current.snow["1h"])) { current.snow = data.current.snow["1h"]; precip = true; } if (precip) { current.precipitationAmount = (current.rain ?? 0) + (current.snow ?? 0); } current.feelsLikeTemp = data.current.feels_like; } let weather = new WeatherObject(); // get hourly weather, if requested const hours = []; if (data.hasOwnProperty("hourly")) { for (const hour of data.hourly) { weather.date = moment.unix(hour.dt).utcOffset(data.timezone_offset / 60); weather.temperature = hour.temp; weather.feelsLikeTemp = hour.feels_like; weather.humidity = hour.humidity; weather.windSpeed = hour.wind_speed; weather.windFromDirection = hour.wind_deg; weather.weatherType = this.convertWeatherType(hour.weather[0].icon); weather.precipitationProbability = hour.pop ? hour.pop * 100 : undefined; weather.uv_index = hour.uvi; precip = false; if (hour.hasOwnProperty("rain") && !isNaN(hour.rain["1h"])) { weather.rain = hour.rain["1h"]; precip = true; } if (hour.hasOwnProperty("snow") && !isNaN(hour.snow["1h"])) { weather.snow = hour.snow["1h"]; precip = true; } if (precip) { weather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0); } hours.push(weather); weather = new WeatherObject(); } } // get daily weather, if requested const days = []; if (data.hasOwnProperty("daily")) { for (const day of data.daily) { weather.date = moment.unix(day.dt).utcOffset(data.timezone_offset / 60); weather.sunrise = moment.unix(day.sunrise).utcOffset(data.timezone_offset / 60); weather.sunset = moment.unix(day.sunset).utcOffset(data.timezone_offset / 60); weather.minTemperature = day.temp.min; weather.maxTemperature = day.temp.max; weather.humidity = day.humidity; weather.windSpeed = day.wind_speed; weather.windFromDirection = day.wind_deg; weather.weatherType = this.convertWeatherType(day.weather[0].icon); weather.precipitationProbability = day.pop ? day.pop * 100 : undefined; weather.uv_index = day.uvi; precip = false; if (!isNaN(day.rain)) { weather.rain = day.rain; precip = true; } if (!isNaN(day.snow)) { weather.snow = day.snow; precip = true; } if (precip) { weather.precipitationAmount = (weather.rain ?? 0) + (weather.snow ?? 0); } days.push(weather); weather = new WeatherObject(); } } return { current: current, hours: hours, days: days }; }, /* * Convert the OpenWeatherMap icons to a more usable name. */ convertWeatherType (weatherType) { const weatherTypes = { "01d": "day-sunny", "02d": "day-cloudy", "03d": "cloudy", "04d": "cloudy-windy", "09d": "showers", "10d": "rain", "11d": "thunderstorm", "13d": "snow", "50d": "fog", "01n": "night-clear", "02n": "night-cloudy", "03n": "night-cloudy", "04n": "night-cloudy", "09n": "night-showers", "10n": "night-rain", "11n": "night-thunderstorm", "13n": "night-snow", "50n": "night-alt-cloudy-windy" }; return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; }, /* * getParams(compliments) * Generates an url with api parameters based on the config. * * return String - URL params. */ getParams () { let params = "?"; if (this.config.weatherEndpoint === "/onecall") { params += `lat=${this.config.lat}`; params += `&lon=${this.config.lon}`; if (this.config.type === "current") { params += "&exclude=minutely,hourly,daily"; } else if (this.config.type === "hourly") { params += "&exclude=current,minutely,daily"; } else if (this.config.type === "daily" || this.config.type === "forecast") { params += "&exclude=current,minutely,hourly"; } else { params += "&exclude=minutely"; } } else if (this.config.lat && this.config.lon) { params += `lat=${this.config.lat}&lon=${this.config.lon}`; } else if (this.config.locationID) { params += `id=${this.config.locationID}`; } else if (this.config.location) { params += `q=${this.config.location}`; } else if (this.firstEvent && this.firstEvent.geo) { params += `lat=${this.firstEvent.geo.lat}&lon=${this.firstEvent.geo.lon}`; } else if (this.firstEvent && this.firstEvent.location) { params += `q=${this.firstEvent.location}`; } else { // TODO hide doesn't exist! this.hide(this.config.animationSpeed, { lockString: this.identifier }); return; } params += "&units=metric"; // WeatherProviders should use metric internally and use the units only for when displaying data params += `&lang=${this.config.lang}`; params += `&APPID=${this.config.apiKey}`; return params; } }); ================================================ FILE: modules/default/weather/providers/overrideWrapper.js ================================================ /* global Class, WeatherObject */ /* * Wrapper class to enable overrides of currentOverrideWeatherObject. * * Sits between the weather.js module and the provider implementations to allow us to * combine the incoming data from the CURRENT_WEATHER_OVERRIDE notification with the * existing data received from the current api provider. If no notifications have * been received then the api provider's data is used. * * The intent is to allow partial WeatherObjects from local sensors to augment or * replace the WeatherObjects from the api providers. * * This class shares the signature of WeatherProvider, and passes any methods not * concerning the current weather directly to the api provider implementation that * is currently in use. */ const OverrideWrapper = Class.extend({ baseProvider: null, providerName: "localWrapper", notificationWeatherObject: null, currentOverrideWeatherObject: null, init (baseProvider) { this.baseProvider = baseProvider; // Binding the scope of current weather functions so any fetchData calls with // setCurrentWeather nested in them call this classes implementation instead // of the provider's default this.baseProvider.setCurrentWeather = this.setCurrentWeather.bind(this); this.baseProvider.currentWeather = this.currentWeather.bind(this); }, /* Unchanged Api Provider Methods */ setConfig (config) { this.baseProvider.setConfig(config); }, start () { this.baseProvider.start(); }, fetchCurrentWeather () { this.baseProvider.fetchCurrentWeather(); }, fetchWeatherForecast () { this.baseProvider.fetchWeatherForecast(); }, fetchWeatherHourly () { this.baseProvider.fetchWeatherHourly(); }, weatherForecast () { this.baseProvider.weatherForecast(); }, weatherHourly () { this.baseProvider.weatherHourly(); }, fetchedLocation () { this.baseProvider.fetchedLocation(); }, setWeatherForecast (weatherForecastArray) { this.baseProvider.setWeatherForecast(weatherForecastArray); }, setWeatherHourly (weatherHourlyArray) { this.baseProvider.setWeatherHourly(weatherHourlyArray); }, setFetchedLocation (name) { this.baseProvider.setFetchedLocation(name); }, updateAvailable () { this.baseProvider.updateAvailable(); }, async fetchData (url, type = "json", requestHeaders = undefined, expectedResponseHeaders = undefined) { this.baseProvider.fetchData(url, type, requestHeaders, expectedResponseHeaders); }, /* Override Methods */ /** * Override to return this scope's * @returns {WeatherObject} The current weather object. May or may not contain overridden data. */ currentWeather () { return this.currentOverrideWeatherObject; }, /** * Override to combine the overrideWeatherObject provided in the * notificationReceived method with the currentOverrideWeatherObject provided by the * api provider fetchData implementation. * @param {WeatherObject} currentWeatherObject - the api provider weather object */ setCurrentWeather (currentWeatherObject) { this.currentOverrideWeatherObject = Object.assign(currentWeatherObject, this.notificationWeatherObject); }, /** * Updates the overrideWeatherObject, calls setCurrentWeather to combine it with * the existing current weather object provided by the base provider, and signals * that an update is ready. * @param {WeatherObject} payload - the weather object received from the CURRENT_WEATHER_OVERRIDE * notification. Represents information to augment the * existing currentOverrideWeatherObject with. */ notificationReceived (payload) { this.notificationWeatherObject = payload; // setCurrentWeather combines the newly received notification weather with // the existing weather object we return for current weather this.setCurrentWeather(this.currentOverrideWeatherObject); this.updateAvailable(); } }); ================================================ FILE: modules/default/weather/providers/pirateweather.js ================================================ /* global WeatherProvider, WeatherObject */ /* * This class is a provider for Pirate Weather, it is a replacement for Dark Sky (same api), * see http://pirateweather.net/en/latest/ */ WeatherProvider.register("pirateweather", { /* * Set the name of the provider. * Not strictly required, but helps for debugging. */ providerName: "pirateweather", // Set the default config properties that is specific to this provider defaults: { useCorsProxy: true, apiBase: "https://api.pirateweather.net", weatherEndpoint: "/forecast", apiKey: "", lat: 0, lon: 0 }, async fetchCurrentWeather () { try { const data = await this.fetchData(this.getUrl()); if (!data || !data.currently || typeof data.currently.temperature === "undefined") { throw new Error("No usable data received from Pirate Weather API."); } const currentWeather = this.generateWeatherDayFromCurrentWeather(data); this.setCurrentWeather(currentWeather); } catch (error) { Log.error("Could not load data ... ", error); } finally { this.updateAvailable(); } }, async fetchWeatherForecast () { try { const data = await this.fetchData(this.getUrl()); if (!data || !data.daily || !data.daily.data.length) { throw new Error("No usable data received from Pirate Weather API."); } const forecast = this.generateWeatherObjectsFromForecast(data.daily.data); this.setWeatherForecast(forecast); } catch (error) { Log.error("Could not load data ... ", error); } finally { this.updateAvailable(); } }, // Create a URL from the config and base URL. getUrl () { return `${this.config.apiBase}${this.config.weatherEndpoint}/${this.config.apiKey}/${this.config.lat},${this.config.lon}?units=si&lang=${this.config.lang}`; }, // Implement WeatherDay generator. generateWeatherDayFromCurrentWeather (currentWeatherData) { const currentWeather = new WeatherObject(); currentWeather.date = moment(); currentWeather.humidity = parseFloat(currentWeatherData.currently.humidity); currentWeather.temperature = parseFloat(currentWeatherData.currently.temperature); currentWeather.windSpeed = parseFloat(currentWeatherData.currently.windSpeed); currentWeather.windFromDirection = currentWeatherData.currently.windBearing; currentWeather.weatherType = this.convertWeatherType(currentWeatherData.currently.icon); currentWeather.sunrise = moment.unix(currentWeatherData.daily.data[0].sunriseTime); currentWeather.sunset = moment.unix(currentWeatherData.daily.data[0].sunsetTime); return currentWeather; }, generateWeatherObjectsFromForecast (forecasts) { const days = []; for (const forecast of forecasts) { const weather = new WeatherObject(); weather.date = moment.unix(forecast.time); weather.minTemperature = forecast.temperatureMin; weather.maxTemperature = forecast.temperatureMax; weather.weatherType = this.convertWeatherType(forecast.icon); weather.snow = 0; weather.rain = 0; let precip = 0; if (forecast.hasOwnProperty("precipAccumulation")) { precip = forecast.precipAccumulation * 10; } weather.precipitationAmount = precip; if (forecast.hasOwnProperty("precipType")) { if (forecast.precipType === "snow") { weather.snow = precip; } else { weather.rain = precip; } } days.push(weather); } return days; }, // Map icons from Pirate Weather to our icons. convertWeatherType (weatherType) { const weatherTypes = { "clear-day": "day-sunny", "clear-night": "night-clear", rain: "rain", snow: "snow", sleet: "snow", wind: "windy", fog: "fog", cloudy: "cloudy", "partly-cloudy-day": "day-cloudy", "partly-cloudy-night": "night-cloudy" }; return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; } }); ================================================ FILE: modules/default/weather/providers/smhi.js ================================================ /* global WeatherProvider, WeatherObject */ /* * This class is a provider for SMHI (Sweden only). * Metric system is the only supported unit, * see https://www.smhi.se/ */ WeatherProvider.register("smhi", { providerName: "SMHI", // Set the default config properties that is specific to this provider defaults: { lat: 0, // Cant have more than 6 digits lon: 0, // Cant have more than 6 digits precipitationValue: "pmedian", location: false }, /** * Implements method in interface for fetching current weather. */ fetchCurrentWeather () { this.fetchData(this.getURL()) .then((data) => { const closest = this.getClosestToCurrentTime(data.timeSeries); const coordinates = this.resolveCoordinates(data); const weatherObject = this.convertWeatherDataToObject(closest, coordinates); this.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`); this.setCurrentWeather(weatherObject); }) .catch((error) => Log.error(`[weatherprovider.smhi] Could not load data: ${error.message}`)) .finally(() => this.updateAvailable()); }, /** * Implements method in interface for fetching a multi-day forecast. */ fetchWeatherForecast () { this.fetchData(this.getURL()) .then((data) => { const coordinates = this.resolveCoordinates(data); const weatherObjects = this.convertWeatherDataGroupedBy(data.timeSeries, coordinates); this.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`); this.setWeatherForecast(weatherObjects); }) .catch((error) => Log.error(`[weatherprovider.smhi] Could not load data: ${error.message}`)) .finally(() => this.updateAvailable()); }, /** * Implements method in interface for fetching hourly forecasts. */ fetchWeatherHourly () { this.fetchData(this.getURL()) .then((data) => { const coordinates = this.resolveCoordinates(data); const weatherObjects = this.convertWeatherDataGroupedBy(data.timeSeries, coordinates, "hour"); this.setFetchedLocation(this.config.location || `(${coordinates.lat},${coordinates.lon})`); this.setWeatherHourly(weatherObjects); }) .catch((error) => Log.error(`[weatherprovider.smhi] Could not load data: ${error.message}`)) .finally(() => this.updateAvailable()); }, /** * Overrides method for setting config with checks for the precipitationValue being unset or invalid * @param {object} config The configuration object */ setConfig (config) { this.config = config; if (!config.precipitationValue || ["pmin", "pmean", "pmedian", "pmax"].indexOf(config.precipitationValue) === -1) { Log.log(`[weatherprovider.smhi] invalid or not set: ${config.precipitationValue}`); config.precipitationValue = this.defaults.precipitationValue; } }, /** * Of all the times returned find out which one is closest to the current time, should be the first if the data isn't old. * @param {object[]} times Array of time objects * @returns {object} The weatherdata closest to the current time */ getClosestToCurrentTime (times) { let now = moment(); let minDiff = undefined; for (const time of times) { let diff = Math.abs(moment(time.validTime).diff(now)); if (!minDiff || diff < Math.abs(moment(minDiff.validTime).diff(now))) { minDiff = time; } } return minDiff; }, /** * Get the forecast url for the configured coordinates * @returns {string} the url for the specified coordinates */ getURL () { const formatter = new Intl.NumberFormat("en-US", { minimumFractionDigits: 6, maximumFractionDigits: 6 }); const lon = formatter.format(this.config.lon); const lat = formatter.format(this.config.lat); return `https://opendata-download-metfcst.smhi.se/api/category/pmp3g/version/2/geotype/point/lon/${lon}/lat/${lat}/data.json`; }, /** * Calculates the apparent temperature based on known atmospheric data. * @param {object} weatherData Weatherdata to use for the calculation * @returns {number} The apparent temperature */ calculateApparentTemperature (weatherData) { const Ta = this.paramValue(weatherData, "t"); const rh = this.paramValue(weatherData, "r"); const ws = this.paramValue(weatherData, "ws"); const p = (rh / 100) * 6.105 * Math.E * ((17.27 * Ta) / (237.7 + Ta)); return Ta + 0.33 * p - 0.7 * ws - 4; }, /** * Converts the returned data into a WeatherObject with required properties set for both current weather and forecast. * The returned units is always in metric system. * Requires coordinates to determine if its daytime or nighttime to know which icon to use and also to set sunrise and sunset. * @param {object} weatherData Weatherdata to convert * @param {object} coordinates Coordinates of the locations of the weather * @returns {WeatherObject} The converted weatherdata at the specified location */ convertWeatherDataToObject (weatherData, coordinates) { let currentWeather = new WeatherObject(); currentWeather.date = moment(weatherData.validTime); currentWeather.updateSunTime(coordinates.lat, coordinates.lon); currentWeather.humidity = this.paramValue(weatherData, "r"); currentWeather.temperature = this.paramValue(weatherData, "t"); currentWeather.windSpeed = this.paramValue(weatherData, "ws"); currentWeather.windFromDirection = this.paramValue(weatherData, "wd"); currentWeather.weatherType = this.convertWeatherType(this.paramValue(weatherData, "Wsymb2"), currentWeather.isDayTime()); currentWeather.feelsLikeTemp = this.calculateApparentTemperature(weatherData); /* * Determine the precipitation amount and category and update the * weatherObject with it, the value type to use can be configured or uses * median as default. */ let precipitationValue = this.paramValue(weatherData, this.config.precipitationValue); switch (this.paramValue(weatherData, "pcat")) { // 0 = No precipitation case 1: // Snow currentWeather.snow += precipitationValue; currentWeather.precipitationAmount += precipitationValue; break; case 2: // Snow and rain, treat it as 50/50 snow and rain currentWeather.snow += precipitationValue / 2; currentWeather.rain += precipitationValue / 2; currentWeather.precipitationAmount += precipitationValue; break; case 3: // Rain case 4: // Drizzle case 5: // Freezing rain case 6: // Freezing drizzle currentWeather.rain += precipitationValue; currentWeather.precipitationAmount += precipitationValue; break; } return currentWeather; }, /** * Takes all the data points and converts it to one WeatherObject per day. * @param {object[]} allWeatherData Array of weatherdata * @param {object} coordinates Coordinates of the locations of the weather * @param {string} groupBy The interval to use for grouping the data (day, hour) * @returns {WeatherObject[]} Array of weather objects */ convertWeatherDataGroupedBy (allWeatherData, coordinates, groupBy = "day") { let currentWeather; let result = []; let allWeatherObjects = this.fillInGaps(allWeatherData).map((weatherData) => this.convertWeatherDataToObject(weatherData, coordinates)); let dayWeatherTypes = []; for (const weatherObject of allWeatherObjects) { //If its the first object or if a day/hour change we need to reset the summary object if (!currentWeather || !currentWeather.date.isSame(weatherObject.date, groupBy)) { currentWeather = new WeatherObject(); dayWeatherTypes = []; currentWeather.temperature = weatherObject.temperature; currentWeather.date = weatherObject.date; currentWeather.minTemperature = Infinity; currentWeather.maxTemperature = -Infinity; currentWeather.snow = 0; currentWeather.rain = 0; currentWeather.precipitationAmount = 0; result.push(currentWeather); } //Keep track of what icons have been used for each hour of daytime and use the middle one for the forecast if (weatherObject.isDayTime()) { dayWeatherTypes.push(weatherObject.weatherType); } if (dayWeatherTypes.length > 0) { currentWeather.weatherType = dayWeatherTypes[Math.floor(dayWeatherTypes.length / 2)]; } else { currentWeather.weatherType = weatherObject.weatherType; } //All other properties is either a sum, min or max of each hour currentWeather.minTemperature = Math.min(currentWeather.minTemperature, weatherObject.temperature); currentWeather.maxTemperature = Math.max(currentWeather.maxTemperature, weatherObject.temperature); currentWeather.snow += weatherObject.snow; currentWeather.rain += weatherObject.rain; currentWeather.precipitationAmount += weatherObject.precipitationAmount; } return result; }, /** * Resolve coordinates from the response data (probably preferably to use * this if it's not matching the config values exactly) * @param {object} data Response data from the weather service * @returns {{lon, lat}} the lat/long coordinates of the data */ resolveCoordinates (data) { return { lat: data.geometry.coordinates[0][1], lon: data.geometry.coordinates[0][0] }; }, /** * The distance between the data points is increasing in the data the more distant the prediction is. * Find these gaps and fill them with the previous hours data to make the data returned a complete set. * @param {object[]} data Response data from the weather service * @returns {object[]} Given data with filled gaps */ fillInGaps (data) { let result = []; for (let i = 1; i < data.length; i++) { let to = moment(data[i].validTime); let from = moment(data[i - 1].validTime); let hours = moment.duration(to.diff(from)).asHours(); // For each hour add a datapoint but change the validTime for (let j = 0; j < hours; j++) { let current = Object.assign({}, data[i]); current.validTime = from.clone().add(j, "hours").toISOString(); result.push(current); } } return result; }, /** * Helper method to get a property from the returned data set. * @param {object} currentWeatherData Weatherdata to get from * @param {string} name The name of the property * @returns {string} The value of the property in the weatherdata */ paramValue (currentWeatherData, name) { return currentWeatherData.parameters.filter((p) => p.name === name).flatMap((p) => p.values)[0]; }, /** * Map the icon value from SMHI to an icon that MagicMirror² understands. * Uses different icons depending on if its daytime or nighttime. * SMHI's description of what the numeric value means is the comment after the case. * @param {number} input The SMHI icon value * @param {boolean} isDayTime True if the icon should be for daytime, false for nighttime * @returns {string} The icon name for the MagicMirror */ convertWeatherType (input, isDayTime) { switch (input) { case 1: return isDayTime ? "day-sunny" : "night-clear"; // Clear sky case 2: return isDayTime ? "day-sunny-overcast" : "night-partly-cloudy"; // Nearly clear sky case 3: return isDayTime ? "day-cloudy" : "night-cloudy"; // Variable cloudiness case 4: return isDayTime ? "day-cloudy" : "night-cloudy"; // Halfclear sky case 5: return "cloudy"; // Cloudy sky case 6: return "cloudy"; // Overcast case 7: return "fog"; // Fog case 8: return "showers"; // Light rain showers case 9: return "showers"; // Moderate rain showers case 10: return "showers"; // Heavy rain showers case 11: return "thunderstorm"; // Thunderstorm case 12: return "sleet"; // Light sleet showers case 13: return "sleet"; // Moderate sleet showers case 14: return "sleet"; // Heavy sleet showers case 15: return "snow"; // Light snow showers case 16: return "snow"; // Moderate snow showers case 17: return "snow"; // Heavy snow showers case 18: return "rain"; // Light rain case 19: return "rain"; // Moderate rain case 20: return "rain"; // Heavy rain case 21: return "thunderstorm"; // Thunder case 22: return "sleet"; // Light sleet case 23: return "sleet"; // Moderate sleet case 24: return "sleet"; // Heavy sleet case 25: return "snow"; // Light snowfall case 26: return "snow"; // Moderate snowfall case 27: return "snow"; // Heavy snowfall default: return ""; } } }); ================================================ FILE: modules/default/weather/providers/ukmetofficedatahub.js ================================================ /* global WeatherProvider, WeatherObject */ /* * This class is a provider for UK Met Office Data Hub (the replacement for their Data Point services). * For more information on Data Hub, see https://www.metoffice.gov.uk/services/data/datapoint/notifications/weather-datahub * Data available: * Hourly data for next 2 days ("hourly") - https://www.metoffice.gov.uk/binaries/content/assets/metofficegovuk/pdf/data/global-spot-data-hourly.pdf * 3-hourly data for the next 7 days ("3hourly") - https://www.metoffice.gov.uk/binaries/content/assets/metofficegovuk/pdf/data/global-spot-data-3-hourly.pdf * Daily data for the next 7 days ("daily") - https://www.metoffice.gov.uk/binaries/content/assets/metofficegovuk/pdf/data/global-spot-data-daily.pdf * * NOTES * This provider requires longitude/latitude coordinates, rather than a location ID (as with the previous Met Office provider) * Provide the following in your config.js file: * weatherProvider: "ukmetofficedatahub", * apiBase: "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/", * apiKey: "[YOUR API KEY]", * lat: [LATITUDE (DECIMAL)], * lon: [LONGITUDE (DECIMAL)] * * At time of writing, free accounts are limited to 360 requests a day per service (hourly, 3hourly, daily); take this in mind when * setting your update intervals. For reference, 360 requests per day is once every 4 minutes. * * Pay attention to the units of the supplied data from the Met Office - it is given in SI/metric units where applicable: * - Temperatures are in degrees Celsius (°C) * - Wind speeds are in metres per second (m/s) * - Wind direction given in degrees (°) * - Pressures are in Pascals (Pa) * - Distances are in metres (m) * - Probabilities and humidity are given as percentages (%) * - Precipitation is measured in millimeters (mm) with rates per hour (mm/h) * * See the PDFs linked above for more information on the data their corresponding units. */ WeatherProvider.register("ukmetofficedatahub", { // Set the name of the provider. providerName: "UK Met Office (DataHub)", // Set the default config properties that is specific to this provider defaults: { apiBase: "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/", apiKey: "", lat: 0, lon: 0 }, // Build URL with query strings according to DataHub API (https://datahub.metoffice.gov.uk/docs/f/category/site-specific/type/site-specific/api-documentation#get-/point/hourly) getUrl (forecastType) { let queryStrings = "?"; queryStrings += `latitude=${this.config.lat}`; queryStrings += `&longitude=${this.config.lon}`; queryStrings += `&includeLocationName=${true}`; // Return URL, making sure there is a trailing "/" in the base URL. return this.config.apiBase + (this.config.apiBase.endsWith("/") ? "" : "/") + forecastType + queryStrings; }, /* * Build the list of headers for the request * For DataHub requests, the API key/secret are sent in the headers rather than as query strings. * Headers defined according to Data Hub API (https://datahub.metoffice.gov.uk/docs/f/category/site-specific/type/site-specific/api-documentation#get-/point/hourly) */ getHeaders () { return { accept: "application/json", apikey: this.config.apiKey }; }, // Fetch data using supplied URL and request headers async fetchWeather (url, headers) { const response = await fetch(url, { headers: headers }); // Return JSON data return response.json(); }, // Fetch hourly forecast data (to use for current weather) fetchCurrentWeather () { this.fetchWeather(this.getUrl("hourly"), this.getHeaders()) .then((data) => { // Check data is usable if (!data || !data.features || !data.features[0].properties || !data.features[0].properties.timeSeries || data.features[0].properties.timeSeries.length === 0) { /* * Did not receive usable new data. * Maybe this needs a better check? */ Log.error("[weatherprovider.ukmetofficedatahub] Possibly bad current/hourly data?", data); return; } // Set location name this.setFetchedLocation(`${data.features[0].properties.location.name}`); // Generate current weather data const currentWeather = this.generateWeatherObjectFromCurrentWeather(data); this.setCurrentWeather(currentWeather); }) // Catch any error(s) .catch((error) => Log.error(`[weatherprovider.ukmetofficedatahub] Could not load data: ${error.message}`)) // Let the module know there is data available .finally(() => this.updateAvailable()); }, // Create a WeatherObject using current weather data (data for the current hour) generateWeatherObjectFromCurrentWeather (currentWeatherData) { const currentWeather = new WeatherObject(); // Extract the actual forecasts let forecastDataHours = currentWeatherData.features[0].properties.timeSeries; // Define now let nowUtc = moment.utc(); // Find hour that contains the current time for (let hour in forecastDataHours) { let forecastTime = moment.utc(forecastDataHours[hour].time); if (nowUtc.isSameOrAfter(forecastTime) && nowUtc.isBefore(moment(forecastTime.add(1, "h")))) { currentWeather.date = forecastTime; currentWeather.windSpeed = forecastDataHours[hour].windSpeed10m; currentWeather.windFromDirection = forecastDataHours[hour].windDirectionFrom10m; currentWeather.temperature = forecastDataHours[hour].screenTemperature; currentWeather.minTemperature = forecastDataHours[hour].minScreenAirTemp; currentWeather.maxTemperature = forecastDataHours[hour].maxScreenAirTemp; currentWeather.weatherType = this.convertWeatherType(forecastDataHours[hour].significantWeatherCode); currentWeather.humidity = forecastDataHours[hour].screenRelativeHumidity; currentWeather.rain = forecastDataHours[hour].totalPrecipAmount; currentWeather.snow = forecastDataHours[hour].totalSnowAmount; currentWeather.precipitationProbability = forecastDataHours[hour].probOfPrecipitation; currentWeather.feelsLikeTemp = forecastDataHours[hour].feelsLikeTemperature; /* * Pass on full details, so they can be used in custom templates * Note the units of the supplied data when using this (see top of file) */ currentWeather.rawData = forecastDataHours[hour]; } } /* * Determine the sunrise/sunset times - (still) not supplied in UK Met Office data * Passes {longitude, latitude} to SunCalc, could pass height to, but * SunCalc.getTimes doesn't take that into account */ currentWeather.updateSunTime(this.config.lat, this.config.lon); return currentWeather; }, // Fetch daily forecast data fetchWeatherForecast () { this.fetchWeather(this.getUrl("daily"), this.getHeaders()) .then((data) => { // Check data is usable if (!data || !data.features || !data.features[0].properties || !data.features[0].properties.timeSeries || data.features[0].properties.timeSeries.length === 0) { /* * Did not receive usable new data. * Maybe this needs a better check? */ Log.error("[weatherprovider.ukmetofficedatahub] Possibly bad forecast data?", data); return; } // Set location name this.setFetchedLocation(`${data.features[0].properties.location.name}`); // Generate the forecast data const forecast = this.generateWeatherObjectsFromForecast(data); this.setWeatherForecast(forecast); }) // Catch any error(s) .catch((error) => Log.error(`[weatherprovider.ukmetofficedatahub] Could not load data: ${error.message}`)) // Let the module know there is new data available .finally(() => this.updateAvailable()); }, // Create a WeatherObject for each day using daily forecast data generateWeatherObjectsFromForecast (forecasts) { const dailyForecasts = []; // Extract the actual forecasts let forecastDataDays = forecasts.features[0].properties.timeSeries; // Define today let today = moment.utc().startOf("date"); // Go through each day in the forecasts for (let day in forecastDataDays) { const forecastWeather = new WeatherObject(); // Get date of forecast let forecastDate = moment.utc(forecastDataDays[day].time); // Check if forecast is for today or in the future (i.e., ignore yesterday's forecast) if (forecastDate.isSameOrAfter(today)) { forecastWeather.date = forecastDate; forecastWeather.minTemperature = forecastDataDays[day].nightMinScreenTemperature; forecastWeather.maxTemperature = forecastDataDays[day].dayMaxScreenTemperature; // Using daytime forecast values forecastWeather.windSpeed = forecastDataDays[day].midday10MWindSpeed; forecastWeather.windFromDirection = forecastDataDays[day].midday10MWindDirection; forecastWeather.weatherType = this.convertWeatherType(forecastDataDays[day].daySignificantWeatherCode); forecastWeather.precipitationProbability = forecastDataDays[day].dayProbabilityOfPrecipitation; forecastWeather.temperature = forecastDataDays[day].dayMaxScreenTemperature; forecastWeather.humidity = forecastDataDays[day].middayRelativeHumidity; forecastWeather.rain = forecastDataDays[day].dayProbabilityOfRain; forecastWeather.snow = forecastDataDays[day].dayProbabilityOfSnow; forecastWeather.feelsLikeTemp = forecastDataDays[day].dayMaxFeelsLikeTemp; /* * Pass on full details, so they can be used in custom templates * Note the units of the supplied data when using this (see top of file) */ forecastWeather.rawData = forecastDataDays[day]; dailyForecasts.push(forecastWeather); } } return dailyForecasts; }, // Set the fetched location name. setFetchedLocation (name) { this.fetchedLocationName = name; }, /* * Match the Met Office "significant weather code" to a weathericons.css icon * Use: https://metoffice.apiconnect.ibmcloud.com/metoffice/production/node/264 * and: https://erikflowers.github.io/weather-icons/ */ convertWeatherType (weatherType) { const weatherTypes = { 0: "night-clear", 1: "day-sunny", 2: "night-alt-cloudy", 3: "day-cloudy", 5: "fog", 6: "fog", 7: "cloudy", 8: "cloud", 9: "night-sprinkle", 10: "day-sprinkle", 11: "raindrops", 12: "sprinkle", 13: "night-alt-showers", 14: "day-showers", 15: "rain", 16: "night-alt-sleet", 17: "day-sleet", 18: "sleet", 19: "night-alt-hail", 20: "day-hail", 21: "hail", 22: "night-alt-snow", 23: "day-snow", 24: "snow", 25: "night-alt-snow", 26: "day-snow", 27: "snow", 28: "night-alt-thunderstorm", 29: "day-thunderstorm", 30: "thunderstorm" }; return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; } }); ================================================ FILE: modules/default/weather/providers/weatherbit.js ================================================ /* global WeatherProvider, WeatherObject */ /* * This class is a provider for Weatherbit, * see https://www.weatherbit.io/ */ WeatherProvider.register("weatherbit", { /* * Set the name of the provider. * Not strictly required, but helps for debugging. */ providerName: "Weatherbit", // Set the default config properties that is specific to this provider defaults: { apiBase: "https://api.weatherbit.io/v2.0", apiKey: "", lat: 0, lon: 0 }, fetchedLocation () { return this.fetchedLocationName || ""; }, fetchCurrentWeather () { this.fetchData(this.getUrl()) .then((data) => { if (!data || !data.data[0] || typeof data.data[0].temp === "undefined") { // No usable data? return; } const currentWeather = this.generateWeatherDayFromCurrentWeather(data); this.setCurrentWeather(currentWeather); }) .catch(function (request) { Log.error("[weatherprovider.weatherbit] Could not load data ... ", request); }) .finally(() => this.updateAvailable()); }, fetchWeatherForecast () { this.fetchData(this.getUrl()) .then((data) => { if (!data || !data.data) { // No usable data? return; } const forecast = this.generateWeatherObjectsFromForecast(data.data); this.setWeatherForecast(forecast); this.fetchedLocationName = `${data.city_name}, ${data.state_code}`; }) .catch(function (request) { Log.error("[weatherprovider.weatherbit] Could not load data ... ", request); }) .finally(() => this.updateAvailable()); }, /** * Overrides method for setting config to check if endpoint is correct for hourly * @param {object} config The configuration object */ setConfig (config) { this.config = config; if (!this.config.weatherEndpoint) { switch (this.config.type) { case "hourly": this.config.weatherEndpoint = "/forecast/hourly"; break; case "daily": case "forecast": this.config.weatherEndpoint = "/forecast/daily"; break; case "current": this.config.weatherEndpoint = "/current"; break; default: Log.error("[weatherprovider.weatherbit] weatherEndpoint not configured and could not resolve it based on type"); } } }, // Create a URL from the config and base URL. getUrl () { return `${this.config.apiBase}${this.config.weatherEndpoint}?lat=${this.config.lat}&lon=${this.config.lon}&units=M&key=${this.config.apiKey}`; }, // Implement WeatherDay generator. generateWeatherDayFromCurrentWeather (currentWeatherData) { //Calculate TZ Offset and invert to convert Sunrise/Sunset times to Local const d = new Date(); let tzOffset = d.getTimezoneOffset(); tzOffset = tzOffset * -1; const currentWeather = new WeatherObject(); currentWeather.date = moment.unix(currentWeatherData.data[0].ts); currentWeather.humidity = parseFloat(currentWeatherData.data[0].rh); currentWeather.temperature = parseFloat(currentWeatherData.data[0].temp); currentWeather.windSpeed = parseFloat(currentWeatherData.data[0].wind_spd); currentWeather.windFromDirection = currentWeatherData.data[0].wind_dir; currentWeather.weatherType = this.convertWeatherType(currentWeatherData.data[0].weather.icon); currentWeather.sunrise = moment(currentWeatherData.data[0].sunrise, "HH:mm").add(tzOffset, "m"); currentWeather.sunset = moment(currentWeatherData.data[0].sunset, "HH:mm").add(tzOffset, "m"); this.fetchedLocationName = `${currentWeatherData.data[0].city_name}, ${currentWeatherData.data[0].state_code}`; return currentWeather; }, generateWeatherObjectsFromForecast (forecasts) { const days = []; for (const forecast of forecasts) { const weather = new WeatherObject(); weather.date = moment(forecast.datetime, "YYYY-MM-DD"); weather.minTemperature = forecast.min_temp; weather.maxTemperature = forecast.max_temp; weather.precipitationAmount = forecast.precip; weather.precipitationProbability = forecast.pop; weather.weatherType = this.convertWeatherType(forecast.weather.icon); days.push(weather); } return days; }, // Map icons from Dark Sky to our icons. convertWeatherType (weatherType) { const weatherTypes = { t01d: "day-thunderstorm", t01n: "night-alt-thunderstorm", t02d: "day-thunderstorm", t02n: "night-alt-thunderstorm", t03d: "thunderstorm", t03n: "thunderstorm", t04d: "day-thunderstorm", t04n: "night-alt-thunderstorm", t05d: "day-sleet-storm", t05n: "night-alt-sleet-storm", d01d: "day-sprinkle", d01n: "night-alt-sprinkle", d02d: "day-sprinkle", d02n: "night-alt-sprinkle", d03d: "day-shower", d03n: "night-alt-shower", r01d: "day-shower", r01n: "night-alt-shower", r02d: "day-rain", r02n: "night-alt-rain", r03d: "day-rain", r03n: "night-alt-rain", r04d: "day-sprinkle", r04n: "night-alt-sprinkle", r05d: "day-shower", r05n: "night-alt-shower", r06d: "day-shower", r06n: "night-alt-shower", f01d: "day-sleet", f01n: "night-alt-sleet", s01d: "day-snow", s01n: "night-alt-snow", s02d: "day-snow-wind", s02n: "night-alt-snow-wind", s03d: "snowflake-cold", s03n: "snowflake-cold", s04d: "day-rain-mix", s04n: "night-alt-rain-mix", s05d: "day-sleet", s05n: "night-alt-sleet", s06d: "day-snow", s06n: "night-alt-snow", a01d: "day-haze", a01n: "dust", a02d: "smoke", a02n: "smoke", a03d: "day-haze", a03n: "dust", a04d: "dust", a04n: "dust", a05d: "day-fog", a05n: "night-fog", a06d: "fog", a06n: "fog", c01d: "day-sunny", c01n: "night-clear", c02d: "day-sunny-overcast", c02n: "night-alt-partly-cloudy", c03d: "day-cloudy", c03n: "night-alt-cloudy", c04d: "cloudy", c04n: "cloudy", u00d: "rain-mix", u00n: "rain-mix" }; return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; } }); ================================================ FILE: modules/default/weather/providers/weatherflow.js ================================================ /* global WeatherProvider, WeatherObject, WeatherUtils */ /* * This class is a provider for Weatherflow. * Note that the Weatherflow API does not provide snowfall. */ WeatherProvider.register("weatherflow", { /* * Set the name of the provider. * Not strictly required, but helps for debugging */ providerName: "WeatherFlow", // Set the default config properties that is specific to this provider defaults: { apiBase: "https://swd.weatherflow.com/swd/rest/", token: "", stationid: "" }, fetchCurrentWeather () { this.fetchData(this.getUrl()) .then((data) => { 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 = 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("[weatherprovider.weatherflow] Could not load data ... ", request); }) .finally(() => this.updateAvailable()); }, fetchWeatherForecast () { this.fetchData(this.getUrl()) .then((data) => { const days = []; for (const forecast of data.forecast.daily) { const weather = new WeatherObject(); weather.date = moment.unix(forecast.day_start_local); weather.minTemperature = forecast.air_temp_low; weather.maxTemperature = forecast.air_temp_high; weather.precipitationProbability = forecast.precip_probability; 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("[weatherprovider.weatherflow] Could not load data ... ", request); }) .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("[weatherprovider.weatherflow] 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}`; } }); ================================================ FILE: modules/default/weather/providers/weathergov.js ================================================ /* global WeatherProvider, WeatherObject, WeatherUtils */ /* * Provider: weather.gov * https://weather-gov.github.io/api/general-faqs * * This class is a provider for weather.gov. * Note that this is only for US locations (lat and lon) and does not require an API key * Since it is free, there are some items missing - like sunrise, sunset */ WeatherProvider.register("weathergov", { /* * Set the name of the provider. * This isn't strictly necessary, since it will fallback to the provider identifier * But for debugging (and future alerts) it would be nice to have the real name. */ providerName: "Weather.gov", // Set the default config properties that is specific to this provider defaults: { apiBase: "https://api.weather.gov/points/", lat: 0, lon: 0 }, // Flag all needed URLs availability configURLs: false, //This API has multiple urls involved forecastURL: "tbd", forecastHourlyURL: "tbd", forecastGridDataURL: "tbd", observationStationsURL: "tbd", stationObsURL: "tbd", // Called to set the config, this config is the same as the weather module's config. setConfig (config) { this.config = config; this.fetchWxGovURLs(this.config); }, // This returns the name of the fetched location or an empty string. fetchedLocation () { return this.fetchedLocationName || ""; }, // Overwrite the fetchCurrentWeather method. fetchCurrentWeather () { if (!this.configURLs) { Log.info("[weatherprovider.weathergov] fetchCurrentWeather: fetch wx waiting on config URLs"); return; } this.fetchData(this.stationObsURL) .then((data) => { if (!data || !data.properties) { // Did not receive usable new data. return; } const currentWeather = this.generateWeatherObjectFromCurrentWeather(data.properties); this.setCurrentWeather(currentWeather); }) .catch(function (request) { Log.error("[weatherprovider.weathergov] Could not load station obs data ... ", request); }) .finally(() => this.updateAvailable()); }, // Overwrite the fetchWeatherForecast method. fetchWeatherForecast () { if (!this.configURLs) { Log.info("[weatherprovider.weathergov] fetchWeatherForecast: fetch wx waiting on config URLs"); return; } this.fetchData(this.forecastURL) .then((data) => { if (!data || !data.properties || !data.properties.periods || !data.properties.periods.length) { // Did not receive usable new data. return; } const forecast = this.generateWeatherObjectsFromForecast(data.properties.periods); this.setWeatherForecast(forecast); }) .catch(function (request) { Log.error("[weatherprovider.weathergov] Could not load forecast hourly data ... ", request); }) .finally(() => this.updateAvailable()); }, // Overwrite the fetchWeatherHourly method. fetchWeatherHourly () { if (!this.configURLs) { Log.info("[weatherprovider.weathergov] fetchWeatherHourly: fetch wx waiting on config URLs"); return; } this.fetchData(this.forecastHourlyURL) .then((data) => { if (!data) { /* * Did not receive usable new data. * Maybe this needs a better check? */ return; } const hourly = this.generateWeatherObjectsFromHourly(data.properties.periods); this.setWeatherHourly(hourly); }) .catch(function (request) { Log.error("[weatherprovider.weathergov] Could not load data ... ", request); }) .finally(() => this.updateAvailable()); }, /** Weather.gov Specific Methods - These are not part of the default provider methods */ /* * Get specific URLs */ fetchWxGovURLs (config) { this.fetchData(`${config.apiBase}/${config.lat},${config.lon}`) .then((data) => { if (!data || !data.properties) { // points URL did not respond with usable data. return; } this.fetchedLocationName = `${data.properties.relativeLocation.properties.city}, ${data.properties.relativeLocation.properties.state}`; Log.log(`[weatherprovider.weathergov] Forecast location is ${this.fetchedLocationName}`); this.forecastURL = `${data.properties.forecast}?units=si`; this.forecastHourlyURL = `${data.properties.forecastHourly}?units=si`; this.forecastGridDataURL = data.properties.forecastGridData; this.observationStationsURL = data.properties.observationStations; // with this URL, we chain another promise for the station obs URL return this.fetchData(data.properties.observationStations); }) .then((obsData) => { if (!obsData || !obsData.features) { // obs station URL did not respond with usable data. return; } this.stationObsURL = `${obsData.features[0].id}/observations/latest`; }) .catch((err) => { Log.error("[weatherprovider.weathergov] fetchWxGovURLs error: ", err); }) .finally(() => { // excellent, let's fetch some actual wx data this.configURLs = true; // handle 'forecast' config, fall back to 'current' if (config.type === "forecast") { this.fetchWeatherForecast(); } else if (config.type === "hourly") { this.fetchWeatherHourly(); } else { this.fetchCurrentWeather(); } }); }, /* * Generate a WeatherObject based on hourlyWeatherInformation * Weather.gov API uses specific units; API does not include choice of units * ... object needs data in units based on config! */ generateWeatherObjectsFromHourly (forecasts) { const days = []; // variable for date let weather = new WeatherObject(); for (const forecast of forecasts) { weather.date = moment(forecast.startTime.slice(0, 19)); if (forecast.windSpeed.search(" ") < 0) { weather.windSpeed = forecast.windSpeed; } else { weather.windSpeed = forecast.windSpeed.slice(0, forecast.windSpeed.search(" ")); } weather.windSpeed = WeatherUtils.convertWindToMs(weather.windSpeed); weather.windFromDirection = forecast.windDirection; weather.temperature = forecast.temperature; //assign probability of precipitation if (forecast.probabilityOfPrecipitation.value === null) { weather.precipitationProbability = 0; } else { weather.precipitationProbability = forecast.probabilityOfPrecipitation.value; } // use the forecast isDayTime attribute to help build the weatherType label weather.weatherType = this.convertWeatherType(forecast.shortForecast, forecast.isDaytime); days.push(weather); weather = new WeatherObject(); } // push weather information to days array days.push(weather); return days; }, /* * Generate a WeatherObject based on currentWeatherInformation * Weather.gov API uses specific units; API does not include choice of units * ... object needs data in units based on config! */ generateWeatherObjectFromCurrentWeather (currentWeatherData) { const currentWeather = new WeatherObject(); currentWeather.date = moment(currentWeatherData.timestamp); currentWeather.temperature = currentWeatherData.temperature.value; currentWeather.windSpeed = WeatherUtils.convertWindToMs(currentWeatherData.windSpeed.value); currentWeather.windFromDirection = currentWeatherData.windDirection.value; currentWeather.minTemperature = currentWeatherData.minTemperatureLast24Hours.value; currentWeather.maxTemperature = currentWeatherData.maxTemperatureLast24Hours.value; currentWeather.humidity = Math.round(currentWeatherData.relativeHumidity.value); currentWeather.precipitationAmount = currentWeatherData.precipitationLastHour?.value ?? currentWeatherData.precipitationLast3Hours?.value; if (currentWeatherData.heatIndex.value !== null) { currentWeather.feelsLikeTemp = currentWeatherData.heatIndex.value; } else if (currentWeatherData.windChill.value !== null) { currentWeather.feelsLikeTemp = currentWeatherData.windChill.value; } else { currentWeather.feelsLikeTemp = currentWeatherData.temperature.value; } // determine the sunrise/sunset times - not supplied in weather.gov data currentWeather.updateSunTime(this.config.lat, this.config.lon); // update weatherType currentWeather.weatherType = this.convertWeatherType(currentWeatherData.textDescription, currentWeather.isDayTime()); return currentWeather; }, /* * Generate WeatherObjects based on forecast information */ generateWeatherObjectsFromForecast (forecasts) { return this.fetchForecastDaily(forecasts); }, /* * fetch forecast information for daily forecast. */ fetchForecastDaily (forecasts) { // initial variable declaration const days = []; // variables for temperature range and rain let minTemp = []; let maxTemp = []; // variable for date let date = ""; let weather = new WeatherObject(); for (const forecast of forecasts) { if (date !== moment(forecast.startTime).format("YYYY-MM-DD")) { // calculate minimum/maximum temperature, specify rain amount weather.minTemperature = Math.min.apply(null, minTemp); weather.maxTemperature = Math.max.apply(null, maxTemp); // push weather information to days array days.push(weather); // create new weather-object weather = new WeatherObject(); minTemp = []; maxTemp = []; //assign probability of precipitation if (forecast.probabilityOfPrecipitation.value === null) { weather.precipitationProbability = 0; } else { weather.precipitationProbability = forecast.probabilityOfPrecipitation.value; } // set new date date = moment(forecast.startTime).format("YYYY-MM-DD"); // specify date weather.date = moment(forecast.startTime); // use the forecast isDayTime attribute to help build the weatherType label weather.weatherType = this.convertWeatherType(forecast.shortForecast, forecast.isDaytime); } if (moment(forecast.startTime).format("H") >= 8 && moment(forecast.startTime).format("H") <= 17) { weather.weatherType = this.convertWeatherType(forecast.shortForecast, forecast.isDaytime); } /* * the same day as before * add values from forecast to corresponding variables */ minTemp.push(forecast.temperature); maxTemp.push(forecast.temperature); } /* * last day * calculate minimum/maximum temperature */ weather.minTemperature = Math.min.apply(null, minTemp); weather.maxTemperature = Math.max.apply(null, maxTemp); // push weather information to days array days.push(weather); return days.slice(1); }, /* * Convert the icons to a more usable name. */ convertWeatherType (weatherType, isDaytime) { /* * https://w1.weather.gov/xml/current_obs/weather.php * There are way too many types to create, so lets just look for certain strings */ if (weatherType.includes("Cloudy") || weatherType.includes("Partly")) { if (isDaytime) { return "day-cloudy"; } return "night-cloudy"; } else if (weatherType.includes("Overcast")) { if (isDaytime) { return "cloudy"; } return "night-cloudy"; } else if (weatherType.includes("Freezing") || weatherType.includes("Ice")) { return "rain-mix"; } else if (weatherType.includes("Snow")) { if (isDaytime) { return "snow"; } return "night-snow"; } else if (weatherType.includes("Thunderstorm")) { if (isDaytime) { return "thunderstorm"; } return "night-thunderstorm"; } else if (weatherType.includes("Showers")) { if (isDaytime) { return "showers"; } return "night-showers"; } else if (weatherType.includes("Rain") || weatherType.includes("Drizzle")) { if (isDaytime) { return "rain"; } return "night-rain"; } else if (weatherType.includes("Breezy") || weatherType.includes("Windy")) { if (isDaytime) { return "cloudy-windy"; } return "night-alt-cloudy-windy"; } else if (weatherType.includes("Fair") || weatherType.includes("Clear") || weatherType.includes("Few") || weatherType.includes("Sunny")) { if (isDaytime) { return "day-sunny"; } return "night-clear"; } else if (weatherType.includes("Dust") || weatherType.includes("Sand")) { return "dust"; } else if (weatherType.includes("Fog")) { return "fog"; } else if (weatherType.includes("Smoke")) { return "smoke"; } else if (weatherType.includes("Haze")) { return "day-haze"; } return null; } }); ================================================ FILE: modules/default/weather/providers/yr.js ================================================ /* global WeatherProvider, WeatherObject */ /* * This class is a provider for Yr.no, a norwegian weather service. * Terms of service: https://developer.yr.no/doc/TermsOfService/ */ WeatherProvider.register("yr", { providerName: "Yr", // Set the default config properties that is specific to this provider defaults: { useCorsProxy: true, apiBase: "https://api.met.no/weatherapi", forecastApiVersion: "2.0", sunriseApiVersion: "3.0", altitude: 0, currentForecastHours: 1 //1, 6 or 12 }, start () { if (typeof Storage === "undefined") { //local storage unavailable Log.error("[weatherprovider.yr] The Yr weather provider requires local storage."); throw new Error("Local storage not available"); } if (this.config.updateInterval < 600000) { Log.warn("[weatherprovider.yr] The Yr weather provider requires a minimum update interval of 10 minutes (600 000 ms). The configuration has been adjusted to meet this requirement."); this.delegate.config.updateInterval = 600000; } Log.info(`[weatherprovider.yr] ${this.providerName} started.`); }, fetchCurrentWeather () { this.getCurrentWeather() .then((currentWeather) => { this.setCurrentWeather(currentWeather); this.updateAvailable(); }) .catch((error) => { Log.error("[weatherprovider.yr] fetchCurrentWeather error:", error); this.updateAvailable(); }); }, async getCurrentWeather () { const [weatherData, stellarData] = await Promise.all([this.getWeatherData(), this.getStellarData()]); if (!stellarData) { Log.warn("[weatherprovider.yr] No stellar data available."); } if (!weatherData.properties.timeseries || !weatherData.properties.timeseries[0]) { Log.error("[weatherprovider.yr] No weather data available."); return; } const currentTime = moment(); let forecast = weatherData.properties.timeseries[0]; let closestTimeInPast = currentTime.diff(moment(forecast.time)); for (const forecastTime of weatherData.properties.timeseries) { const comparison = currentTime.diff(moment(forecastTime.time)); if (0 < comparison && comparison < closestTimeInPast) { closestTimeInPast = comparison; forecast = forecastTime; } } const forecastXHours = this.getForecastForXHoursFrom(forecast.data); forecast.weatherType = this.convertWeatherType(forecastXHours.summary.symbol_code, forecast.time); forecast.precipitationAmount = forecastXHours.details?.precipitation_amount; forecast.precipitationProbability = forecastXHours.details?.probability_of_precipitation; forecast.minTemperature = forecastXHours.details?.air_temperature_min; forecast.maxTemperature = forecastXHours.details?.air_temperature_max; return this.getWeatherDataFrom(forecast, stellarData, weatherData.properties.meta.units); }, getWeatherData () { return new Promise((resolve, reject) => { /* * If a user has several Yr-modules, for instance one current and one forecast, the API calls must be synchronized across classes. * This is to avoid multiple similar calls to the API. */ let shouldWait = localStorage.getItem("yrIsFetchingWeatherData"); if (shouldWait) { const checkForGo = setInterval(function () { shouldWait = localStorage.getItem("yrIsFetchingWeatherData"); }, 100); setTimeout(function () { clearInterval(checkForGo); shouldWait = false; }, 5000); //Assume other fetch finished but failed to remove lock const attemptFetchWeather = setInterval(() => { if (!shouldWait) { clearInterval(checkForGo); clearInterval(attemptFetchWeather); this.getWeatherDataFromYrOrCache(resolve, reject); } }, 100); } else { this.getWeatherDataFromYrOrCache(resolve, reject); } }); }, getWeatherDataFromYrOrCache (resolve, reject) { localStorage.setItem("yrIsFetchingWeatherData", "true"); let weatherData = this.getWeatherDataFromCache(); if (this.weatherDataIsValid(weatherData)) { localStorage.removeItem("yrIsFetchingWeatherData"); Log.debug("[weatherprovider.yr] Weather data found in cache."); resolve(weatherData); } else { this.getWeatherDataFromYr(weatherData?.downloadedAt) .then((weatherData) => { Log.debug("[weatherprovider.yr] Got weather data from yr."); let data; if (weatherData) { this.cacheWeatherData(weatherData); data = weatherData; } else { //Undefined if unchanged data = this.getWeatherDataFromCache(); } resolve(data); }) .catch((err) => { Log.error("[weatherprovider.yr] getWeatherDataFromYr error: ", err); if (weatherData) { Log.warn("[weatherprovider.yr] Using outdated cached weather data."); resolve(weatherData); } else { reject("Unable to get weather data from Yr."); } }) .finally(() => { localStorage.removeItem("yrIsFetchingWeatherData"); }); } }, weatherDataIsValid (weatherData) { return ( weatherData && weatherData.timeout && 0 < moment(weatherData.timeout).diff(moment()) && (!weatherData.geometry || !weatherData.geometry.coordinates || !weatherData.geometry.coordinates.length < 2 || (weatherData.geometry.coordinates[0] === this.config.lat && weatherData.geometry.coordinates[1] === this.config.lon)) ); }, getWeatherDataFromCache () { const weatherData = localStorage.getItem("weatherData"); if (weatherData) { return JSON.parse(weatherData); } else { return undefined; } }, getWeatherDataFromYr (currentDataFetchedAt) { const requestHeaders = [{ name: "Accept", value: "application/json" }]; if (currentDataFetchedAt) { requestHeaders.push({ name: "If-Modified-Since", value: currentDataFetchedAt }); } const expectedResponseHeaders = ["expires", "date"]; return this.fetchData(this.getForecastUrl(), "json", requestHeaders, expectedResponseHeaders) .then((data) => { if (!data || !data.headers) return data; data.timeout = data.headers.find((header) => header.name === "expires").value; data.downloadedAt = data.headers.find((header) => header.name === "date").value; data.headers = undefined; return data; }) .catch((err) => { Log.error("[weatherprovider.yr] Could not load weather data.", err); throw new Error(err); }); }, getConfigOptions () { if (!this.config.lat) { Log.error("[weatherprovider.yr] Latitude not provided."); throw new Error("Latitude not provided."); } if (!this.config.lon) { Log.error("[weatherprovider.yr] Longitude not provided."); throw new Error("Longitude not provided."); } let lat = this.config.lat.toString(); let lon = this.config.lon.toString(); const altitude = this.config.altitude ?? 0; return { lat, lon, altitude }; }, getForecastUrl () { let { lat, lon, altitude } = this.getConfigOptions(); if (lat.includes(".") && lat.split(".")[1].length > 4) { Log.warn("[weatherprovider.yr] Latitude is too specific for weather data. Do not use more than four decimals. Trimming to maximum length."); const latParts = lat.split("."); lat = `${latParts[0]}.${latParts[1].substring(0, 4)}`; } if (lon.includes(".") && lon.split(".")[1].length > 4) { Log.warn("[weatherprovider.yr] Longitude is too specific for weather data. Do not use more than four decimals. Trimming to maximum length."); const lonParts = lon.split("."); lon = `${lonParts[0]}.${lonParts[1].substring(0, 4)}`; } return `${this.config.apiBase}/locationforecast/${this.config.forecastApiVersion}/complete?&altitude=${altitude}&lat=${lat}&lon=${lon}`; }, cacheWeatherData (weatherData) { localStorage.setItem("weatherData", JSON.stringify(weatherData)); }, getStellarData () { /* * If a user has several Yr-modules, for instance one current and one forecast, the API calls must be synchronized across classes. * This is to avoid multiple similar calls to the API. */ return new Promise((resolve, reject) => { let shouldWait = localStorage.getItem("yrIsFetchingStellarData"); if (shouldWait) { const checkForGo = setInterval(function () { shouldWait = localStorage.getItem("yrIsFetchingStellarData"); }, 100); setTimeout(function () { clearInterval(checkForGo); shouldWait = false; }, 5000); //Assume other fetch finished but failed to remove lock const attemptFetchWeather = setInterval(() => { if (!shouldWait) { clearInterval(checkForGo); clearInterval(attemptFetchWeather); this.getStellarDataFromYrOrCache(resolve, reject); } }, 100); } else { this.getStellarDataFromYrOrCache(resolve, reject); } }); }, getStellarDataFromYrOrCache (resolve, reject) { localStorage.setItem("yrIsFetchingStellarData", "true"); let stellarData = this.getStellarDataFromCache(); const today = moment().format("YYYY-MM-DD"); const tomorrow = moment().add(1, "days").format("YYYY-MM-DD"); if (stellarData && stellarData.today && stellarData.today.date === today && stellarData.tomorrow && stellarData.tomorrow.date === tomorrow) { Log.debug("[weatherprovider.yr] Stellar data found in cache."); localStorage.removeItem("yrIsFetchingStellarData"); resolve(stellarData); } else if (stellarData && stellarData.tomorrow && stellarData.tomorrow.date === today) { Log.debug("[weatherprovider.yr] Stellar data for today found in cache, but not for tomorrow."); stellarData.today = stellarData.tomorrow; this.getStellarDataFromYr(tomorrow) .then((data) => { if (data) { data.date = tomorrow; stellarData.tomorrow = data; this.cacheStellarData(stellarData); resolve(stellarData); } else { reject(`No stellar data returned from Yr for ${tomorrow}`); } }) .catch((err) => { Log.error("[weatherprovider.yr] getStellarDataFromYr error: ", err); reject(`Unable to get stellar data from Yr for ${tomorrow}`); }) .finally(() => { localStorage.removeItem("yrIsFetchingStellarData"); }); } else { this.getStellarDataFromYr(today, 2) .then((stellarData) => { if (stellarData) { const data = { today: stellarData }; data.tomorrow = Object.assign({}, data.today); data.today.date = today; data.tomorrow.date = tomorrow; this.cacheStellarData(data); resolve(data); } else { Log.error(`[weatherprovider.yr] Something went wrong when fetching stellar data. Responses: ${stellarData}`); reject(stellarData); } }) .catch((err) => { Log.error("[weatherprovider.yr] getStellarDataFromYr error: ", err); reject("Unable to get stellar data from Yr."); }) .finally(() => { localStorage.removeItem("yrIsFetchingStellarData"); }); } }, getStellarDataFromCache () { const stellarData = localStorage.getItem("stellarData"); if (stellarData) { return JSON.parse(stellarData); } else { return undefined; } }, getStellarDataFromYr (date, days = 1) { const requestHeaders = [{ name: "Accept", value: "application/json" }]; return this.fetchData(this.getStellarDataUrl(date, days), "json", requestHeaders) .then((data) => { Log.debug("[weatherprovider.yr] Got stellar data from yr."); return data; }) .catch((err) => { Log.error("[weatherprovider.yr] Could not load weather data.", err); throw new Error(err); }); }, getStellarDataUrl (date, days) { let { lat, lon, altitude } = this.getConfigOptions(); if (lat.includes(".") && lat.split(".")[1].length > 4) { Log.warn("[weatherprovider.yr] Latitude is too specific for stellar data. Do not use more than four decimals. Trimming to maximum length."); const latParts = lat.split("."); lat = `${latParts[0]}.${latParts[1].substring(0, 4)}`; } if (lon.includes(".") && lon.split(".")[1].length > 4) { Log.warn("[weatherprovider.yr] Longitude is too specific for stellar data. Do not use more than four decimals. Trimming to maximum length."); const lonParts = lon.split("."); lon = `${lonParts[0]}.${lonParts[1].substring(0, 4)}`; } let utcOffset = moment().utcOffset() / 60; let utcOffsetPrefix = "%2B"; if (utcOffset < 0) { utcOffsetPrefix = "-"; } utcOffset = Math.abs(utcOffset); let minutes = "00"; if (utcOffset % 1 !== 0) { minutes = "30"; } let hours = Math.floor(utcOffset).toString(); if (hours.length < 2) { hours = `0${hours}`; } return `${this.config.apiBase}/sunrise/${this.config.sunriseApiVersion}/sun?lat=${lat}&lon=${lon}&date=${date}&offset=${utcOffsetPrefix}${hours}%3A${minutes}`; }, cacheStellarData (data) { localStorage.setItem("stellarData", JSON.stringify(data)); }, getWeatherDataFrom (forecast, stellarData, units) { const weather = new WeatherObject(); weather.date = moment(forecast.time); weather.windSpeed = forecast.data.instant.details.wind_speed; weather.windFromDirection = forecast.data.instant.details.wind_from_direction; weather.temperature = forecast.data.instant.details.air_temperature; weather.minTemperature = forecast.minTemperature; weather.maxTemperature = forecast.maxTemperature; weather.weatherType = forecast.weatherType; weather.humidity = forecast.data.instant.details.relative_humidity; weather.precipitationAmount = forecast.precipitationAmount; weather.precipitationProbability = forecast.precipitationProbability; weather.precipitationUnits = units.precipitation_amount; weather.sunrise = stellarData?.today?.properties?.sunrise?.time; weather.sunset = stellarData?.today?.properties?.sunset?.time; return weather; }, convertWeatherType (weatherType, weatherTime) { const weatherHour = moment(weatherTime).format("HH"); const weatherTypes = { clearsky_day: "day-sunny", clearsky_night: "night-clear", clearsky_polartwilight: weatherHour < 14 ? "sunrise" : "sunset", cloudy: "cloudy", fair_day: "day-sunny-overcast", fair_night: "night-alt-partly-cloudy", fair_polartwilight: "day-sunny-overcast", fog: "fog", heavyrain: "rain", // Possibly raindrops or raindrop heavyrainandthunder: "thunderstorm", heavyrainshowers_day: "day-rain", heavyrainshowers_night: "night-alt-rain", heavyrainshowers_polartwilight: "day-rain", heavyrainshowersandthunder_day: "day-thunderstorm", heavyrainshowersandthunder_night: "night-alt-thunderstorm", heavyrainshowersandthunder_polartwilight: "day-thunderstorm", heavysleet: "sleet", heavysleetandthunder: "day-sleet-storm", heavysleetshowers_day: "day-sleet", heavysleetshowers_night: "night-alt-sleet", heavysleetshowers_polartwilight: "day-sleet", heavysleetshowersandthunder_day: "day-sleet-storm", heavysleetshowersandthunder_night: "night-alt-sleet-storm", heavysleetshowersandthunder_polartwilight: "day-sleet-storm", heavysnow: "snow-wind", heavysnowandthunder: "day-snow-thunderstorm", heavysnowshowers_day: "day-snow-wind", heavysnowshowers_night: "night-alt-snow-wind", heavysnowshowers_polartwilight: "day-snow-wind", heavysnowshowersandthunder_day: "day-snow-thunderstorm", heavysnowshowersandthunder_night: "night-alt-snow-thunderstorm", heavysnowshowersandthunder_polartwilight: "day-snow-thunderstorm", lightrain: "rain-mix", lightrainandthunder: "thunderstorm", lightrainshowers_day: "day-rain-mix", lightrainshowers_night: "night-alt-rain-mix", lightrainshowers_polartwilight: "day-rain-mix", lightrainshowersandthunder_day: "thunderstorm", lightrainshowersandthunder_night: "thunderstorm", lightrainshowersandthunder_polartwilight: "thunderstorm", lightsleet: "day-sleet", lightsleetandthunder: "day-sleet-storm", lightsleetshowers_day: "day-sleet", lightsleetshowers_night: "night-alt-sleet", lightsleetshowers_polartwilight: "day-sleet", lightsnow: "snowflake-cold", lightsnowandthunder: "day-snow-thunderstorm", lightsnowshowers_day: "day-snow-wind", lightsnowshowers_night: "night-alt-snow-wind", lightsnowshowers_polartwilight: "day-snow-wind", lightssleetshowersandthunder_day: "day-sleet-storm", lightssleetshowersandthunder_night: "night-alt-sleet-storm", lightssleetshowersandthunder_polartwilight: "day-sleet-storm", lightssnowshowersandthunder_day: "day-snow-thunderstorm", lightssnowshowersandthunder_night: "night-alt-snow-thunderstorm", lightssnowshowersandthunder_polartwilight: "day-snow-thunderstorm", partlycloudy_day: "day-cloudy", partlycloudy_night: "night-alt-cloudy", partlycloudy_polartwilight: "day-cloudy", rain: "rain", rainandthunder: "thunderstorm", rainshowers_day: "day-rain", rainshowers_night: "night-alt-rain", rainshowers_polartwilight: "day-rain", rainshowersandthunder_day: "thunderstorm", rainshowersandthunder_night: "lightning", rainshowersandthunder_polartwilight: "thunderstorm", sleet: "sleet", sleetandthunder: "day-sleet-storm", sleetshowers_day: "day-sleet", sleetshowers_night: "night-alt-sleet", sleetshowers_polartwilight: "day-sleet", sleetshowersandthunder_day: "day-sleet-storm", sleetshowersandthunder_night: "night-alt-sleet-storm", sleetshowersandthunder_polartwilight: "day-sleet-storm", snow: "snowflake-cold", snowandthunder: "lightning", snowshowers_day: "day-snow-wind", snowshowers_night: "night-alt-snow-wind", snowshowers_polartwilight: "day-snow-wind", snowshowersandthunder_day: "day-snow-thunderstorm", snowshowersandthunder_night: "night-alt-snow-thunderstorm", snowshowersandthunder_polartwilight: "day-snow-thunderstorm" }; return weatherTypes.hasOwnProperty(weatherType) ? weatherTypes[weatherType] : null; }, getForecastForXHoursFrom (weather) { if (this.config.currentForecastHours === 1) { if (weather.next_1_hours) { return weather.next_1_hours; } else if (weather.next_6_hours) { return weather.next_6_hours; } else { return weather.next_12_hours; } } else if (this.config.currentForecastHours === 6) { if (weather.next_6_hours) { return weather.next_6_hours; } else if (weather.next_12_hours) { return weather.next_12_hours; } else { return weather.next_1_hours; } } else { if (weather.next_12_hours) { return weather.next_12_hours; } else if (weather.next_6_hours) { return weather.next_6_hours; } else { return weather.next_1_hours; } } }, fetchWeatherHourly () { this.getWeatherForecast("hourly") .then((forecast) => { this.setWeatherHourly(forecast); this.updateAvailable(); }) .catch((error) => { Log.error("[weatherprovider.yr] fetchWeatherHourly error: ", error); this.updateAvailable(); }); }, async getWeatherForecast (type) { const [weatherData, stellarData] = await Promise.all([this.getWeatherData(), this.getStellarData()]); if (!weatherData.properties.timeseries || !weatherData.properties.timeseries[0]) { Log.error("[weatherprovider.yr] No weather data available."); return; } if (!stellarData) { Log.warn("[weatherprovider.yr] No stellar data available."); } let forecasts; switch (type) { case "hourly": forecasts = this.getHourlyForecastFrom(weatherData); break; case "daily": default: forecasts = this.getDailyForecastFrom(weatherData); break; } const series = []; for (const forecast of forecasts) { series.push(this.getWeatherDataFrom(forecast, stellarData, weatherData.properties.meta.units)); } return series; }, 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; forecast.minTemperature = forecast.data.next_1_hours?.details?.air_temperature_min; forecast.maxTemperature = forecast.data.next_1_hours?.details?.air_temperature_max; forecast.weatherType = this.convertWeatherType(forecast.symbol, forecast.time); series.push(forecast); } return series; }, getDailyForecastFrom (weatherData) { const series = []; const days = weatherData.properties.timeseries.reduce(function (days, forecast) { const date = moment(forecast.time).format("YYYY-MM-DD"); days[date] = days[date] || []; days[date].push(forecast); return days; }, Object.create(null)); Object.keys(days).forEach(function (time) { let minTemperature = undefined; let maxTemperature = undefined; //Default to first entry let forecast = days[time][0]; forecast.symbol = forecast.data.next_12_hours?.summary?.symbol_code; forecast.precipitation = forecast.data.next_12_hours?.details?.precipitation_amount; //Coming days let forecastDiffToEight = undefined; for (const timeseries of days[time]) { if (!timeseries.data.next_6_hours) continue; //next_6_hours has the most data if (!minTemperature || timeseries.data.next_6_hours.details.air_temperature_min < minTemperature) minTemperature = timeseries.data.next_6_hours.details.air_temperature_min; if (!maxTemperature || maxTemperature < timeseries.data.next_6_hours.details.air_temperature_max) maxTemperature = timeseries.data.next_6_hours.details.air_temperature_max; let closestTime = Math.abs(moment(timeseries.time).local().set({ hour: 8, minute: 0, second: 0, millisecond: 0 }).diff(moment(timeseries.time).local())); if ((forecastDiffToEight === undefined || closestTime < forecastDiffToEight) && timeseries.data.next_12_hours) { forecastDiffToEight = closestTime; forecast = timeseries; } } const forecastXHours = forecast.data.next_12_hours ?? forecast.data.next_6_hours ?? forecast.data.next_1_hours; if (forecastXHours) { forecast.symbol = forecastXHours.summary?.symbol_code; forecast.precipitationAmount = forecastXHours.details?.precipitation_amount ?? forecast.data.next_6_hours?.details?.precipitation_amount; // 6 hours is likely to have precipitation amount even if 12 hours does not forecast.precipitationProbability = forecastXHours.details?.probability_of_precipitation; forecast.minTemperature = minTemperature; forecast.maxTemperature = maxTemperature; series.push(forecast); } }); for (const forecast of series) { forecast.weatherType = this.convertWeatherType(forecast.symbol, forecast.time); } return series; }, fetchWeatherForecast () { this.getWeatherForecast("daily") .then((forecast) => { this.setWeatherForecast(forecast); this.updateAvailable(); }) .catch((error) => { Log.error("[weatherprovider.yr] fetchWeatherForecast error: ", error); this.updateAvailable(); }); } }); ================================================ FILE: modules/default/weather/weather.css ================================================ .weather .weathericon, .weather .fa-home { font-size: 75%; } .weather .humidity-icon { padding-right: 4px; } .weather .humidity-padding { padding-bottom: 6px; } .weather .day { padding-left: 0; padding-right: 25px; } .weather .weather-icon { padding-right: 30px; text-align: center; } .weather .min-temp { padding-left: 20px; padding-right: 0; } .weather .precipitation-amount, .weather .precipitation-prob, .weather .humidity-hourly, .weather .uv-index { padding-left: 20px; padding-right: 0; } .weather tr.colored .min-temp { color: #bcddff; } .weather tr.colored .max-temp { color: #ff8e99; } .weather .type-temp { display: flex; align-items: baseline; gap: 10px; } ================================================ FILE: modules/default/weather/weather.js ================================================ /* global WeatherProvider, WeatherUtils, formatTime */ Module.register("weather", { // Default module config. defaults: { weatherProvider: "openweathermap", roundTemp: false, type: "current", // current, forecast, daily (equivalent to forecast), hourly (only with OpenWeatherMap /onecall endpoint) lang: config.language, units: config.units, tempUnits: config.units, windUnits: config.units, timeFormat: config.timeFormat, updateInterval: 10 * 60 * 1000, // every 10 minutes animationSpeed: 1000, showFeelsLike: true, 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, showPeriod: true, showPeriodUpper: false, showPrecipitationAmount: false, showPrecipitationProbability: false, showUVIndex: false, showSun: true, showWindDirection: true, showWindDirectionAsArrow: false, degreeLabel: false, decimalSymbol: ".", maxNumberOfDays: 5, maxEntries: 5, ignoreToday: false, fade: true, fadePoint: 0.25, // Start on 1/4th of the list. initialLoadDelay: 0, // 0 seconds delay appendLocationNameToHeader: true, calendarClass: "calendar", tableClass: "small", onlyTemp: false, colored: false, absoluteDates: false, forecastDateFormat: "ddd", // format for forecast date display, e.g., "ddd" = Mon, "dddd" = Monday, "D MMM" = 18 Oct hourlyForecastIncrements: 1 }, // Module properties. weatherProvider: null, // Can be used by the provider to display location of event if nothing else is specified firstEvent: null, // Define required scripts. getStyles () { return ["font-awesome.css", "weather-icons.css", "weather.css"]; }, // Return the scripts that are necessary for the weather module. getScripts () { return ["moment.js", "weatherutils.js", "weatherobject.js", this.file("providers/overrideWrapper.js"), "weatherprovider.js", "suncalc.js", this.file(`providers/${this.config.weatherProvider.toLowerCase()}.js`)]; }, // Override getHeader method. getHeader () { if (this.config.appendLocationNameToHeader && this.weatherProvider) { if (this.data.header) return `${this.data.header} ${this.weatherProvider.fetchedLocation()}`; else return this.weatherProvider.fetchedLocation(); } return this.data.header ? this.data.header : ""; }, // Start the weather module. start () { moment.locale(this.config.lang); if (this.config.useKmh) { Log.warn("[weather] Deprecation warning: Your are using the deprecated config values 'useKmh'. Please switch to windUnits!"); this.windUnits = "kmh"; } else if (this.config.useBeaufort) { Log.warn("[weather] Deprecation warning: Your are using the deprecated config values 'useBeaufort'. Please switch to windUnits!"); this.windUnits = "beaufort"; } if (typeof this.config.showHumidity === "boolean") { Log.warn("[weather] Deprecation warning: Please consider updating showHumidity to the new style (config string)."); this.config.showHumidity = this.config.showHumidity ? "wind" : "none"; } // Initialize the weather provider. this.weatherProvider = WeatherProvider.initialize(this.config.weatherProvider, this); // Let the weather provider know we are starting. this.weatherProvider.start(); // Add custom filters this.addFilters(); // Schedule the first update. this.scheduleUpdate(this.config.initialLoadDelay); }, // Override notification handler. notificationReceived (notification, payload, sender) { if (notification === "CALENDAR_EVENTS") { const senderClasses = sender.data.classes.toLowerCase().split(" "); if (senderClasses.indexOf(this.config.calendarClass.toLowerCase()) !== -1) { this.firstEvent = null; for (let event of payload) { if (event.location || event.geo) { this.firstEvent = event; Log.debug("[weather] First upcoming event with location: ", event); break; } } } } else if (notification === "INDOOR_TEMPERATURE") { this.indoorTemperature = this.roundValue(payload); this.updateDom(300); } else if (notification === "INDOOR_HUMIDITY") { this.indoorHumidity = this.roundValue(payload); this.updateDom(300); } else if (notification === "CURRENT_WEATHER_OVERRIDE" && this.config.allowOverrideNotification) { this.weatherProvider.notificationReceived(payload); } }, // Select the template depending on the display type. getTemplate () { switch (this.config.type.toLowerCase()) { case "current": return "current.njk"; case "hourly": return "hourly.njk"; case "daily": case "forecast": return "forecast.njk"; //Make the invalid values use the "Loading..." from forecast default: return "forecast.njk"; } }, // Add all the data to the template. getTemplateData () { const currentData = this.weatherProvider.currentWeather(); const forecastData = this.weatherProvider.weatherForecast(); // Skip some hourly forecast entries if configured const hourlyData = this.weatherProvider.weatherHourly()?.filter((e, i) => (i + 1) % this.config.hourlyForecastIncrements === this.config.hourlyForecastIncrements - 1); return { config: this.config, current: currentData, forecast: forecastData, hourly: hourlyData, indoor: { humidity: this.indoorHumidity, temperature: this.indoorTemperature } }; }, // What to do when the weather provider has new information available? updateAvailable () { Log.log("[weather] New weather information available."); // this value was changed from 0 to 300 to stabilize weather tests: this.updateDom(300); this.scheduleUpdate(); if (this.weatherProvider.currentWeather()) { this.sendNotification("CURRENTWEATHER_TYPE", { type: this.weatherProvider.currentWeather().weatherType?.replace("-", "_") }); } const notificationPayload = { currentWeather: this.config.units === "imperial" ? WeatherUtils.convertWeatherObjectToImperial(this.weatherProvider?.currentWeatherObject?.simpleClone()) ?? null : this.weatherProvider?.currentWeatherObject?.simpleClone() ?? null, forecastArray: this.config.units === "imperial" ? this.weatherProvider?.weatherForecastArray?.map((ar) => WeatherUtils.convertWeatherObjectToImperial(ar.simpleClone())) ?? [] : this.weatherProvider?.weatherForecastArray?.map((ar) => ar.simpleClone()) ?? [], hourlyArray: this.config.units === "imperial" ? this.weatherProvider?.weatherHourlyArray?.map((ar) => WeatherUtils.convertWeatherObjectToImperial(ar.simpleClone())) ?? [] : this.weatherProvider?.weatherHourlyArray?.map((ar) => ar.simpleClone()) ?? [], locationName: this.weatherProvider?.fetchedLocationName, providerName: this.weatherProvider.providerName }; this.sendNotification("WEATHER_UPDATED", notificationPayload); }, scheduleUpdate (delay = null) { let nextLoad = this.config.updateInterval; if (delay !== null && delay >= 0) { nextLoad = delay; } setTimeout(() => { switch (this.config.type.toLowerCase()) { case "current": this.weatherProvider.fetchCurrentWeather(); break; case "hourly": this.weatherProvider.fetchWeatherHourly(); break; case "daily": case "forecast": this.weatherProvider.fetchWeatherForecast(); break; default: Log.error(`[weather] Invalid type ${this.config.type} configured (must be one of 'current', 'hourly', 'daily' or 'forecast')`); } }, nextLoad); }, roundValue (temperature) { const decimals = this.config.roundTemp ? 0 : 1; const roundValue = parseFloat(temperature).toFixed(decimals); return roundValue === "-0" ? 0 : roundValue; }, addFilters () { this.nunjucksEnvironment().addFilter( "formatTime", function (date) { return formatTime(this.config, date); }.bind(this) ); this.nunjucksEnvironment().addFilter( "unit", function (value, type, valueUnit) { let formattedValue; if (type === "temperature") { formattedValue = `${this.roundValue(WeatherUtils.convertTemp(value, this.config.tempUnits))}°`; if (this.config.degreeLabel) { if (this.config.tempUnits === "metric") { formattedValue += "C"; } else if (this.config.tempUnits === "imperial") { formattedValue += "F"; } else { formattedValue += "K"; } } } else if (type === "precip") { if (value === null || isNaN(value)) { formattedValue = ""; } else { formattedValue = WeatherUtils.convertPrecipitationUnit(value, valueUnit, this.config.units); } } else if (type === "humidity") { formattedValue = `${value}%`; } else if (type === "wind") { formattedValue = WeatherUtils.convertWind(value, this.config.windUnits); } return formattedValue; }.bind(this) ); this.nunjucksEnvironment().addFilter( "roundValue", function (value) { return this.roundValue(value); }.bind(this) ); this.nunjucksEnvironment().addFilter( "decimalSymbol", function (value) { return value.toString().replace(/\./g, this.config.decimalSymbol); }.bind(this) ); this.nunjucksEnvironment().addFilter( "calcNumSteps", function (forecast) { return Math.min(forecast.length, this.config.maxNumberOfDays); }.bind(this) ); this.nunjucksEnvironment().addFilter( "calcNumEntries", function (dataArray) { return Math.min(dataArray.length, this.config.maxEntries); }.bind(this) ); this.nunjucksEnvironment().addFilter( "opacity", function (currentStep, numSteps) { if (this.config.fade && this.config.fadePoint < 1) { if (this.config.fadePoint < 0) { this.config.fadePoint = 0; } const startingPoint = numSteps * this.config.fadePoint; const numFadesteps = numSteps - startingPoint; if (currentStep >= startingPoint) { return 1 - (currentStep - startingPoint) / numFadesteps; } else { return 1; } } else { return 1; } }.bind(this) ); } }); ================================================ FILE: modules/default/weather/weatherobject.js ================================================ /* global SunCalc, WeatherUtils */ /** * @external Moment */ class WeatherObject { /** * Constructor for a WeatherObject */ constructor () { this.date = null; this.windSpeed = null; this.windFromDirection = null; this.sunrise = null; this.sunset = null; this.temperature = null; this.minTemperature = null; this.maxTemperature = null; this.weatherType = null; this.humidity = null; this.precipitationAmount = null; this.precipitationUnits = null; this.precipitationProbability = null; this.feelsLikeTemp = null; } cardinalWindDirection () { if (this.windFromDirection > 11.25 && this.windFromDirection <= 33.75) { return "NNE"; } else if (this.windFromDirection > 33.75 && this.windFromDirection <= 56.25) { return "NE"; } else if (this.windFromDirection > 56.25 && this.windFromDirection <= 78.75) { return "ENE"; } else if (this.windFromDirection > 78.75 && this.windFromDirection <= 101.25) { return "E"; } else if (this.windFromDirection > 101.25 && this.windFromDirection <= 123.75) { return "ESE"; } else if (this.windFromDirection > 123.75 && this.windFromDirection <= 146.25) { return "SE"; } else if (this.windFromDirection > 146.25 && this.windFromDirection <= 168.75) { return "SSE"; } else if (this.windFromDirection > 168.75 && this.windFromDirection <= 191.25) { return "S"; } else if (this.windFromDirection > 191.25 && this.windFromDirection <= 213.75) { return "SSW"; } else if (this.windFromDirection > 213.75 && this.windFromDirection <= 236.25) { return "SW"; } else if (this.windFromDirection > 236.25 && this.windFromDirection <= 258.75) { return "WSW"; } else if (this.windFromDirection > 258.75 && this.windFromDirection <= 281.25) { return "W"; } else if (this.windFromDirection > 281.25 && this.windFromDirection <= 303.75) { return "WNW"; } else if (this.windFromDirection > 303.75 && this.windFromDirection <= 326.25) { return "NW"; } else if (this.windFromDirection > 326.25 && this.windFromDirection <= 348.75) { return "NNW"; } else { return "N"; } } /** * Determines if the sun sets or rises next. Uses the current time and not * the date from the weather-forecast. * @param {Moment} date an optional date where you want to get the next * action for. Useful only in tests, defaults to the current time. * @returns {string} "sunset" or "sunrise" */ nextSunAction (date = moment()) { return date.isBetween(this.sunrise, this.sunset) ? "sunset" : "sunrise"; } feelsLike () { if (this.feelsLikeTemp) { return this.feelsLikeTemp; } return WeatherUtils.calculateFeelsLike(this.temperature, this.windSpeed, this.humidity); } /** * Checks if the weatherObject is at dayTime. * @returns {boolean} true if it is at dayTime */ isDayTime () { const now = !this.date ? moment() : this.date; return now.isBetween(this.sunrise, this.sunset, undefined, "[]"); } /** * Update the sunrise / sunset time depending on the location. This can be * used if your provider doesn't provide that data by itself. Then SunCalc * is used here to calculate them according to the location. * @param {number} lat latitude * @param {number} lon longitude */ updateSunTime (lat, lon) { const now = !this.date ? new Date() : this.date.toDate(); const times = SunCalc.getTimes(now, lat, lon); this.sunrise = moment(times.sunrise); this.sunset = moment(times.sunset); } /** * Clone to simple object to prevent mutating and deprecation of legacy library. * * Before being handed to other modules, mutable values must be cloned safely. * Especially 'moment' object is not immutable, so original 'date', 'sunrise', 'sunset' could be corrupted or changed by other modules. * @returns {object} plained object clone of original weatherObject */ simpleClone () { const toFlat = ["date", "sunrise", "sunset"]; let clone = { ...this }; for (const prop of toFlat) { clone[prop] = clone?.[prop]?.valueOf() ?? clone?.[prop]; } return clone; } } /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = WeatherObject; } ================================================ FILE: modules/default/weather/weatherprovider.js ================================================ /* global Class, performWebRequest, OverrideWrapper */ // This class is the blueprint for a weather provider. const WeatherProvider = Class.extend({ // Weather Provider Properties providerName: null, defaults: {}, // The following properties have accessor methods. // Try to not access them directly. currentWeatherObject: null, weatherForecastArray: null, weatherHourlyArray: null, fetchedLocationName: null, // The following properties will be set automatically. // You do not need to overwrite these properties. config: null, delegate: null, providerIdentifier: null, // Weather Provider Methods // All the following methods can be overwritten, although most are good as they are. // Called when a weather provider is initialized. init (config) { this.config = config; Log.info(`[weatherprovider] ${this.providerName} initialized.`); }, // Called to set the config, this config is the same as the weather module's config. setConfig (config) { this.config = config; Log.info(`[weatherprovider] ${this.providerName} config set.`, this.config); }, // Called when the weather provider is about to start. start () { Log.info(`[weatherprovider] ${this.providerName} started.`); }, // This method should start the API request to fetch the current weather. // This method should definitely be overwritten in the provider. fetchCurrentWeather () { Log.warn(`[weatherprovider] ${this.providerName} does not override the fetchCurrentWeather method.`); }, // This method should start the API request to fetch the weather forecast. // This method should definitely be overwritten in the provider. fetchWeatherForecast () { Log.warn(`[weatherprovider] ${this.providerName} does not override the fetchWeatherForecast method.`); }, // This method should start the API request to fetch the weather hourly. // This method should definitely be overwritten in the provider. fetchWeatherHourly () { Log.warn(`[weatherprovider] ${this.providerName} does not override the fetchWeatherHourly method.`); }, // This returns a WeatherDay object for the current weather. currentWeather () { return this.currentWeatherObject; }, // This returns an array of WeatherDay objects for the weather forecast. weatherForecast () { return this.weatherForecastArray; }, // This returns an object containing WeatherDay object(s) depending on the type of call. weatherHourly () { return this.weatherHourlyArray; }, // This returns the name of the fetched location or an empty string. fetchedLocation () { return this.fetchedLocationName || ""; }, // Set the currentWeather and notify the delegate that new information is available. setCurrentWeather (currentWeatherObject) { // We should check here if we are passing a WeatherDay this.currentWeatherObject = currentWeatherObject; }, // Set the weatherForecastArray and notify the delegate that new information is available. setWeatherForecast (weatherForecastArray) { // We should check here if we are passing a WeatherDay this.weatherForecastArray = weatherForecastArray; }, // Set the weatherHourlyArray and notify the delegate that new information is available. setWeatherHourly (weatherHourlyArray) { this.weatherHourlyArray = weatherHourlyArray; }, // Set the fetched location name. setFetchedLocation (name) { this.fetchedLocationName = name; }, // Notify the delegate that new weather is available. updateAvailable () { this.delegate.updateAvailable(this); }, /** * A convenience function to make requests. * @param {string} url the url to fetch from * @param {string} type what content-type to expect in the response, can be "json" or "xml" * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send * @param {Array.} expectedResponseHeaders the expected HTTP headers to receive * @returns {Promise} resolved when the fetch is done */ async fetchData (url, type = "json", requestHeaders = undefined, expectedResponseHeaders = undefined) { const mockData = this.config.mockData; if (mockData) { const data = mockData.substring(1, mockData.length - 1); return JSON.parse(data); } const useCorsProxy = typeof this.config.useCorsProxy !== "undefined" && this.config.useCorsProxy; return performWebRequest(url, type, useCorsProxy, requestHeaders, expectedResponseHeaders, config.basePath); } }); /** * Collection of registered weather providers. */ WeatherProvider.providers = []; /** * Static method to register a new weather provider. * @param {string} providerIdentifier The name of the weather provider * @param {object} providerDetails The details of the weather provider */ WeatherProvider.register = function (providerIdentifier, providerDetails) { WeatherProvider.providers[providerIdentifier.toLowerCase()] = WeatherProvider.extend(providerDetails); }; /** * Static method to initialize a new weather provider. * @param {string} providerIdentifier The name of the weather provider * @param {object} delegate The weather module * @returns {object} The new weather provider */ WeatherProvider.initialize = function (providerIdentifier, delegate) { const pi = providerIdentifier.toLowerCase(); const provider = new WeatherProvider.providers[pi](); const config = Object.assign({}, provider.defaults, delegate.config); provider.delegate = delegate; provider.setConfig(config); provider.providerIdentifier = pi; if (!provider.providerName) { provider.providerName = pi; } if (config.allowOverrideNotification) { return new OverrideWrapper(provider); } return provider; }; ================================================ FILE: modules/default/weather/weatherutils.js ================================================ const WeatherUtils = { /** * Convert wind (from m/s) to beaufort scale * @param {number} speedInMS the windspeed you want to convert * @returns {number} the speed in beaufort */ beaufortWindSpeed (speedInMS) { const windInKmh = this.convertWind(speedInMS, "kmh"); const speeds = [1, 5, 11, 19, 28, 38, 49, 61, 74, 88, 102, 117, 1000]; for (const [index, speed] of speeds.entries()) { if (speed > windInKmh) { return index; } } return 12; }, /** * Convert a value in a given unit to a string with a converted * value and a postfix matching the output unit system. * @param {number} value - The value to convert. * @param {string} valueUnit - The unit the values has. Default is mm. * @param {string} outputUnit - The unit system (imperial/metric) the return value should have. * @returns {string} - A string with tha value and a unit postfix. */ convertPrecipitationUnit (value, valueUnit, outputUnit) { if (valueUnit === "%") return `${value.toFixed(0)} ${valueUnit}`; let convertedValue = value; let conversionUnit = valueUnit; if (outputUnit === "imperial") { convertedValue = this.convertPrecipitationToInch(value, valueUnit); conversionUnit = "in"; } else { conversionUnit = valueUnit ? valueUnit : "mm"; } return `${convertedValue.toFixed(2)} ${conversionUnit}`; }, /** * Convert precipitation value into inch * @param {number} value the precipitation value for convert * @param {string} valueUnit can be 'mm' or 'cm' * @returns {number} the converted precipitation value */ convertPrecipitationToInch (value, valueUnit) { if (valueUnit && valueUnit.toLowerCase() === "cm") return value * 0.3937007874; else return value * 0.03937007874; }, /** * Convert temp (from degrees C) into imperial or metric unit depending on * your config * @param {number} tempInC the temperature in Celsius you want to convert * @param {string} unit can be 'imperial' or 'metric' * @returns {number} the converted temperature */ convertTemp (tempInC, unit) { return unit === "imperial" ? tempInC * 1.8 + 32 : tempInC; }, /** * Convert temp (from degrees C) into metric unit * @param {number} tempInF the temperature in Fahrenheit you want to convert * @returns {number} the converted temperature */ convertTempToMetric (tempInF) { return ((tempInF - 32) * 5) / 9; }, /** * Convert wind speed into another unit. * @param {number} windInMS the windspeed in meter/sec you want to convert * @param {string} unit can be 'beaufort', 'kmh', 'knots, 'imperial' (mph) * or 'metric' (mps) * @returns {number} the converted windspeed */ convertWind (windInMS, unit) { switch (unit) { case "beaufort": return this.beaufortWindSpeed(windInMS); case "kmh": return (windInMS * 3600) / 1000; case "knots": return windInMS * 1.943844; case "imperial": return windInMS * 2.2369362920544; case "metric": default: return windInMS; } }, /* * Convert the wind direction cardinal to value */ convertWindDirection (windDirection) { const windCardinals = { N: 0, NNE: 22, NE: 45, ENE: 67, E: 90, ESE: 112, SE: 135, SSE: 157, S: 180, SSW: 202, SW: 225, WSW: 247, W: 270, WNW: 292, NW: 315, NNW: 337 }; return windCardinals.hasOwnProperty(windDirection) ? windCardinals[windDirection] : null; }, convertWindToMetric (mph) { return mph / 2.2369362920544; }, convertWindToMs (kmh) { return kmh * 0.27777777777778; }, /** * Taken from https://community.home-assistant.io/t/calculating-apparent-feels-like-temperature/370834/18 * @param {number} temperature temperature in degrees Celsius * @param {number} windSpeed wind speed in meter/second * @param {number} humidity relative humidity in percent * @returns {number} the feels like temperature in degrees Celsius */ calculateFeelsLike (temperature, windSpeed, humidity) { const windInMph = this.convertWind(windSpeed, "imperial"); const tempInF = this.convertTemp(temperature, "imperial"); let HI; let WC = tempInF; // Calculate wind chill for certain conditions if (tempInF <= 70 && windInMph >= 3) { WC = 35.74 + (0.6215 * tempInF) - 35.75 * Math.pow(windInMph, 0.16) + ((0.4275 * tempInF) * Math.pow(windInMph, 0.16)); } // Steadman Heat Index Vorberechnung const STEADMAN_HI = 0.5 * (tempInF + 61.0 + ((tempInF - 68.0) * 1.2) + (humidity * 0.094)); if (STEADMAN_HI >= 80) { // Rothfusz-Komplex const ROTHFUSZ_HI = -42.379 + 2.04901523 * tempInF + 10.14333127 * humidity - 0.22475541 * tempInF * humidity - 0.00683783 * tempInF * tempInF - 0.05481717 * humidity * humidity + 0.00122874 * tempInF * tempInF * humidity + 0.00085282 * tempInF * humidity * humidity - 0.00000199 * tempInF * tempInF * humidity * humidity; HI = ROTHFUSZ_HI; if (humidity < 13 && tempInF > 80 && tempInF < 112) { const ADJUSTMENT = ((13 - humidity) / 4) * Math.pow(Math.abs(17 - (tempInF - 95)), 0.5) / 17; // sqrt Teil HI = HI - ADJUSTMENT; } else if (humidity > 85 && tempInF > 80 && tempInF < 87) { const ADJUSTMENT = ((humidity - 85) / 10) * ((87 - tempInF) / 5); HI = HI + ADJUSTMENT; } } else { HI = STEADMAN_HI; } // Feuchte Lastberechnung FL let FL; if (tempInF < 50) { FL = WC; } else if (tempInF >= 50 && tempInF < 70) { FL = ((70 - tempInF) / 20) * WC + ((tempInF - 50) / 20) * HI; } else if (tempInF >= 70) { FL = HI; } return this.convertTempToMetric(FL); }, /** * Converts the Weather Object's values into imperial unit * @param {object} weatherObject the weather object * @returns {object} the weather object with converted values to imperial */ convertWeatherObjectToImperial (weatherObject) { if (!weatherObject || Object.keys(weatherObject).length === 0) return null; let imperialWeatherObject = { ...weatherObject }; if (imperialWeatherObject) { if (imperialWeatherObject.feelsLikeTemp) imperialWeatherObject.feelsLikeTemp = this.convertTemp(imperialWeatherObject.feelsLikeTemp, "imperial"); if (imperialWeatherObject.maxTemperature) imperialWeatherObject.maxTemperature = this.convertTemp(imperialWeatherObject.maxTemperature, "imperial"); if (imperialWeatherObject.minTemperature) imperialWeatherObject.minTemperature = this.convertTemp(imperialWeatherObject.minTemperature, "imperial"); if (imperialWeatherObject.precipitationAmount) imperialWeatherObject.precipitationAmount = this.convertPrecipitationToInch(imperialWeatherObject.precipitationAmount, imperialWeatherObject.precipitationUnits); if (imperialWeatherObject.temperature) imperialWeatherObject.temperature = this.convertTemp(imperialWeatherObject.temperature, "imperial"); if (imperialWeatherObject.windSpeed) imperialWeatherObject.windSpeed = this.convertWind(imperialWeatherObject.windSpeed, "imperial"); } return imperialWeatherObject; } }; if (typeof module !== "undefined") { module.exports = WeatherUtils; } ================================================ FILE: package.json ================================================ { "name": "magicmirror", "version": "2.34.0", "description": "The open source modular smart mirror platform.", "keywords": [ "magic mirror", "magicmirror", "smart mirror", "mirror UI", "modular" ], "homepage": "https://magicmirror.builders", "bugs": { "url": "https://github.com/MagicMirrorOrg/MagicMirror/issues" }, "repository": { "type": "git", "url": "https://github.com/MagicMirrorOrg/MagicMirror" }, "license": "MIT", "author": "Michael Teeuw", "contributors": [ { "name": "MagicMirror contributors", "url": "https://github.com/MagicMirrorOrg/MagicMirror/graphs/contributors" } ], "type": "commonjs", "imports": { "#module_functions": { "default": "./js/module_functions.js" }, "#server_functions": { "default": "./js/server_functions.js" } }, "main": "js/electron.js", "scripts": { "config:check": "node js/check_config.js", "postinstall": "git clean -df fonts vendor", "install-mm": "npm install --no-audit --no-fund --no-update-notifier --only=prod --omit=dev", "install-mm:dev": "npm install --no-audit --no-fund --no-update-notifier && npx playwright install chromium", "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", "prepare": "[ -f node_modules/.bin/husky ] && husky || echo no husky installed.", "server": "node ./serveronly", "server:watch": "node ./serveronly/watcher.js", "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 --ozone-platform=wayland", "start:wayland:dev": "node --run start:wayland -- dev", "start:windows": ".\\node_modules\\.bin\\electron js\\electron.js", "start:windows:dev": "node --run start:windows -- dev", "start:x11": "DISPLAY=\"${DISPLAY:=:0}\" ./node_modules/.bin/electron js/electron.js", "start:x11:dev": "node --run start:x11 -- dev", "test": "vitest run", "test:calendar": "node ./modules/default/calendar/debug.js", "test:coverage": "vitest run --coverage", "test:css": "stylelint 'css/main.css' 'css/roboto.css' 'css/font-awesome.css' 'modules/default/**/*.css'", "test:e2e": "vitest run tests/e2e", "test:electron": "vitest run tests/electron", "test:js": "eslint", "test:markdown": "markdownlint-cli2 .", "test:prettier": "prettier . --check", "test:spelling": "cspell . --gitignore", "test:ui": "vitest --ui", "test:unit": "vitest run tests/unit", "test:watch": "vitest" }, "lint-staged": { "*": "prettier --ignore-unknown --write", "*.js": "eslint --fix", "*.css": "stylelint --fix" }, "dependencies": { "@fontsource/roboto": "^5.2.9", "@fontsource/roboto-condensed": "^5.2.8", "@fortawesome/fontawesome-free": "^7.1.0", "ajv": "^8.17.1", "animate.css": "^4.1.1", "console-stamp": "^3.1.2", "croner": "^9.1.0", "envsub": "^4.1.0", "eslint": "^9.39.2", "express": "^5.2.1", "feedme": "^2.0.2", "helmet": "^8.1.0", "html-to-text": "^9.0.5", "iconv-lite": "^0.7.1", "ipaddr.js": "^2.3.0", "moment": "^2.30.1", "moment-timezone": "^0.6.0", "node-ical": "^0.22.1", "nunjucks": "^3.2.4", "pm2": "^6.0.14", "socket.io": "^4.8.3", "suncalc": "^1.9.0", "systeminformation": "^5.28.2", "undici": "^7.16.0", "weathericons": "^2.1.0" }, "devDependencies": { "@stylistic/eslint-plugin": "^5.6.1", "@vitest/coverage-v8": "^4.0.16", "@vitest/ui": "^4.0.16", "cspell": "^9.4.0", "eslint-plugin-import-x": "^4.16.1", "eslint-plugin-jsdoc": "^61.5.0", "eslint-plugin-package-json": "^0.85.0", "eslint-plugin-playwright": "^2.4.0", "eslint-plugin-vitest": "^0.5.4", "express-basic-auth": "^1.2.1", "husky": "^9.1.7", "jsdom": "^27.4.0", "lint-staged": "^16.2.7", "markdownlint-cli2": "^0.20.0", "playwright": "^1.57.0", "prettier": "^3.7.4", "prettier-plugin-jinja-template": "^2.1.0", "stylelint": "^16.26.1", "stylelint-config-standard": "^39.0.1", "stylelint-prettier": "^5.0.3", "vitest": "^4.0.16" }, "optionalDependencies": { "electron": "^39.2.7" }, "engines": { "node": ">=22.21.1 <23 || >=24" } } ================================================ FILE: prettier.config.mjs ================================================ const config = { plugins: ["prettier-plugin-jinja-template"], overrides: [ { files: "*.md", options: { parser: "markdown" } }, { files: ["*.njk"], options: { parser: "jinja-template" } } ], trailingComma: "none" }; export default config; ================================================ FILE: serveronly/index.js ================================================ const app = require("../js/app"); 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}:${global.mmPort || config.port} <<<`); }); ================================================ FILE: serveronly/watcher.js ================================================ // Load lightweight internal alias resolver to enable require("logger") require("../js/alias-resolver"); const { spawn } = require("child_process"); const fs = require("fs"); const path = require("path"); const net = require("net"); const http = require("http"); const Log = require("logger"); const { getConfigFilePath } = require("#server_functions"); const RESTART_DELAY_MS = 500; const PORT_CHECK_MAX_ATTEMPTS = 20; const PORT_CHECK_INTERVAL_MS = 500; let child = null; let restartTimer = null; let isShuttingDown = false; let isRestarting = false; let serverConfig = null; const rootDir = path.join(__dirname, ".."); /** * Get the server configuration (port and address) * @returns {{port: number, address: string}} The server config */ function getServerConfig () { if (serverConfig) return serverConfig; try { const configPath = getConfigFilePath(); delete require.cache[require.resolve(configPath)]; const config = require(configPath); serverConfig = { port: global.mmPort || config.port || 8080, address: config.address || "localhost" }; } catch (err) { serverConfig = { port: 8080, address: "localhost" }; } return serverConfig; } /** * Check if a port is available on the configured address * @param {number} port The port to check * @returns {Promise} True if port is available */ function isPortAvailable (port) { return new Promise((resolve) => { const server = net.createServer(); server.once("error", () => { resolve(false); }); server.once("listening", () => { server.close(); resolve(true); }); // Use the same address as the actual server will bind to const { address } = getServerConfig(); server.listen(port, address); }); } /** * Wait until port is available * @param {number} port The port to wait for * @param {number} maxAttempts Maximum number of attempts * @returns {Promise} */ async function waitForPort (port, maxAttempts = PORT_CHECK_MAX_ATTEMPTS) { for (let i = 0; i < maxAttempts; i++) { if (await isPortAvailable(port)) { Log.info(`Port ${port} is now available`); return; } await new Promise((resolve) => setTimeout(resolve, PORT_CHECK_INTERVAL_MS)); } Log.warn(`Port ${port} still not available after ${maxAttempts} attempts`); } /** * Start the server process */ function startServer () { // Start node directly instead of via npm to avoid process tree issues child = spawn("node", ["./serveronly"], { stdio: "inherit", cwd: path.join(__dirname, "..") }); child.on("error", (error) => { Log.error("Failed to start server process:", error.message); child = null; }); child.on("exit", (code, signal) => { child = null; if (isShuttingDown) { return; } if (isRestarting) { // Expected restart - don't log as error isRestarting = false; } else { // Unexpected exit Log.error(`Server exited unexpectedly with code ${code} and signal ${signal}`); } }); } /** * Send reload notification to all connected clients */ function notifyClientsToReload () { const { port, address } = getServerConfig(); const options = { hostname: address, port: port, path: "/reload", method: "GET" }; const req = http.request(options, (res) => { if (res.statusCode === 200) { Log.info("Reload notification sent to clients"); } }); req.on("error", (err) => { // Server might not be running yet, ignore Log.debug(`Could not send reload notification: ${err.message}`); }); req.end(); } /** * Restart the server process * @param {string} reason The reason for the restart */ async function restartServer (reason) { if (restartTimer) clearTimeout(restartTimer); restartTimer = setTimeout(async () => { Log.info(reason); if (child) { isRestarting = true; // Get the actual port being used const { port } = getServerConfig(); // Notify clients to reload before restart notifyClientsToReload(); // Set up one-time listener for the exit event child.once("exit", async () => { // Wait until port is actually available await waitForPort(port); // Reset config cache in case it changed serverConfig = null; startServer(); }); child.kill("SIGTERM"); } else { startServer(); } }, RESTART_DELAY_MS); } /** * Watch a specific file for changes and restart the server on change * Watches the parent directory to handle editors that use atomic writes * @param {string} file The file path to watch */ function watchFile (file) { try { const fileName = path.basename(file); const dirName = path.dirname(file); const watcher = fs.watch(dirName, (_eventType, changedFile) => { // Only trigger for the specific file we're interested in if (changedFile !== fileName) return; Log.info(`[watchFile] Change detected in: ${file}`); if (restartTimer) clearTimeout(restartTimer); restartTimer = setTimeout(() => { Log.info(`[watchFile] Triggering restart due to change in: ${file}`); restartServer(`File changed: ${path.basename(file)} — restarting...`); }, RESTART_DELAY_MS); }); watcher.on("error", (error) => { Log.error(`Watcher error for ${file}:`, error.message); }); Log.log(`Watching file: ${file}`); } catch (error) { Log.error(`Failed to watch file ${file}:`, error.message); } } startServer(); // Setup file watching based on config try { const configPath = getConfigFilePath(); delete require.cache[require.resolve(configPath)]; const config = require(configPath); let watchTargets = []; if (Array.isArray(config.watchTargets) && config.watchTargets.length > 0) { watchTargets = config.watchTargets.filter((target) => typeof target === "string" && target.trim() !== ""); } if (watchTargets.length === 0) { Log.warn("Watch mode is enabled but no watchTargets are configured. No files will be monitored. Set the watchTargets array in your config.js to enable file watching."); } Log.log(`Watch mode enabled. Watching ${watchTargets.length} file(s)`); // Watch each target file for (const target of watchTargets) { const targetPath = path.isAbsolute(target) ? target : path.join(rootDir, target); // Check if file exists if (!fs.existsSync(targetPath)) { Log.warn(`Watch target does not exist: ${targetPath}`); continue; } // Check if it's a file (directories are not supported) const stats = fs.statSync(targetPath); if (stats.isFile()) { watchFile(targetPath); } else { Log.warn(`Watch target is not a file (directories not supported): ${targetPath}`); } } } catch (err) { // Config file might not exist or be invalid, use fallback targets Log.warn("Could not load watchTargets from config."); } process.on("SIGINT", () => { isShuttingDown = true; if (restartTimer) clearTimeout(restartTimer); if (child) child.kill("SIGTERM"); process.exit(0); }); ================================================ FILE: stylelint.config.mjs ================================================ const config = { extends: ["stylelint-config-standard", "stylelint-prettier/recommended"], root: true, rules: {} }; export default config; ================================================ FILE: tests/configs/customregions.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], modules: // Using exotic content. This is why don't accept go to JSON configuration file (() => { let positions = ["row3_left", "top3_left1"]; let modules = Array(); for (let idx in positions) { modules.push({ module: "helloworld", position: positions[idx], config: { text: `Text in ${positions[idx]}` } }); } return modules; })() }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/default.js ================================================ 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 ); }; } ================================================ FILE: tests/configs/empty_ipWhiteList.js ================================================ let config = require(`${process.cwd()}/tests/configs/default.js`).configFactory({ ipWhitelist: [], port: 8282 }); /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/alert/welcome_false.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], modules: [ { module: "alert", config: { display_time: 1000000, welcome_message: false } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/alert/welcome_string.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], modules: [ { module: "alert", config: { display_time: 1000000, welcome_message: "Custom welcome message!" } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/alert/welcome_true.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], modules: [ { module: "alert", config: { display_time: 1000000, welcome_message: true } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/3_move_first_allday_repeating_event.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 24, units: "metric", modules: [ { module: "calendar", position: "bottom_bar", config: { fade: false, hideDuplicates: false, maximumEntries: 100, urgency: 0, dateFormat: "Do.MMM, HH:mm", fullDayEventDateFormat: "Do.MMM", timeFormat: "absolute", getRelative: 0, maximumNumberOfDays: 28, calendars: [ { maximumEntries: 100, url: "http://localhost:8080/tests/mocks/3_move_first_allday_repeating_event.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/auth-default.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "calendar", position: "bottom_bar", config: { calendars: [ { maximumNumberOfDays: 10000, url: "http://localhost:8080/tests/mocks/calendar_test.ics", auth: { user: "MagicMirror", pass: "CallMeADog" } } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/bad_rrule.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, logLevel: ["INFO", "LOG", "WARN", "ERROR", "DEBUG"], modules: [ { module: "calendar", position: "bottom_bar", config: { calendars: [ { url: "http://localhost:8080/tests/mocks/bad_rrule.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/basic-auth.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "calendar", position: "bottom_bar", config: { calendars: [ { maximumNumberOfDays: 10000, url: "http://localhost:8080/tests/mocks/calendar_test.ics", auth: { user: "MagicMirror", pass: "CallMeADog", method: "basic" } } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/berlin_end_of_day_repeating.js ================================================ 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/end_of_day_berlin_moved.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/berlin_multi.js ================================================ 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/RepeatingEvent.Oct21.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/berlin_whole_day_event_moved_over_dst_change.js ================================================ 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/whole_day_moved_over_dst_change_berlin.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/changed-port.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "calendar", position: "bottom_bar", config: { calendars: [ { maximumNumberOfDays: 10000, url: "http://localhost:8010/tests/mocks/calendar_test.ics", auth: { user: "MagicMirror", pass: "CallMeADog" } } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/chicago-looking-at-ny-recurring.js ================================================ 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; } ================================================ FILE: tests/configs/modules/calendar/chicago_late_in_timezone.js ================================================ 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: 20, calendars: [ { maximumEntries: 100, //url: "http://localhost:8080/tests/mocks/chicago_late_in_timezone.ics" url: "http://localhost:8080/tests/mocks/chicago_late_in_timezone.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/countCalendarEvents.js ================================================ 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; } ================================================ FILE: tests/configs/modules/calendar/custom.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "calendar", position: "bottom_bar", config: { customEvents: [{ keyword: "CustomEvent", symbol: "dice", eventClass: "undo" }], forceUseCurrentTime: true, calendars: [ { maximumEntries: 5, pastDaysCount: 5, broadcastPastEvents: true, maximumNumberOfDays: 10000, symbol: "birthday-cake", fullDaySymbol: "calendar-day", recurringSymbol: "undo", url: "http://localhost:8080/tests/mocks/calendar_test_icons.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/default.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "calendar", position: "bottom_bar", config: { calendars: [ { maximumNumberOfDays: 10000, url: "http://localhost:8080/tests/mocks/calendar_test.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/diff_tz_start_end.js ================================================ 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", dateEndFormat: "Do.MMM, HH:mm", fullDayEventDateFormat: "Do.MMM", timeFormat: "absolute", getRelative: 0, maximumNumberOfDays: 28, showEnd: true, calendars: [ { maximumEntries: 100, url: "http://localhost:8080/tests/mocks/diff_tz_start_end.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/end_of_day_berlin_moved.js ================================================ 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/end_of_day_berlin_moved.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_display_end.js ================================================ 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", dateEndFormat: "Do.MMM, HH:mm", fullDayEventDateFormat: "Do.MMM", timeFormat: "absolute", getRelative: 0, showEnd: true, calendars: [ { maximumEntries: 100, url: "http://localhost:8080/tests/mocks/event_with_time_over_multiple_days_non_repeating.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/event_with_time_over_multiple_days_non_repeating_no_display_end.js ================================================ 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", dateEndFormat: "Do.MMM, HH:mm", fullDayEventDateFormat: "Do.MMM", timeFormat: "absolute", getRelative: 0, showEnd: true, showEndsOnlyWithDuration: true, calendars: [ { maximumEntries: 100, url: "http://localhost:8080/tests/mocks/event_with_time_over_multiple_days_non_repeating.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/exdate_and_recurrence_together.js ================================================ 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/exdate_and_recurrence_together.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/exdate_la_at_midnight_dst.js ================================================ /* * MagicMirror² Test calendar exdate * * By jkriegshauser * MIT Licensed. * * See issue #3250 * See tests/electron/modules/calendar_spec.js */ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "calendar", position: "bottom_bar", config: { maximumEntries: 100, calendars: [ { maximumEntries: 100, maximumNumberOfDays: 28, // 4 weeks, 2 of which are skipped url: "http://localhost:8080/tests/mocks/exdate_la_at_midnight_dst.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/exdate_la_at_midnight_std.js ================================================ /* * MagicMirror² Test calendar exdate * * By jkriegshauser * MIT Licensed. * * See issue #3250 * See tests/electron/modules/calendar_spec.js */ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "calendar", position: "bottom_bar", config: { maximumEntries: 100, calendars: [ { maximumEntries: 100, maximumNumberOfDays: 28, // 4 weeks, 2 of which are skipped url: "http://localhost:8080/tests/mocks/exdate_la_at_midnight_std.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/exdate_la_before_midnight.js ================================================ /* * MagicMirror² Test calendar exdate * * By jkriegshauser * MIT Licensed. * * See issue #3250 * See tests/electron/modules/calendar_spec.js */ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "calendar", position: "bottom_bar", config: { maximumEntries: 100, calendars: [ { maximumEntries: 100, maximumNumberOfDays: 28, // 4 weeks, 2 of which are skipped url: "http://localhost:8080/tests/mocks/exdate_la_before_midnight.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/exdate_syd_at_midnight_dst.js ================================================ /* * MagicMirror² Test calendar exdate * * By jkriegshauser * MIT Licensed. * * See issue #3250 * See tests/electron/modules/calendar_spec.js */ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "calendar", position: "bottom_bar", config: { maximumEntries: 100, calendars: [ { maximumEntries: 100, maximumNumberOfDays: 28, // 4 weeks, 2 of which are skipped url: "http://localhost:8080/tests/mocks/exdate_syd_at_midnight_dst.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/exdate_syd_at_midnight_std.js ================================================ /* * MagicMirror² Test calendar exdate * * By jkriegshauser * MIT Licensed. * * See issue #3250 * See tests/electron/modules/calendar_spec.js */ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "calendar", position: "bottom_bar", config: { maximumEntries: 100, calendars: [ { maximumEntries: 100, maximumNumberOfDays: 28, // 4 weeks, 2 of which are skipped url: "http://localhost:8080/tests/mocks/exdate_syd_at_midnight_std.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/exdate_syd_before_midnight.js ================================================ /* * MagicMirror² Test calendar exdate * * By jkriegshauser * MIT Licensed. * * See issue #3250 * See tests/electron/modules/calendar_spec.js */ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "calendar", position: "bottom_bar", config: { maximumEntries: 100, calendars: [ { maximumEntries: 100, maximumNumberOfDays: 28, // 4 weeks, 2 of which are skipped url: "http://localhost:8080/tests/mocks/exdate_syd_before_midnight.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/fail-basic-auth.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "calendar", position: "bottom_bar", config: { calendars: [ { maximumNumberOfDays: 10000, url: "http://localhost:8020/tests/mocks/calendar_test.ics", auth: { user: "MagicMirror", pass: "StairwayToHeaven", method: "basic" } } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/fullday_event_over_multiple_days_nonrepeating.js ================================================ 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, showEnd: true, calendars: [ { maximumEntries: 100, url: "http://localhost:8080/tests/mocks/fullday_event_over_multiple_days_nonrepeating.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/fullday_until.js ================================================ 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; } ================================================ FILE: tests/configs/modules/calendar/germany_at_end_of_day_repeating.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "calendar", position: "bottom_bar", config: { hideDuplicates: false, maximumEntries: 100, sliceMultiDayEvents: true, dateFormat: "MMM Do, HH:mm", timeFormat: "absolute", getRelative: 0, urgency: 0, calendars: [ { maximumEntries: 100, url: "http://localhost:8080/tests/mocks/germany_at_end_of_day_repeating.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/long-fullday-event.js ================================================ /* * MagicMirror² Test config for fullday calendar entries over multiple days * * By Paranoid93 https://github.com/Paranoid93/ * MIT Licensed. */ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "calendar", position: "bottom_bar", config: { calendars: [ { maximumNumberOfDays: 2, url: "http://localhost:8080/tests/mocks/calendar_test_multi_day_starting_today.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/old-basic-auth.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "calendar", position: "bottom_bar", config: { calendars: [ { maximumNumberOfDays: 10000, url: "http://localhost:8080/tests/mocks/calendar_test.ics", user: "MagicMirror", pass: "CallMeADog" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/recurring.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "calendar", position: "bottom_bar", config: { calendars: [ { maximumEntries: 6, maximumNumberOfDays: 3650, url: "http://localhost:8080/tests/mocks/calendar_test_recurring.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/rrule_until.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "calendar", position: "bottom_bar", config: { hideDuplicates: false, maximumEntries: 100, calendars: [ { maximumEntries: 100, maximumNumberOfDays: 1, // Just today url: "http://localhost:8080/tests/mocks/rrule_until.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/show-duplicates-in-calendar.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "calendar", position: "bottom_bar", config: { maximumEntries: 30, hideDuplicates: false, calendars: [ { maximumEntries: 15, maximumNumberOfDays: 10000, url: "http://localhost:8080/tests/mocks/calendar_test.ics" // contains 11 events }, { maximumEntries: 15, maximumNumberOfDays: 10000, url: "http://localhost:8080/tests/mocks/calendar_test_clone.ics" // clone of upper calendar } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/single-fullday-event.js ================================================ /* * MagicMirror² Test config for fullday calendar entries over multiple days * * By Paranoid93 https://github.com/Paranoid93/ * MIT Licensed. */ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "calendar", position: "bottom_bar", config: { calendars: [ { maximumNumberOfDays: 2, url: "http://localhost:8080/tests/mocks/calendar_test_full_day_events.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/sliceMultiDayEvents.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "calendar", position: "bottom_bar", config: { hideDuplicates: false, maximumEntries: 100, sliceMultiDayEvents: true, calendars: [ { maximumEntries: 100, url: "http://localhost:8080/tests/mocks/sliceMultiDayEvents.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/calendar/symboltest.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "calendar", position: "bottom_bar", config: { maximumEntries: 1, calendars: [ { symbol: ["calendar-check", "google"], url: "http://localhost:8080/tests/mocks/12_events.ics" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/clock/clock_12hr.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "clock", position: "middle_center" } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/clock/clock_24hr.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], modules: [ { module: "clock", position: "middle_center" } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/clock/clock_analog.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], modules: [ { module: "clock", position: "middle_center", config: { displayType: "analog", analogFace: "face-006", showDate: false } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/clock/clock_displaySeconds_false.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "clock", position: "middle_center", config: { displaySeconds: false } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/clock/clock_showDateAnalog.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "clock", position: "middle_center", config: { showTime: true, showDate: true, displayType: "analog" } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/clock/clock_showPeriodUpper.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "clock", position: "middle_center", config: { showPeriodUpper: true } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/clock/clock_showSunMoon.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "clock", position: "middle_center", config: { showSunTimes: true, showMoonTimes: true } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/clock/clock_showSunNoEvent.js ================================================ 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; } ================================================ FILE: tests/configs/modules/clock/clock_showTime.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "clock", position: "middle_center", config: { showTime: false } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/clock/clock_showWeek.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "clock", position: "middle_center", config: { showWeek: true } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/clock/clock_showWeek_short.js ================================================ 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; } ================================================ FILE: tests/configs/modules/clock/de/clock_showWeek.js ================================================ 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; } ================================================ FILE: tests/configs/modules/clock/de/clock_showWeek_short.js ================================================ 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; } ================================================ FILE: tests/configs/modules/clock/es/clock_12hr.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], language: "es", timeFormat: 12, modules: [ { module: "clock", position: "middle_center" } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/clock/es/clock_24hr.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], language: "es", modules: [ { module: "clock", position: "middle_center" } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/clock/es/clock_showPeriodUpper.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], language: "es", timeFormat: 12, modules: [ { module: "clock", position: "middle_center", config: { showPeriodUpper: true } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/clock/es/clock_showWeek.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], language: "es", timeFormat: 12, modules: [ { module: "clock", position: "middle_center", config: { showWeek: true } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/clock/es/clock_showWeek_short.js ================================================ 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; } ================================================ FILE: tests/configs/modules/compliments/compliments_animateCSS.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], modules: [ { module: "compliments", position: "lower_third", animateIn: "flipInX", animateOut: "flipOutX", config: { compliments: { anytime: ["AnimateCSS Testing..."] }, updateInterval: 2000, fadeSpeed: 1000 } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/compliments/compliments_animateCSS_fallbackToDefault.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], modules: [ { module: "compliments", position: "lower_third", animateIn: "foo", animateOut: "bar", config: { compliments: { anytime: ["AnimateCSS Testing..."] }, updateInterval: 2000, fadeSpeed: 1000 } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/compliments/compliments_animateCSS_invertedAnimationName.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], modules: [ { module: "compliments", position: "lower_third", animateIn: "flipOutX", animateOut: "flipInX", config: { compliments: { anytime: ["AnimateCSS Testing..."] }, updateInterval: 2000, fadeSpeed: 1000 } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/compliments/compliments_anytime.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "compliments", position: "middle_center", config: { compliments: { morning: [], afternoon: [], evening: [], anytime: ["Anytime here"] } } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/compliments/compliments_cron_entry.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], modules: [ { module: "compliments", position: "middle_center", config: { specialDayUnique: true, compliments: { anytime: ["just a test"], "00-10 16-19 * * fri": ["just pub time"] } } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/compliments/compliments_date.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "compliments", position: "middle_center", config: { compliments: { morning: [], afternoon: [], evening: [], "....-01-01": ["Happy new year!"] } } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/compliments/compliments_e2e_cron_entry.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], modules: [ { module: "compliments", position: "middle_center", config: { specialDayUnique: true, compliments: { anytime: ["just a test"], "* * * * *": ["anytime cron"] } } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/compliments/compliments_evening.js ================================================ 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; } ================================================ FILE: tests/configs/modules/compliments/compliments_file.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], modules: [ { module: "compliments", position: "bottom_bar", config: { updateInterval: 3000, remoteFile: "http://localhost:8080/tests/mocks/compliments_test.json" } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/compliments/compliments_file_change.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], modules: [ { module: "compliments", position: "bottom_bar", config: { updateInterval: 3000, remoteFileRefreshInterval: 1500, remoteFile: "http://localhost:8080/tests/mocks/compliments_test.json", remoteFile2: "http://localhost:8080/tests/mocks/compliments_file.json" } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/compliments/compliments_only_anytime.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "compliments", position: "middle_center", config: { compliments: { anytime: ["Anytime here"] } } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/compliments/compliments_parts_day.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "compliments", position: "middle_center", config: { compliments: { morning: ["Hi", "Good Morning", "Morning test"], afternoon: ["Hello", "Good Afternoon", "Afternoon test"], evening: ["Hello There", "Good Evening", "Evening test"] } } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/compliments/compliments_remote.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], modules: [ { module: "compliments", position: "middle_center", config: { remoteFile: "http://localhost:8080/tests/mocks/compliments_test.json" } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/compliments/compliments_specialDayUnique_false.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], modules: [ { module: "compliments", position: "middle_center", config: { specialDayUnique: false, compliments: { anytime: [ "Typical message 1", "Typical message 2", "Typical message 3" ], "....-..-..": ["Special day message"] } } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/compliments/compliments_specialDayUnique_true.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], modules: [ { module: "compliments", position: "middle_center", config: { specialDayUnique: true, compliments: { anytime: [ "Typical message 1", "Typical message 2", "Typical message 3" ], "....-..-..": ["Special day message"] } } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/display.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], modules: [ { module: "helloworld", position: "top_bar", header: "test_header", config: { text: "Test Display Header" } }, { module: "helloworld", position: "bottom_bar", config: { text: "Test Hide Header" } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/helloworld/helloworld.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], modules: [ { module: "helloworld", position: "bottom_bar", config: { text: "Test HelloWorld Module" } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/helloworld/helloworld_default.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], modules: [ { module: "helloworld", position: "bottom_bar" } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/newsfeed/default.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "newsfeed", position: "bottom_bar", config: { feeds: [ { title: "Rodrigo Ramirez Blog", url: "http://localhost:8080/tests/mocks/newsfeed_test.xml" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/newsfeed/ignore_items.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "newsfeed", position: "bottom_bar", config: { feeds: [ { title: "Rodrigo Ramirez Blog", url: "http://localhost:8080/tests/mocks/newsfeed_test.xml" } ], ignoreOldItems: true } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/newsfeed/incorrect_url.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "newsfeed", position: "bottom_bar", config: { feeds: [ { title: "Incorrect Url", url: "this is not a valid url" } ] } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/newsfeed/prohibited_words.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "newsfeed", position: "bottom_bar", config: { feeds: [ { title: "Rodrigo Ramirez Blog", url: "http://localhost:8080/tests/mocks/newsfeed_test.xml" } ], prohibitedWords: ["QPanel"], showDescription: true } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/positions.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], modules: // Using exotic content. This is why don't accept go to JSON configuration file (() => { let positions = ["top_bar", "top_left", "top_center", "top_right", "upper_third", "middle_center", "lower_third", "bottom_left", "bottom_center", "bottom_right", "bottom_bar", "fullscreen_above", "fullscreen_below"]; let modules = Array(); for (let idx in positions) { modules.push({ module: "helloworld", position: positions[idx], config: { text: `Text in ${positions[idx]}` } }); } return modules; })() }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/weather/currentweather_compliments.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], modules: [ { module: "compliments", position: "top_bar", config: { compliments: { snow: ["snow"] }, updateInterval: 3000 } }, { module: "weather", position: "bottom_bar", config: { location: "Munich", weatherProvider: "openweathermap", weatherEndpoint: "/weather", mockData: '"#####WEATHERDATA#####"' } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/weather/currentweather_default.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "weather", position: "bottom_bar", config: { location: "Munich", showHumidity: "feelslike", weatherProvider: "openweathermap", weatherEndpoint: "/weather", mockData: '"#####WEATHERDATA#####"' } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/weather/currentweather_options.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], modules: [ { module: "weather", position: "bottom_bar", config: { location: "Munich", weatherProvider: "openweathermap", weatherEndpoint: "/weather", mockData: '"#####WEATHERDATA#####"', windUnits: "beaufort", showWindDirectionAsArrow: true, showSun: false, showHumidity: "wind", roundTemp: true, degreeLabel: true } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/weather/currentweather_units.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], units: "imperial", modules: [ { module: "weather", position: "bottom_bar", config: { location: "Munich", weatherProvider: "openweathermap", weatherEndpoint: "/weather", mockData: '"#####WEATHERDATA#####"', decimalSymbol: ",", showHumidity: "wind" } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/weather/forecastweather_absolute.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "weather", position: "bottom_bar", config: { type: "forecast", location: "Munich", weatherProvider: "openweathermap", weatherEndpoint: "/forecast/daily", mockData: '"#####WEATHERDATA#####"', absoluteDates: true } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/weather/forecastweather_default.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "weather", position: "bottom_bar", config: { type: "forecast", location: "Munich", weatherProvider: "openweathermap", weatherEndpoint: "/forecast/daily", mockData: '"#####WEATHERDATA#####"' } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/weather/forecastweather_options.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "weather", position: "bottom_bar", config: { type: "forecast", location: "Munich", weatherProvider: "openweathermap", weatherEndpoint: "/forecast/daily", mockData: '"#####WEATHERDATA#####"', showPrecipitationAmount: true, colored: true, tableClass: "myTableClass" } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/weather/forecastweather_units.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], units: "imperial", modules: [ { module: "weather", position: "bottom_bar", config: { type: "forecast", location: "Munich", weatherProvider: "openweathermap", weatherEndpoint: "/forecast/daily", mockData: '"#####WEATHERDATA#####"', decimalSymbol: "_", showPrecipitationAmount: true } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/weather/hourlyweather_default.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "weather", position: "bottom_bar", config: { type: "hourly", location: "Berlin", weatherProvider: "openweathermap", weatherEndpoint: "/onecall", mockData: '"#####WEATHERDATA#####"' } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/weather/hourlyweather_options.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "weather", position: "bottom_bar", config: { type: "hourly", location: "Berlin", weatherProvider: "openweathermap", weatherEndpoint: "/onecall", mockData: '"#####WEATHERDATA#####"', hourlyForecastIncrements: 2 } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/modules/weather/hourlyweather_showPrecipitation.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [], timeFormat: 12, modules: [ { module: "weather", position: "bottom_bar", config: { type: "hourly", location: "Berlin", weatherProvider: "openweathermap", weatherEndpoint: "/onecall", mockData: '"#####WEATHERDATA#####"', showPrecipitationAmount: true, showPrecipitationProbability: true } } ] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/noIpWhiteList.js ================================================ let config = require(`${process.cwd()}/tests/configs/default.js`).configFactory({ ipWhitelist: ["x.x.x.x"], port: 8181 }); /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/port_8090.js ================================================ let config = require(`${process.cwd()}/tests/configs/default.js`).configFactory({ port: 8090 }); /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/port_variable.env ================================================ MM_PORT=8090 ================================================ FILE: tests/configs/port_variable.js.template ================================================ let config = require(`${process.cwd()}/tests/configs/default.js`).configFactory({ port: ${MM_PORT} }); /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/configs/without_modules.js ================================================ let config = { address: "0.0.0.0", ipWhitelist: [] }; /*************** DO NOT EDIT THE LINE BELOW ***************/ if (typeof module !== "undefined") { module.exports = config; } ================================================ FILE: tests/e2e/animateCSS_spec.js ================================================ const { expect } = require("playwright/test"); const helpers = require("./helpers/global-setup"); // Validate Animate.css integration for compliments module using class toggling. // We intentionally ignore computed animation styles (jsdom doesn't simulate real animations). describe("AnimateCSS integration Test", () => { let page; // Config variants under test const TEST_CONFIG_ANIM = "tests/configs/modules/compliments/compliments_animateCSS.js"; const TEST_CONFIG_FALLBACK = "tests/configs/modules/compliments/compliments_animateCSS_fallbackToDefault.js"; // invalid animation names const TEST_CONFIG_INVERTED = "tests/configs/modules/compliments/compliments_animateCSS_invertedAnimationName.js"; // in/out swapped const TEST_CONFIG_NONE = "tests/configs/modules/compliments/compliments_anytime.js"; // no animations defined /** * Get the compliments container element (waits until available). * @returns {Promise} */ async function getComplimentsElement () { await helpers.getDocument(); page = helpers.getPage(); await expect(page.locator(".compliments")).toBeVisible(); } /** * Wait for an Animate.css class to appear and persist briefly. * @param {string} cls Animation class name without leading dot (e.g. animate__flipInX) * @param {{timeout?: number}} [options] Poll timeout in ms (default 6000) * @returns {Promise} */ async function waitForAnimationClass (cls, { timeout = 6000 } = {}) { const locator = page.locator(`.compliments.animate__animated.${cls}`); await locator.waitFor({ state: "attached", timeout }); // small stability wait await new Promise((r) => setTimeout(r, 50)); await expect(locator).toBeAttached(); } /** * Assert that no Animate.css animation class is applied within a time window. * @param {number} [ms] Observation period in ms (default 2000) * @returns {Promise} */ async function assertNoAnimationWithin (ms = 2000) { const start = Date.now(); const locator = page.locator(".compliments.animate__animated"); while (Date.now() - start < ms) { const count = await locator.count(); if (count > 0) { throw new Error("Unexpected animate__animated class present in non-animation scenario"); } await new Promise((r) => setTimeout(r, 100)); } } /** * Run one animation test scenario. * @param {string} [animationIn] Expected animate-in name * @param {string} [animationOut] Expected animate-out name * @returns {Promise} Throws on assertion failure */ async function runAnimationTest (animationIn, animationOut) { await getComplimentsElement(); if (!animationIn && !animationOut) { await assertNoAnimationWithin(2000); return; } if (animationIn) await waitForAnimationClass(`animate__${animationIn}`); if (animationOut) { // Wait just beyond one update cycle (updateInterval=2000ms) before expecting animateOut. await new Promise((r) => setTimeout(r, 2100)); await waitForAnimationClass(`animate__${animationOut}`); } } afterEach(async () => { await helpers.stopApplication(); }); describe("animateIn and animateOut Test", () => { it("with flipInX and flipOutX animation", async () => { await helpers.startApplication(TEST_CONFIG_ANIM); await runAnimationTest("flipInX", "flipOutX"); }); }); describe("use animateOut name for animateIn (vice versa) Test", () => { it("without animation (inverted names)", async () => { await helpers.startApplication(TEST_CONFIG_INVERTED); await runAnimationTest(); }); }); describe("false Animation name test", () => { it("without animation (invalid names)", async () => { await helpers.startApplication(TEST_CONFIG_FALLBACK); await runAnimationTest(); }); }); describe("no Animation defined test", () => { it("without animation (no config)", async () => { await helpers.startApplication(TEST_CONFIG_NONE); await runAnimationTest(); }); }); }); ================================================ FILE: tests/e2e/custom_module_regions_spec.js ================================================ const { expect } = require("playwright/test"); const helpers = require("./helpers/global-setup"); describe("Custom Position of modules", () => { let page; beforeAll(async () => { await helpers.fixupIndex(); await helpers.startApplication("tests/configs/customregions.js"); await helpers.getDocument(); page = helpers.getPage(); }); afterAll(async () => { await helpers.stopApplication(); await helpers.restoreIndex(); }); const positions = ["row3_left", "top3_left1"]; let i = 0; const className1 = positions[i].replace("_", "."); let message1 = positions[i]; it(`should show text in ${message1}`, async () => { await expect(page.locator(`.${className1} .module-content`)).toContainText(`Text in ${message1}`); }); i = 1; const className2 = positions[i].replace("_", "."); let message2 = positions[i]; it(`should NOT show text in ${message2}`, async () => { await expect(page.locator(`.${className2} .module-content`)).toHaveCount(0); }); }); ================================================ FILE: tests/e2e/env_spec.js ================================================ const { expect } = require("playwright/test"); const helpers = require("./helpers/global-setup"); describe("App environment", () => { let page; beforeAll(async () => { await helpers.startApplication("tests/configs/default.js"); await helpers.getDocument(); page = helpers.getPage(); }); afterAll(async () => { await helpers.stopApplication(); }); it("get request from http://localhost:8080 should return 200", async () => { const res = await fetch("http://localhost:8080"); expect(res.status).toBe(200); }); it("get request from http://localhost:8080/nothing should return 404", async () => { const res = await fetch("http://localhost:8080/nothing"); expect(res.status).toBe(404); }); it("should show the title MagicMirror²", async () => { await expect(page).toHaveTitle("MagicMirror²"); }); }); ================================================ FILE: tests/e2e/fonts_spec.js ================================================ 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(`${global.root_path}/css/roboto.css`, "utf8"); const regex = /\burl\(['"]([^'"]+)['"]\)/g; let match = regex.exec(fileContent); while (match !== null) { // Push 1st match group onto fontFiles stack fontFiles.push(match[1]); // Find the next one match = regex.exec(fileContent); } beforeAll(async () => { await helpers.startApplication("tests/configs/without_modules.js"); }); afterAll(async () => { await helpers.stopApplication(); }); it.each(fontFiles)("should return 200 HTTP code for file '%s'", async (fontFile) => { const fontUrl = `http://localhost:8080/fonts/${fontFile}`; const res = await fetch(fontUrl); expect(res.status).toBe(200); }); }); ================================================ FILE: tests/e2e/helpers/basic-auth.js ================================================ const path = require("node:path"); const auth = require("express-basic-auth"); const express = require("express"); const app = express(); const basicAuth = auth({ realm: "MagicMirror² Area restricted.", users: { MagicMirror: "CallMeADog" } }); app.use(basicAuth); // Set available directories const directories = ["/tests/configs", "/tests/mocks"]; for (let directory of directories) { app.use(directory, express.static(path.resolve(`${global.root_path}/${directory}`))); } let server; exports.listen = (...args) => { server = app.listen.apply(app, args); }; exports.close = async () => { await server.close(); }; ================================================ FILE: tests/e2e/helpers/global-setup.js ================================================ const path = require("node:path"); const os = require("node:os"); const fs = require("node:fs"); const { chromium } = require("playwright"); // global absolute root path global.root_path = path.resolve(`${__dirname}/../../../`); const indexFile = `${global.root_path}/index.html`; const cssFile = `${global.root_path}/css/custom.css`; const sampleCss = [ ".region.row3 {", " top: 0;", "}", ".region.row3.left {", " top: 100%;", "}" ]; let indexData = ""; let cssData = ""; let browser; let context; let page; /** * Ensure Playwright browser and context are available. * @returns {Promise} */ async function ensureContext () { if (!browser) { // Additional args for CI stability to prevent crashes const launchOptions = { headless: true, args: [ "--disable-dev-shm-usage", // Overcome limited resource problems in Docker/CI "--disable-gpu", // Disable GPU hardware acceleration "--no-sandbox", // Required for running as root in some CI environments "--disable-setuid-sandbox", "--single-process" // Run in single process mode for better stability in CI ] }; browser = await chromium.launch(launchOptions); } if (!context) { context = await browser.newContext(); } } /** * Open a fresh page pointing to the provided url. * @param {string} url target url * @returns {Promise} initialized page instance */ async function openPage (url) { await ensureContext(); if (page) { await page.close(); } page = await context.newPage(); await page.goto(url, { waitUntil: "load" }); return page; } /** * Close page, context and browser if they exist. * @returns {Promise} */ async function closeBrowser () { if (page) { await page.close(); page = null; } if (context) { await context.close(); context = null; } if (browser) { await browser.close(); browser = null; } } exports.getPage = () => { if (!page) { throw new Error("Playwright page is not initialized. Call getDocument() first."); } return page; }; exports.startApplication = async (configFilename, exec) => { vi.resetModules(); // Clear Node's require cache for config and app files to prevent stale configs and middlewares Object.keys(require.cache).forEach((key) => { if ( key.includes("/tests/configs/") || key.includes("/config/config") || key.includes("/js/app.js") || key.includes("/js/server.js") ) { delete require.cache[key]; } }); if (global.app) { await exports.stopApplication(); } // Use fixed port 8080 (tests run sequentially, no conflicts) const port = 8080; global.testPort = port; // Set config sample for use in test let configPath; if (configFilename === "") { configPath = "config/config.js"; } else { configPath = configFilename; } process.env.MM_CONFIG_FILE = configPath; // Override port in config - MUST be set before app loads process.env.MM_PORT = port.toString(); process.env.mmTestMode = "true"; process.setMaxListeners(0); if (exec) exec; global.app = require(`${global.root_path}/js/app`); return global.app.start(); }; exports.stopApplication = async (waitTime = 100) => { await closeBrowser(); if (!global.app) { delete global.testPort; return Promise.resolve(); } await global.app.stop(); delete global.app; delete global.testPort; // Wait for any pending async operations to complete before closing DOM await new Promise((resolve) => setTimeout(resolve, waitTime)); }; exports.getDocument = async () => { const port = global.testPort || config.port || 8080; const address = config.address === "0.0.0.0" ? "localhost" : config.address || "localhost"; const url = `http://${address}:${port}`; await openPage(url); }; exports.fixupIndex = async () => { // read and save the git level index file indexData = (await fs.promises.readFile(indexFile)).toString(); // make lines of the content const workIndexLines = indexData.split(os.EOL); // loop thru the lines to find place to insert new region for (let l in workIndexLines) { if (workIndexLines[l].includes("region top right")) { // insert a new line with new region definition workIndexLines.splice(l, 0, "
"); break; } } // write out the new index.html file, not append await fs.promises.writeFile(indexFile, workIndexLines.join(os.EOL), { flush: true }); // read in the current custom.css cssData = (await fs.promises.readFile(cssFile)).toString(); // write out the custom.css for this testcase, matching the new region name await fs.promises.writeFile(cssFile, sampleCss.join(os.EOL), { flush: true }); }; exports.restoreIndex = async () => { // if we read in data if (indexData.length > 0) { //write out saved index.html await fs.promises.writeFile(indexFile, indexData, { flush: true }); // write out saved custom.css await fs.promises.writeFile(cssFile, cssData, { flush: true }); } }; ================================================ FILE: tests/e2e/helpers/weather-functions.js ================================================ const { injectMockData, cleanupMockData } = require("../../utils/weather_mocker"); const helpers = require("./global-setup"); exports.startApplication = async (configFileName, additionalMockData) => { await helpers.startApplication(injectMockData(configFileName, additionalMockData)); await helpers.getDocument(); }; exports.stopApplication = async () => { await helpers.stopApplication(); cleanupMockData(); }; ================================================ FILE: tests/e2e/ipWhitelist_spec.js ================================================ const helpers = require("./helpers/global-setup"); describe("ipWhitelist directive configuration", () => { describe("When IP is not in whitelist", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/noIpWhiteList.js"); }); afterAll(async () => { await helpers.stopApplication(); }); it("should reject request with 403 (Forbidden)", async () => { const port = global.testPort || 8080; const res = await fetch(`http://localhost:${port}`); expect(res.status).toBe(403); }); }); describe("When whitelist is empty (allow all IPs)", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/empty_ipWhiteList.js"); }); afterAll(async () => { await helpers.stopApplication(); }); it("should allow request with 200 (OK)", async () => { const port = global.testPort || 8080; const res = await fetch(`http://localhost:${port}`); expect(res.status).toBe(200); }); }); }); ================================================ FILE: tests/e2e/modules/alert_spec.js ================================================ const { expect } = require("playwright/test"); const helpers = require("../helpers/global-setup"); describe("Alert module", () => { let page; afterAll(async () => { await helpers.stopApplication(); }); describe("with welcome_message set to false", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/alert/welcome_false.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should not show any welcome message", async () => { // Wait a bit to ensure no message appears await new Promise((resolve) => setTimeout(resolve, 1000)); // Check that no alert/notification elements are present await expect(page.locator(".ns-box .ns-box-inner .light.bright.small")).toHaveCount(0); }); }); describe("with welcome_message set to true", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/alert/welcome_true.js"); await helpers.getDocument(); page = helpers.getPage(); // Wait for the application to initialize await new Promise((resolve) => setTimeout(resolve, 1000)); }); it("should show the translated welcome message", async () => { await expect(page.locator(".ns-box .ns-box-inner .light.bright.small")).toContainText("Welcome, start was successful!"); }); }); describe("with welcome_message set to custom string", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/alert/welcome_string.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should show the custom welcome message", async () => { await expect(page.locator(".ns-box .ns-box-inner .light.bright.small")).toContainText("Custom welcome message!"); }); }); }); ================================================ FILE: tests/e2e/modules/calendar_spec.js ================================================ const { expect } = require("playwright/test"); const helpers = require("../helpers/global-setup"); const serverBasicAuth = require("../helpers/basic-auth"); describe("Calendar module", () => { let page; /** * Assert the number of matching elements. * @param {string} selector css selector * @param {number} expectedLength expected number of elements * @param {string} [not] optional negation marker (use "not" to negate) * @returns {Promise} */ const testElementLength = async (selector, expectedLength, not) => { const locator = page.locator(selector); if (not === "not") { await expect(locator).not.toHaveCount(expectedLength); } else { await expect(locator).toHaveCount(expectedLength); } }; const testTextContain = async (selector, expectedText) => { await expect(page.locator(selector).first()).toContainText(expectedText); }; afterAll(async () => { await helpers.stopApplication(); }); describe("Default configuration", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/calendar/default.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should show the default maximumEntries of 10", async () => { await testElementLength(".calendar .event", 10); }); it("should show the default calendar symbol in each event", async () => { await testElementLength(".calendar .event .fa-calendar-days", 0, "not"); }); }); describe("Custom configuration", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/calendar/custom.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should show the custom maximumEntries of 5", async () => { await testElementLength(".calendar .event", 5); }); it("should show the custom calendar symbol in four events", async () => { await testElementLength(".calendar .event .fa-birthday-cake", 4); }); it("should show a customEvent calendar symbol in one event", async () => { await testElementLength(".calendar .event .fa-dice", 1); }); it("should show a customEvent calendar eventClass in one event", async () => { await testElementLength(".calendar .event.undo", 1); }); it("should show two custom icons for repeating events", async () => { await testElementLength(".calendar .event .fa-undo", 2); }); it("should show two custom icons for day events", async () => { await testElementLength(".calendar .event .fa-calendar-day", 2); }); }); describe("Recurring event", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/calendar/recurring.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should show the recurring birthday event 6 times", async () => { await testElementLength(".calendar .event", 6); }); }); //Will contain everyday an fullDayEvent that starts today and ends tomorrow, and one starting tomorrow and ending the day after tomorrow describe("FullDayEvent over several days should show how many days are left from the from the starting date on", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/calendar/long-fullday-event.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should contain text 'Ends in' with the left days", async () => { await testTextContain(".calendar .today .time", "Ends in"); await testTextContain(".calendar .yesterday .time", "Today"); await testTextContain(".calendar .tomorrow .time", "Tomorrow"); }); it("should contain in total three events", async () => { await testElementLength(".calendar .event", 3); }); }); describe("FullDayEvent Single day, should show Today", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/calendar/single-fullday-event.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should contain text 'Today'", async () => { await testTextContain(".calendar .time", "Today"); }); it("should contain in total two events", async () => { await testElementLength(".calendar .event", 2); }); }); describe("Changed port", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/calendar/changed-port.js"); serverBasicAuth.listen(8010); await helpers.getDocument(); page = helpers.getPage(); }); afterAll(async () => { await serverBasicAuth.close(); }); it("should return TestEvents", async () => { await testElementLength(".calendar .event", 0, "not"); }); }); describe("Basic auth", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/calendar/basic-auth.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should return TestEvents", async () => { await testElementLength(".calendar .event", 0, "not"); }); }); describe("Basic auth by default", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/calendar/auth-default.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should return TestEvents", async () => { await testElementLength(".calendar .event", 0, "not"); }); }); describe("Basic auth backward compatibility configuration: DEPRECATED", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/calendar/old-basic-auth.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should return TestEvents", async () => { await testElementLength(".calendar .event", 0, "not"); }); }); describe("Fail Basic auth", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/calendar/fail-basic-auth.js"); serverBasicAuth.listen(8020); await helpers.getDocument(); page = helpers.getPage(); }); afterAll(async () => { await serverBasicAuth.close(); }); it("should show Unauthorized error", async () => { await testTextContain(".calendar", "Error in the calendar module. Authorization failed"); }); }); }); ================================================ FILE: tests/e2e/modules/clock_de_spec.js ================================================ const { expect } = require("playwright/test"); const helpers = require("../helpers/global-setup"); describe("Clock set to german language module", () => { let page; 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(); page = helpers.getPage(); }); it("shows week with correct format", async () => { const weekRegex = /^[0-9]{1,2}. Kalenderwoche$/; await expect(page.locator(".clock .week")).toHaveText(weekRegex); }); }); describe("with showWeek short config enabled", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/clock/de/clock_showWeek_short.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("shows week with correct format", async () => { const weekRegex = /^[0-9]{1,2}KW$/; await expect(page.locator(".clock .week")).toHaveText(weekRegex); }); }); }); ================================================ FILE: tests/e2e/modules/clock_es_spec.js ================================================ const { expect } = require("playwright/test"); const helpers = require("../helpers/global-setup"); describe("Clock set to spanish language module", () => { let page; afterAll(async () => { await helpers.stopApplication(); }); describe("with default 24hr clock config", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/clock/es/clock_24hr.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("shows date with correct format", async () => { const dateRegex = /^(?:lunes|martes|miércoles|jueves|viernes|sábado|domingo), \d{1,2} de (?:enero|febrero|marzo|abril|mayo|junio|julio|agosto|septiembre|octubre|noviembre|diciembre) de \d{4}$/; await expect(page.locator(".clock .date")).toHaveText(dateRegex); }); it("shows time in 24hr format", async () => { const timeRegex = /^(?:2[0-3]|[01]\d):[0-5]\d[0-5]\d$/; await expect(page.locator(".clock .time")).toHaveText(timeRegex); }); }); describe("with default 12hr clock config", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/clock/es/clock_12hr.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("shows date with correct format", async () => { const dateRegex = /^(?:lunes|martes|miércoles|jueves|viernes|sábado|domingo), \d{1,2} de (?:enero|febrero|marzo|abril|mayo|junio|julio|agosto|septiembre|octubre|noviembre|diciembre) de \d{4}$/; await expect(page.locator(".clock .date")).toHaveText(dateRegex); }); it("shows time in 12hr format", async () => { const timeRegex = /^(?:1[0-2]|[1-9]):[0-5]\d[0-5]\d[ap]m$/; await expect(page.locator(".clock .time")).toHaveText(timeRegex); }); }); describe("with showPeriodUpper config enabled", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/clock/es/clock_showPeriodUpper.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("shows 12hr time with upper case AM/PM", async () => { const timeRegex = /^(?:1[0-2]|[1-9]):[0-5]\d[0-5]\d[AP]M$/; await expect(page.locator(".clock .time")).toHaveText(timeRegex); }); }); describe("with showWeek config enabled", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/clock/es/clock_showWeek.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("shows week with correct format", async () => { const weekRegex = /^Semana [0-9]{1,2}$/; await expect(page.locator(".clock .week")).toHaveText(weekRegex); }); }); describe("with showWeek short config enabled", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/clock/es/clock_showWeek_short.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("shows week with correct format", async () => { const weekRegex = /^S[0-9]{1,2}$/; await expect(page.locator(".clock .week")).toHaveText(weekRegex); }); }); }); ================================================ FILE: tests/e2e/modules/clock_spec.js ================================================ const { expect } = require("playwright/test"); const moment = require("moment"); const helpers = require("../helpers/global-setup"); describe("Clock module", () => { let page; afterAll(async () => { await helpers.stopApplication(); }); describe("with default 24hr clock config", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/clock/clock_24hr.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should show the date in the correct format", async () => { const dateRegex = /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (?:January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2}, \d{4}$/; await expect(page.locator(".clock .date")).toHaveText(dateRegex); }); it("should show the time in 24hr format", async () => { const timeRegex = /^(?:2[0-3]|[01]\d):[0-5]\d[0-5]\d$/; await expect(page.locator(".clock .time")).toHaveText(timeRegex); }); }); describe("with default 12hr clock config", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/clock/clock_12hr.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should show the date in the correct format", async () => { const dateRegex = /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (?:January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2}, \d{4}$/; await expect(page.locator(".clock .date")).toHaveText(dateRegex); }); it("should show the time in 12hr format", async () => { const timeRegex = /^(?:1[0-2]|[1-9]):[0-5]\d[0-5]\d[ap]m$/; await expect(page.locator(".clock .time")).toHaveText(timeRegex); }); it("check for discreet elements of clock", async () => { await expect(page.locator(".clock-hour-digital")).toBeVisible(); await expect(page.locator(".clock-minute-digital")).toBeVisible(); }); }); describe("with showPeriodUpper config enabled", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/clock/clock_showPeriodUpper.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should show 12hr time with upper case AM/PM", async () => { const timeRegex = /^(?:1[0-2]|[1-9]):[0-5]\d[0-5]\d[AP]M$/; await expect(page.locator(".clock .time")).toHaveText(timeRegex); }); }); describe("with displaySeconds config disabled", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/clock/clock_displaySeconds_false.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should show 12hr time without seconds am/pm", async () => { const timeRegex = /^(?:1[0-2]|[1-9]):[0-5]\d[ap]m$/; await expect(page.locator(".clock .time")).toHaveText(timeRegex); }); }); describe("with showTime config disabled", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/clock/clock_showTime.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should not show the time when digital clock is shown", async () => { await expect(page.locator(".clock .digital .time")).toHaveCount(0); }); }); describe("with showSun/MoonTime enabled", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/clock/clock_showSunMoon.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should show the sun times", async () => { await expect(page.locator(".clock .digital .sun")).toBeVisible(); await expect(page.locator(".clock .digital .sun .fas.fa-sun")).toBeVisible(); }); it("should show the moon times", async () => { await expect(page.locator(".clock .digital .moon")).toBeVisible(); }); }); describe("with showSunNextEvent disabled", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/clock/clock_showSunNoEvent.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should show the sun times", async () => { await expect(page.locator(".clock .digital .sun")).toBeVisible(); await expect(page.locator(".clock .digital .sun .fas.fa-sun")).toHaveCount(0); }); }); describe("with showWeek config enabled", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/clock/clock_showWeek.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should show the week in the correct format", async () => { const weekRegex = /^Week [0-9]{1,2}$/; await expect(page.locator(".clock .week")).toHaveText(weekRegex); }); it("should show the week with the correct number of week of year", async () => { const currentWeekNumber = moment().week(); const weekToShow = `Week ${currentWeekNumber}`; await expect(page.locator(".clock .week")).toHaveText(weekToShow); }); }); describe("with showWeek short config enabled", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/clock/clock_showWeek_short.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should show the week in the correct format", async () => { const weekRegex = /^W[0-9]{1,2}$/; await expect(page.locator(".clock .week")).toHaveText(weekRegex); }); it("should show the week with the correct number of week of year", async () => { const currentWeekNumber = moment().week(); const weekToShow = `W${currentWeekNumber}`; await expect(page.locator(".clock .week")).toHaveText(weekToShow); }); }); describe("with analog clock face enabled", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/clock/clock_analog.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should show the analog clock face", async () => { await expect(page.locator(".clock-circle")).toBeVisible(); }); }); describe("with analog clock face and date enabled", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/clock/clock_showDateAnalog.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should show the analog clock face and the date", async () => { await expect(page.locator(".clock-circle")).toBeVisible(); await expect(page.locator(".clock .date")).toBeVisible(); }); }); }); ================================================ FILE: tests/e2e/modules/compliments_spec.js ================================================ const { expect } = require("playwright/test"); const helpers = require("../helpers/global-setup"); describe("Compliments module", () => { let page; /** * move similar tests in function doTest * @param {Array} complimentsArray The array of compliments. * @returns {Promise} */ const doTest = async (complimentsArray) => { await expect(page.locator(".compliments")).toBeVisible(); const contentLocator = page.locator(".module-content"); await contentLocator.waitFor({ state: "visible" }); const content = await contentLocator.textContent(); expect(complimentsArray).toContain(content); }; afterAll(async () => { await helpers.stopApplication(); }); describe("Feature anytime in compliments module", () => { describe("Set anytime and empty compliments for morning, evening and afternoon", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/compliments/compliments_anytime.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("shows anytime because if configure empty parts of day compliments and set anytime compliments", async () => { await doTest(["Anytime here"]); }); }); describe("Only anytime present in configuration compliments", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/compliments/compliments_only_anytime.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("shows anytime compliments", async () => { await doTest(["Anytime here"]); }); }); }); describe("remoteFile option", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/compliments/compliments_remote.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should show compliments from a remote file", async () => { await doTest(["Remote compliment file works!"]); }); }); describe("Feature specialDayUnique in compliments module", () => { describe("specialDayUnique is false", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/compliments/compliments_specialDayUnique_false.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("compliments array can contain all values", async () => { await doTest(["Special day message", "Typical message 1", "Typical message 2", "Typical message 3"]); }); }); describe("specialDayUnique is true", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/compliments/compliments_specialDayUnique_true.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("compliments array contains only special value", async () => { await doTest(["Special day message"]); }); }); describe("cron type key", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/compliments/compliments_e2e_cron_entry.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("compliments array contains only special value", async () => { await doTest(["anytime cron"]); }); }); }); describe("Feature remote compliments file", () => { describe("get list from remote file", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/compliments/compliments_file.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("shows 'Remote compliment file works!' as only anytime list set", async () => { //await helpers.startApplication("tests/configs/modules/compliments/compliments_file.js", "01 Jan 2022 10:00:00 GMT"); await doTest(["Remote compliment file works!"]); }); // afterAll(async () =>{ // await helpers.stopApplication() // }); }); describe("get list from remote file w update", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/compliments/compliments_file_change.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("shows 'test in morning' as test time set to 10am", async () => { //await helpers.startApplication("tests/configs/modules/compliments/compliments_file_change.js", "01 Jan 2022 10:00:00 GMT"); await doTest(["Remote compliment file works!"]); await new Promise((r) => setTimeout(r, 10000)); await doTest(["test in morning"]); }); // afterAll(async () =>{ // await helpers.stopApplication() // }); }); }); }); ================================================ FILE: tests/e2e/modules/helloworld_spec.js ================================================ const { expect } = require("playwright/test"); const helpers = require("../helpers/global-setup"); describe("Test helloworld module", () => { let page; afterAll(async () => { await helpers.stopApplication(); }); describe("helloworld set config text", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/helloworld/helloworld.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("Test message helloworld module", async () => { await expect(page.locator(".helloworld")).toContainText("Test HelloWorld Module"); }); }); describe("helloworld default config text", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/helloworld/helloworld_default.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("Test message helloworld module", async () => { await expect(page.locator(".helloworld")).toContainText("Hello World!"); }); }); }); ================================================ FILE: tests/e2e/modules/newsfeed_spec.js ================================================ const fs = require("node:fs"); const { expect } = require("playwright/test"); const helpers = require("../helpers/global-setup"); const runTests = async () => { let page; describe("Default configuration", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/newsfeed/default.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should show the newsfeed title", async () => { await expect(page.locator(".newsfeed .newsfeed-source")).toContainText("Rodrigo Ramirez Blog"); }); it("should show the newsfeed article", async () => { await expect(page.locator(".newsfeed .newsfeed-title")).toContainText("QPanel"); }); it("should NOT show the newsfeed description", async () => { await page.locator(".newsfeed").waitFor({ state: "visible" }); await expect(page.locator(".newsfeed .newsfeed-desc")).toHaveCount(0); }); }); describe("Custom configuration", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/newsfeed/prohibited_words.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should not show articles with prohibited words", async () => { await expect(page.locator(".newsfeed .newsfeed-title")).toContainText("Problema VirtualBox"); }); it("should show the newsfeed description", async () => { const locator = page.locator(".newsfeed .newsfeed-desc"); await expect(locator).toBeVisible(); const text = await locator.textContent(); expect(text).toMatch(/\S/); }); }); describe("Invalid configuration", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/newsfeed/incorrect_url.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should show malformed url warning", async () => { await expect(page.locator(".newsfeed .small")).toContainText("Error in the Newsfeed module. Malformed url."); }); }); describe("Ignore items", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/newsfeed/ignore_items.js"); await helpers.getDocument(); page = helpers.getPage(); }); it("should show empty items info message", async () => { await expect(page.locator(".newsfeed .small")).toContainText("No news at the moment."); }); }); }; describe("Newsfeed module", () => { afterAll(async () => { await helpers.stopApplication(); }); runTests(); }); describe("Newsfeed module located in config directory", () => { beforeAll(() => { fs.cpSync(`${global.root_path}/modules/default/newsfeed`, `${global.root_path}/config/newsfeed`, { recursive: true }); process.env.MM_MODULES_DIR = "config"; }); afterAll(async () => { await helpers.stopApplication(); }); runTests(); }); ================================================ FILE: tests/e2e/modules/weather_current_spec.js ================================================ const { expect } = require("playwright/test"); const helpers = require("../helpers/global-setup"); const weatherFunc = require("../helpers/weather-functions"); describe("Weather module", () => { let page; afterAll(async () => { await weatherFunc.stopApplication(); }); describe("Current weather", () => { describe("Default configuration", () => { beforeAll(async () => { await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_default.js", {}); page = helpers.getPage(); }); it("should render wind speed and wind direction", async () => { await expect(page.locator(".weather .normal.medium span:nth-child(2)")).toHaveText("12 WSW"); }); it("should render temperature with icon", async () => { await expect(page.locator(".weather .large span.light.bright")).toHaveText("1.5°"); await expect(page.locator(".weather .large span.weathericon")).toBeVisible(); }); it("should render feels like temperature", async () => { // Template contains   which renders as \xa0 await expect(page.locator(".weather .normal.medium.feelslike span.dimmed")).toHaveText("93.7\xa0 Feels like -5.6°"); }); it("should render humidity next to feels-like", async () => { await expect(page.locator(".weather .normal.medium.feelslike span.dimmed .humidity")).toHaveText("93.7"); }); }); }); describe("Compliments Integration", () => { beforeAll(async () => { await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_compliments.js", {}); page = helpers.getPage(); }); it("should render a compliment based on the current weather", async () => { const compliment = page.locator(".compliments .module-content span"); await compliment.waitFor({ state: "visible" }); await expect(compliment).toHaveText("snow"); }); }); describe("Configuration Options", () => { beforeAll(async () => { await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_options.js", {}); page = helpers.getPage(); }); it("should render windUnits in beaufort", async () => { await expect(page.locator(".weather .normal.medium span:nth-child(2)")).toHaveText("6"); }); it("should render windDirection with an arrow", async () => { const arrow = page.locator(".weather .normal.medium sup i.fa-long-arrow-alt-down"); await expect(arrow).toHaveAttribute("style", "transform:rotate(250deg)"); }); it("should render humidity next to wind", async () => { await expect(page.locator(".weather .normal.medium .humidity")).toHaveText("93.7"); }); it("should render degreeLabel for temp", async () => { await expect(page.locator(".weather .large span.bright.light")).toHaveText("1°C"); }); it("should render degreeLabel for feels like", async () => { await expect(page.locator(".weather .normal.medium.feelslike span.dimmed")).toHaveText("Feels like -6°C"); }); }); describe("Current weather with imperial units", () => { beforeAll(async () => { await weatherFunc.startApplication("tests/configs/modules/weather/currentweather_units.js", {}); page = helpers.getPage(); }); it("should render wind in imperial units", async () => { await expect(page.locator(".weather .normal.medium span:nth-child(2)")).toHaveText("26 WSW"); }); it("should render temperatures in fahrenheit", async () => { await expect(page.locator(".weather .large span.bright.light")).toHaveText("34,7°"); }); it("should render 'feels like' in fahrenheit", async () => { await expect(page.locator(".weather .normal.medium.feelslike span.dimmed")).toHaveText("Feels like 21,9°"); }); }); }); ================================================ FILE: tests/e2e/modules/weather_forecast_spec.js ================================================ const { expect } = require("playwright/test"); const helpers = require("../helpers/global-setup"); const weatherFunc = require("../helpers/weather-functions"); describe("Weather module: Weather Forecast", () => { let page; afterAll(async () => { await weatherFunc.stopApplication(); }); describe("Default configuration", () => { beforeAll(async () => { await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_default.js", {}); page = helpers.getPage(); }); const days = ["Today", "Tomorrow", "Sun", "Mon", "Tue"]; for (const [index, day] of days.entries()) { it(`should render day ${day}`, async () => { const dayCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(1)`); await expect(dayCell).toHaveText(day); }); } const icons = ["day-cloudy", "rain", "day-sunny", "day-sunny", "day-sunny"]; for (const [index, icon] of icons.entries()) { it(`should render icon ${icon}`, async () => { const iconElement = page.locator(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(2) span.wi-${icon}`); await expect(iconElement).toBeVisible(); }); } const maxTemps = ["24.4°", "21.0°", "22.9°", "23.4°", "20.6°"]; for (const [index, temp] of maxTemps.entries()) { it(`should render max temperature ${temp}`, async () => { const maxTempCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(3)`); await expect(maxTempCell).toHaveText(temp); }); } const minTemps = ["15.3°", "13.6°", "13.8°", "13.9°", "10.9°"]; for (const [index, temp] of minTemps.entries()) { it(`should render min temperature ${temp}`, async () => { const minTempCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(4)`); await expect(minTempCell).toHaveText(temp); }); } const opacities = [1, 1, 0.8, 0.5333333333333333, 0.2666666666666667]; for (const [index, opacity] of opacities.entries()) { it(`should render fading of rows with opacity=${opacity}`, async () => { const row = page.locator(`.weather table.small tr:nth-child(${index + 1})`); await expect(row).toHaveAttribute("style", `opacity: ${opacity};`); }); } }); describe("Absolute configuration", () => { beforeAll(async () => { await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_absolute.js", {}); page = helpers.getPage(); }); const days = ["Fri", "Sat", "Sun", "Mon", "Tue"]; for (const [index, day] of days.entries()) { it(`should render day ${day}`, async () => { const dayCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(1)`); await expect(dayCell).toHaveText(day); }); } }); describe("Configuration Options", () => { beforeAll(async () => { await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_options.js", {}); page = helpers.getPage(); }); it("should render custom table class", async () => { await expect(page.locator(".weather table.myTableClass")).toBeVisible(); }); it("should render colored rows", async () => { const rows = page.locator(".weather table.myTableClass tr"); await expect(rows).toHaveCount(5); }); const precipitations = [undefined, "2.51 mm"]; for (const [index, precipitation] of precipitations.entries()) { if (precipitation) { it(`should render precipitation amount ${precipitation}`, async () => { const precipCell = page.locator(`.weather table tr:nth-child(${index + 1}) td.precipitation-amount`); await expect(precipCell).toHaveText(precipitation); }); } } }); describe("Forecast weather with imperial units", () => { beforeAll(async () => { await weatherFunc.startApplication("tests/configs/modules/weather/forecastweather_units.js", {}); page = helpers.getPage(); }); describe("Temperature units", () => { const temperatures = ["75_9°", "69_8°", "73_2°", "74_1°", "69_1°"]; for (const [index, temp] of temperatures.entries()) { it(`should render custom decimalSymbol = '_' for temp ${temp}`, async () => { const tempCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td:nth-child(3)`); await expect(tempCell).toHaveText(temp); }); } }); describe("Precipitation units", () => { const precipitations = [undefined, "0.10 in"]; for (const [index, precipitation] of precipitations.entries()) { if (precipitation) { it(`should render precipitation amount ${precipitation}`, async () => { const precipCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td.precipitation-amount`); await expect(precipCell).toHaveText(precipitation); }); } } }); }); }); ================================================ FILE: tests/e2e/modules/weather_hourly_spec.js ================================================ const { expect } = require("playwright/test"); const helpers = require("../helpers/global-setup"); const weatherFunc = require("../helpers/weather-functions"); describe("Weather module: Weather Hourly Forecast", () => { let page; afterAll(async () => { await weatherFunc.stopApplication(); }); describe("Default configuration", () => { beforeAll(async () => { await weatherFunc.startApplication("tests/configs/modules/weather/hourlyweather_default.js", {}); page = helpers.getPage(); }); const minTemps = ["7:00 pm", "8:00 pm", "9:00 pm", "10:00 pm", "11:00 pm"]; for (const [index, hour] of minTemps.entries()) { it(`should render forecast for hour ${hour}`, async () => { const dayCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td.day`); await expect(dayCell).toHaveText(hour); }); } }); describe("Hourly weather options", () => { beforeAll(async () => { await weatherFunc.startApplication("tests/configs/modules/weather/hourlyweather_options.js", {}); page = helpers.getPage(); }); describe("Hourly increments of 2", () => { const minTemps = ["7:00 pm", "9:00 pm", "11:00 pm", "1:00 am", "3:00 am"]; for (const [index, hour] of minTemps.entries()) { it(`should render forecast for hour ${hour}`, async () => { const dayCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td.day`); await expect(dayCell).toHaveText(hour); }); } }); }); describe("Show precipitations", () => { beforeAll(async () => { await weatherFunc.startApplication("tests/configs/modules/weather/hourlyweather_showPrecipitation.js", {}); page = helpers.getPage(); }); describe("Shows precipitation amount", () => { const amounts = [undefined, undefined, undefined, "0.13 mm", "0.13 mm"]; for (const [index, amount] of amounts.entries()) { if (amount) { it(`should render precipitation amount ${amount}`, async () => { const amountCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td.precipitation-amount`); await expect(amountCell).toHaveText(amount); }); } } }); describe("Shows precipitation probability", () => { const probabilities = [undefined, undefined, "12 %", "36 %", "44 %"]; for (const [index, probability] of probabilities.entries()) { if (probability) { it(`should render probability ${probability}`, async () => { const probabilityCell = page.locator(`.weather table.small tr:nth-child(${index + 1}) td.precipitation-prob`); await expect(probabilityCell).toHaveText(probability); }); } } }); }); }); ================================================ FILE: tests/e2e/modules_display_spec.js ================================================ const { expect } = require("playwright/test"); const helpers = require("./helpers/global-setup"); describe("Display of modules", () => { let page; beforeAll(async () => { await helpers.startApplication("tests/configs/modules/display.js"); await helpers.getDocument(); page = helpers.getPage(); }); afterAll(async () => { await helpers.stopApplication(); }); it("should show the test header", async () => { // textContent returns lowercase here, the uppercase is realized by CSS, which therefore does not end up in textContent await expect(page.locator("#module_0_helloworld .module-header")).toHaveText("test_header"); }); it("should show no header if no header text is specified", async () => { await expect(page.locator("#module_1_helloworld .module-header")).toHaveText("undefined"); }); }); ================================================ FILE: tests/e2e/modules_empty_spec.js ================================================ const { expect } = require("playwright/test"); const helpers = require("./helpers/global-setup"); describe("Check configuration without modules", () => { let page; beforeAll(async () => { await helpers.startApplication("tests/configs/without_modules.js"); await helpers.getDocument(); page = helpers.getPage(); }); afterAll(async () => { await helpers.stopApplication(); }); it("shows the message MagicMirror² title", async () => { await expect(page.locator("#module_1_helloworld .module-content")).toContainText("MagicMirror²"); }); it("shows the project URL", async () => { await expect(page.locator("#module_5_helloworld .module-content")).toContainText("https://magicmirror.builders/"); }); }); ================================================ FILE: tests/e2e/modules_position_spec.js ================================================ const helpers = require("./helpers/global-setup"); const getPage = () => helpers.getPage(); describe("Position of modules", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/modules/positions.js"); await helpers.getDocument(); }); afterAll(async () => { await helpers.stopApplication(); }); const positions = ["top_bar", "top_left", "top_center", "top_right", "upper_third", "middle_center", "lower_third", "bottom_left", "bottom_center", "bottom_right", "bottom_bar", "fullscreen_above", "fullscreen_below"]; for (const position of positions) { const className = position.replace("_", "."); it(`should show text in ${position}`, async () => { const locator = getPage().locator(`.${className} .module-content`).first(); await locator.waitFor({ state: "visible" }); const text = await locator.textContent(); expect(text).not.toBeNull(); expect(text).toContain(`Text in ${position}`); }); } }); ================================================ FILE: tests/e2e/port_spec.js ================================================ const helpers = require("./helpers/global-setup"); describe("port directive configuration", () => { describe("Set port 8090", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/port_8090.js"); }); afterAll(async () => { await helpers.stopApplication(); }); it("should return 200", async () => { const port = global.testPort || 8080; const res = await fetch(`http://localhost:${port}`); expect(res.status).toBe(200); }); }); describe("Set port 8100 on environment variable MM_PORT", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/port_8090.js", (process.env.MM_PORT = 8100)); }); afterAll(async () => { await helpers.stopApplication(); }); it("should return 200", async () => { const port = global.testPort || 8080; const res = await fetch(`http://localhost:${port}`); expect(res.status).toBe(200); }); }); }); ================================================ FILE: tests/e2e/serveronly_spec.js ================================================ const delay = (time) => { return new Promise((resolve) => setTimeout(resolve, time)); }; const runConfigCheck = async () => { const serverProcess = await require("node:child_process").spawnSync("node", ["--run", "config:check"], { env: process.env }); return await serverProcess.status; }; describe("App environment", () => { let serverProcess; beforeAll(async () => { // Use fixed port 8080 (tests run sequentially) const testPort = 8080; process.env.MM_CONFIG_FILE = "tests/configs/default.js"; process.env.MM_PORT = testPort.toString(); serverProcess = await require("node:child_process").spawn("node", ["--run", "server"], { env: process.env, detached: true }); // we have to wait until the server is started await delay(2000); }); afterAll(async () => { await process.kill(-serverProcess.pid); }); it("get request from http://localhost:8080 should return 200", async () => { const res = await fetch("http://localhost:8080"); expect(res.status).toBe(200); }); it("get request from http://localhost:8080/nothing should return 404", async () => { const res = await fetch("http://localhost:8080/nothing"); expect(res.status).toBe(404); }); }); describe("Check config", () => { it("config check should return without errors", async () => { process.env.MM_CONFIG_FILE = "tests/configs/default.js"; const exitCode = await runConfigCheck(); expect(exitCode).toBe(0); }); it("config check should fail with non existent config file", async () => { process.env.MM_CONFIG_FILE = "tests/configs/not_exists.js"; const exitCode = await runConfigCheck(); expect(exitCode).toBe(1); }); }); ================================================ FILE: tests/e2e/template_spec.js ================================================ const fs = require("node:fs"); const helpers = require("./helpers/global-setup"); describe("templated config with port variable", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/port_variable.js"); }); afterAll(async () => { await helpers.stopApplication(); try { fs.unlinkSync("tests/configs/port_variable.js"); } catch (err) { // do nothing } }); it("should return 200", async () => { const port = global.testPort || 8080; const res = await fetch(`http://localhost:${port}`); expect(res.status).toBe(200); }); }); ================================================ FILE: tests/e2e/translations_spec.js ================================================ const fs = require("node:fs"); const path = require("node:path"); const helmet = require("helmet"); const { JSDOM } = require("jsdom"); const express = require("express"); const translations = require("../../translations/translations"); /** * Helper function to create a fresh Translator instance with DOM environment. * @returns {object} Object containing window and Translator */ function createTranslationTestEnvironment () { // Setup DOM environment with Translator const translatorJs = fs.readFileSync(path.join(__dirname, "..", "..", "js", "translator.js"), "utf-8"); const dom = new JSDOM("", { url: "http://localhost:3000", runScripts: "outside-only" }); dom.window.Log = { log: vi.fn(), error: vi.fn() }; dom.window.translations = translations; dom.window.fetch = fetch; dom.window.eval(translatorJs); const window = dom.window; return { window, Translator: window.Translator }; } describe("translations", () => { let server; beforeAll(() => { const app = express(); app.use(helmet()); app.use((req, res, next) => { res.header("Access-Control-Allow-Origin", "*"); next(); }); app.use("/translations", express.static(path.join(__dirname, "..", "..", "translations"))); server = app.listen(3000); }); afterAll(async () => { await server.close(); }); it("should have a translation file in the specified path", () => { for (const language in translations) { const file = fs.statSync(translations[language]); expect(file.isFile()).toBe(true); } }); describe("loadTranslations", () => { let dom; beforeEach(() => { // Create a new translation test environment for each test const env = createTranslationTestEnvironment(); const window = env.window; // Load class.js and module.js content directly for loadTranslations tests 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 window.eval(classJs); window.eval(moduleJs); // Additional setup for loadTranslations tests window.config = { language: "de" }; dom = { window }; }); it("should load translation file", async () => { const { Translator, Module, config } = dom.window; config.language = "en"; Translator.load = vi.fn().mockImplementation((_m, _f, _fb) => null); Module.register("name", { getTranslations: () => translations }); const MMM = Module.create("name"); await MMM.loadTranslations(); expect(Translator.load.mock.calls).toHaveLength(1); expect(Translator.load).toHaveBeenCalledWith(MMM, "translations/en.json", false); }); it("should load translation + fallback file", async () => { const { Translator, Module } = dom.window; Translator.load = vi.fn().mockImplementation((_m, _f, _fb) => null); Module.register("name", { getTranslations: () => translations }); const MMM = Module.create("name"); await MMM.loadTranslations(); expect(Translator.load.mock.calls).toHaveLength(2); expect(Translator.load).toHaveBeenCalledWith(MMM, "translations/de.json", false); expect(Translator.load).toHaveBeenCalledWith(MMM, "translations/en.json", true); }); it("should load translation fallback file", async () => { const { Translator, Module, config } = dom.window; config.language = "--"; Translator.load = vi.fn().mockImplementation((_m, _f, _fb) => null); Module.register("name", { getTranslations: () => translations }); const MMM = Module.create("name"); await MMM.loadTranslations(); expect(Translator.load.mock.calls).toHaveLength(1); expect(Translator.load).toHaveBeenCalledWith(MMM, "translations/en.json", true); }); it("should load no file", async () => { const { Translator, Module } = dom.window; Translator.load = vi.fn(); Module.register("name", {}); const MMM = Module.create("name"); await MMM.loadTranslations(); expect(Translator.load.mock.calls).toHaveLength(0); }); }); const mmm = { name: "TranslationTest", file (file) { return `http://localhost:3000/${file}`; } }; describe("parsing language files through the Translator class", () => { for (const language in translations) { it(`should parse ${language}`, async () => { const { Translator } = createTranslationTestEnvironment(); 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", () => { let base; // 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 = async (language) => { const { Translator } = createTranslationTestEnvironment(); await Translator.load(mmm, translations[language], false); return 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(); }); for (const language in translations) { if (language === "de") continue; describe(`Translation keys of ${language}`, () => { let keys; beforeAll(async () => { const languageTranslations = await initializeTranslationDOM(language); keys = Object.keys(languageTranslations).sort(); }); it(`${language} should not contain keys that are not in base language`, () => { keys.forEach((key) => { expect(base).toContain(key, `Translation key '${key}' in language '${language}' is not present in base language`); }); }); 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)); 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}'`); }); }); }); } }); }); ================================================ FILE: tests/e2e/vendor_spec.js ================================================ const helpers = require("./helpers/global-setup"); describe("Vendors", () => { beforeAll(async () => { await helpers.startApplication("tests/configs/default.js"); }); afterAll(async () => { await helpers.stopApplication(); }); describe("Get list vendors", () => { const vendors = require(`${global.root_path}/js/vendor.js`); Object.keys(vendors).forEach((vendor) => { it(`should return 200 HTTP code for vendor "${vendor}"`, async () => { const urlVendor = `http://localhost:8080/${vendors[vendor]}`; const res = await fetch(urlVendor); expect(res.status).toBe(200); }); }); }); }); ================================================ FILE: tests/electron/env_spec.js ================================================ const events = require("node:events"); const helpers = require("./helpers/global-setup"); describe("Electron app environment", () => { beforeEach(async () => { await helpers.startApplication("tests/configs/modules/display.js"); }); afterEach(async () => { await helpers.stopApplication(); }); it("should open browserwindow", async () => { // Wait for module content to be rendered, not just the module wrapper const moduleContent = await helpers.getElement("#module_0_helloworld .module-content"); await expect(moduleContent.textContent()).resolves.toContain("Test Display Header"); expect(global.electronApp.windows()).toHaveLength(1); }); }); describe("Development console tests", () => { beforeEach(async () => { await helpers.startApplication("tests/configs/modules/display.js", null, ["dev"]); }); afterEach(async () => { await helpers.stopApplication(); }); it("should open browserwindow and dev console", async () => { while (global.electronApp.windows().length < 2) await events.once(global.electronApp, "window"); const pageArray = await global.electronApp.windows(); expect(pageArray).toHaveLength(2); for (const page of pageArray) { expect(["MagicMirror²", "DevTools"]).toContain(await page.title()); } }); }); ================================================ FILE: tests/electron/helpers/global-setup.js ================================================ // see https://playwright.dev/docs/api/class-electronapplication // https://github.com/microsoft/playwright/issues/6347#issuecomment-1085850728 // 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 = [], timezone = "GMT") => { global.electronApp = null; global.page = null; process.env.MM_CONFIG_FILE = configFilename; process.env.TZ = timezone; if (systemDate) { process.env.MOCK_DATE = systemDate; } 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"); } // Pass environment variables to Electron process const env = { ...process.env, MM_CONFIG_FILE: configFilename, TZ: timezone, mmTestMode: "true" }; if (systemDate) { env.MOCK_DATE = systemDate; } global.electronApp = await electron.launch({ args: electronParams, env: env }); await global.electronApp.firstWindow(); for (const win of global.electronApp.windows()) { const title = await win.title(); expect(["MagicMirror²", "DevTools"]).toContain(title); if (title === "MagicMirror²") { global.page = win; if (systemDate) { await global.page.evaluate((systemDate) => { Date.now = () => { return new Date(systemDate).valueOf(); }; }, systemDate); } } } }; exports.stopApplication = async (timeout = 10000) => { const app = global.electronApp; global.electronApp = null; global.page = null; process.env.MOCK_DATE = undefined; if (!app) { return; } const killElectron = () => { try { const electronProcess = typeof app.process === "function" ? app.process() : null; if (electronProcess && !electronProcess.killed) { electronProcess.kill("SIGKILL"); } } catch (error) { // Ignore errors caused by Playwright already tearing down the connection } }; try { await Promise.race([ app.close(), new Promise((_, reject) => setTimeout(() => reject(new Error("Electron close timeout")), timeout)) ]); } catch (error) { killElectron(); } }; exports.getElement = async (selector, state = "visible") => { expect(global.page).not.toBeNull(); const elem = global.page.locator(selector); await elem.waitFor({ state: state }); expect(elem).not.toBeNull(); return elem; }; ================================================ FILE: tests/electron/helpers/weather-setup.js ================================================ const { injectMockData } = require("../../utils/weather_mocker"); const helpers = require("./global-setup"); exports.getText = async (element, result) => { const elem = await helpers.getElement(element); await expect(elem).not.toBeNull(); const text = await elem.textContent(); await expect( text .trim() .replace(/(\r\n|\n|\r)/gm, "") .replace(/[ ]+/g, " ") ).toBe(result); return true; }; exports.startApp = async (configFileName, systemDate) => { await helpers.startApplication(injectMockData(configFileName), systemDate); }; ================================================ FILE: tests/electron/modules/calendar_spec.js ================================================ const helpers = require("../helpers/global-setup"); describe("Calendar module", () => { /** * move similar tests in function doTest * @param {string} cssClass css selector * @returns {boolean} result */ const doTest = async (cssClass) => { const elem = await helpers.getElement(`.calendar .module-content .event${cssClass}`); await expect(elem.isVisible()).resolves.toBe(true); return true; }; 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} */ 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(); console.log(await loc.allInnerTexts()); }; const first = 0; const second = 1; const third = 2; const last = -1; // get results of table row and column, can select specific row of results, // row is 0 based index -1 is last, 0 is first... need 10th(human count), use 9 as row // uses playwright nth locator syntax const doTestTableContent = async (table_row, table_column, content, row = first) => { const elem = await global.page.locator(table_row); const column = await elem.locator(table_column).locator(`nth=${row}`); await expect(column.textContent()).resolves.toContain(content); return true; }; afterEach(async () => { await helpers.stopApplication(); }); describe("Test css classes", () => { it("has css class dayBeforeYesterday", async () => { await helpers.startApplication("tests/configs/modules/calendar/custom.js", "03 Jan 2030 12:30:00 GMT"); await expect(doTest(".dayBeforeYesterday")).resolves.toBe(true); }); it("has css class yesterday", async () => { await helpers.startApplication("tests/configs/modules/calendar/custom.js", "02 Jan 2030 12:30:00 GMT"); await expect(doTest(".yesterday")).resolves.toBe(true); }); it("has css class today", async () => { await helpers.startApplication("tests/configs/modules/calendar/custom.js", "01 Jan 2030 12:30:00 GMT"); await expect(doTest(".today")).resolves.toBe(true); }); it("has css class tomorrow", async () => { await helpers.startApplication("tests/configs/modules/calendar/custom.js", "31 Dec 2029 12:30:00 GMT"); await expect(doTest(".tomorrow")).resolves.toBe(true); }); it("has css class dayAfterTomorrow", async () => { await helpers.startApplication("tests/configs/modules/calendar/custom.js", "30 Dec 2029 12:30:00 GMT"); await expect(doTest(".dayAfterTomorrow")).resolves.toBe(true); }); }); describe("Events from multiple calendars", () => { it("should show multiple events with the same title and start time from different calendars", async () => { await helpers.startApplication("tests/configs/modules/calendar/show-duplicates-in-calendar.js", "15 Sep 2024 12:30:00 GMT"); await expect(doTestCount()).resolves.toBe(20); }); }); /* * RRULE TESTS: * Add any tests that check rrule functionality here. */ 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", [], "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); }); }); /* * LOS ANGELES TESTS: * In 2023, DST (GMT-7) was until 5 Nov, after which is standard (STD) (GMT-8) time. * Test takes place on Thu 19 Oct, recurring event on a Wednesday. maximumNumberOfDays=28, so there should be * 4 events (25 Oct, 1 Nov, (switch to STD), 8 Nov, Nov 15), but 1 Nov and 8 Nov are excluded. * There are three separate tests: * * before midnight GMT (3pm local time) * * at midnight GMT in STD time (4pm local time) * * at midnight GMT in DST time (5pm local time) */ 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", [], "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", [], "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", [], "America/Los_Angeles"); await expect(doTestCount()).resolves.toBe(2); }); }); /* * SYDNEY TESTS: * In 2023, standard time (STD) (GMT+10) was until 1 Oct, after which is DST (GMT+11). * Test takes place on Thu 14 Sep, recurring event on a Wednesday. maximumNumberOfDays=28, so there should be * 4 events (20 Sep, 27 Sep, (switch to DST), 4 Oct, 11 Oct), but 27 Sep and 4 Oct are excluded. * There are three separate tests: * * before midnight GMT (9am local time) * * at midnight GMT in STD time (10am local time) * * at midnight GMT in DST time (11am local time) */ 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", [], "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", [], "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", [], "Australia/Sydney"); await expect(doTestCount()).resolves.toBe(2); }); }); /* * RRULE TESTS: * Add any tests that check rrule functionality here. */ 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", [], "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", [], "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", [], "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", [], "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", [], "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", [], "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", [], "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", [], "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); }); }); 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", [], "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); }); }); 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", [], "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); }); }); 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", [], "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 await expect(doTestTableContent(".calendar .event", ".time", "22nd.Oct, 14:30-15:30", first)).resolves.toBe(true); // remaining original 4th, now 3rd await expect(doTestTableContent(".calendar .event", ".time", "26th.Oct, 14:30-15:30", second)).resolves.toBe(true); }); }); 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", [], "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", [], "America/Chicago"); // just await expect(doTestTableContent(".calendar .event", ".time", "25th.Oct-30th.Oct", first)).resolves.toBe(true); }); }); 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", [], "America/Chicago"); // just await expect(doTestTableContent(".calendar .event", ".time", "25th.Oct, 20:00", first)).resolves.toBe(true); }); }); 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", [], "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); }); }); }); ================================================ FILE: tests/electron/modules/compliments_spec.js ================================================ const helpers = require("../helpers/global-setup"); 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, 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; }; afterEach(async () => { await helpers.stopApplication(); }); describe("parts of days", () => { it("Morning compliments for that part of day", async () => { await helpers.startApplication("tests/configs/modules/compliments/compliments_parts_day.js", "01 Oct 2022 10:00:00 GMT"); await expect(doTest(["Hi", "Good Morning", "Morning test"])).resolves.toBe(true); }); it("Afternoon show Compliments for that part of day", async () => { await helpers.startApplication("tests/configs/modules/compliments/compliments_parts_day.js", "01 Oct 2022 15:00:00 GMT"); await expect(doTest(["Hello", "Good Afternoon", "Afternoon test"])).resolves.toBe(true); }); it("Evening show Compliments for that part of day", async () => { 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", () => { describe("Set date and empty compliments for anytime, morning, evening and afternoon", () => { it("shows happy new year compliment on new years day", async () => { await helpers.startApplication("tests/configs/modules/compliments/compliments_date.js", "01 Jan 2022 10:00:00 GMT"); await expect(doTest(["Happy new year!"])).resolves.toBe(true); }); }); describe("Test only custom date events shown with new property", () => { it("shows 'Special day message' on May 6", async () => { await helpers.startApplication("tests/configs/modules/compliments/compliments_specialDayUnique_true.js", "06 May 2022 10:00:00 GMT"); await expect(doTest(["Special day message"])).resolves.toBe(true); }); }); describe("Test all date events shown without new property", () => { it("shows 'any message' on May 6", async () => { await helpers.startApplication("tests/configs/modules/compliments/compliments_specialDayUnique_false.js", "06 May 2022 10:00:00 GMT"); await expect(doTest(["Special day message", "Typical message 1", "Typical message 2", "Typical message 3"])).resolves.toBe(true); }); }); describe("Test only custom cron date event shown with new property", () => { it("shows 'any message' on May 6", async () => { await helpers.startApplication("tests/configs/modules/compliments/compliments_cron_entry.js", "06 May 2022 17:03:00 GMT"); await expect(doTest(["just pub time"])).resolves.toBe(true); }); }); describe("Test any event shows after time window", () => { it("shows 'any message' on May 6", async () => { await helpers.startApplication("tests/configs/modules/compliments/compliments_cron_entry.js", "06 May 2022 17:11:00 GMT"); await expect(doTest(["just a test"])).resolves.toBe(true); }); }); describe("Test any event shows different day", () => { it("shows 'any message' on May 5", async () => { await helpers.startApplication("tests/configs/modules/compliments/compliments_cron_entry.js", "05 May 2022 17:00:00 GMT"); await expect(doTest(["just a test"])).resolves.toBe(true); }); }); }); describe("Feature remote compliments file", () => { describe("get list from remote file", () => { it("shows 'Remote compliment file works!' as only anytime list set", async () => { await helpers.startApplication("tests/configs/modules/compliments/compliments_file.js", "01 Jan 2022 10:00:00 GMT"); await expect(doTest(["Remote compliment file works!"])).resolves.toBe(true); }); }); describe("get updated list from remote file", () => { it("shows 'test in morning'", async () => { await helpers.startApplication("tests/configs/modules/compliments/compliments_file_change.js", "01 Jan 2022 10:00:00 GMT"); await expect(doTest(["Remote compliment file works!"])).resolves.toBe(true); await new Promise((r) => setTimeout(r, 10000)); await expect(doTest(["test in morning"])).resolves.toBe(true); }); }); }); }); ================================================ FILE: tests/electron/modules/weather_spec.js ================================================ const helpers = require("../helpers/global-setup"); const weatherHelper = require("../helpers/weather-setup"); const { cleanupMockData } = require("../../utils/weather_mocker"); const CURRENT_WEATHER_CONFIG = "tests/configs/modules/weather/currentweather_default.js"; const SUNRISE_DATE = "13 Jan 2019 00:30:00 GMT"; const SUNSET_DATE = "13 Jan 2019 12:30:00 GMT"; const SUN_EVENT_SELECTOR = ".weather .normal.medium span:nth-child(4)"; const EXPECTED_SUNRISE_TEXT = "7:00 am"; const EXPECTED_SUNSET_TEXT = "3:45 pm"; describe("Weather module", () => { afterEach(async () => { await helpers.stopApplication(); cleanupMockData(); }); describe("Current weather with sunrise", () => { beforeAll(async () => { await weatherHelper.startApp(CURRENT_WEATHER_CONFIG, SUNRISE_DATE); }); it("should render sunrise", async () => { const isSunriseRendered = await weatherHelper.getText(SUN_EVENT_SELECTOR, EXPECTED_SUNRISE_TEXT); expect(isSunriseRendered).toBe(true); }); }); describe("Current weather with sunset", () => { beforeAll(async () => { await weatherHelper.startApp(CURRENT_WEATHER_CONFIG, SUNSET_DATE); }); it("should render sunset", async () => { const isSunsetRendered = await weatherHelper.getText(SUN_EVENT_SELECTOR, EXPECTED_SUNSET_TEXT); expect(isSunsetRendered).toBe(true); }); }); }); ================================================ FILE: tests/mocks/12_events.ics ================================================ 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 ================================================ FILE: tests/mocks/3_move_first_allday_repeating_event.ics ================================================ BEGIN:VCALENDAR PRODID:-//Google Inc//Google Calendar 70.9054//EN VERSION:2.0 CALSCALE:GREGORIAN METHOD:PUBLISH X-WR-CALNAME:TestCal X-WR-TIMEZONE:Europe/Berlin X-WR-CALDESC:Calendar for testing purposes BEGIN:VEVENT DTSTART;VALUE=DATE:20241011 DTEND;VALUE=DATE:20241012 RRULE:FREQ=WEEKLY;WKST=MO;COUNT=5;BYDAY=FR DTSTAMP:20241009T153220Z UID:2m6mt1p89l2anl74915ur3hsgm@google.com CREATED:20241009T153058Z LAST-MODIFIED:20241009T153205Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:TestCal_AllDayRepeatingEvent TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20241012 DTEND;VALUE=DATE:20241013 DTSTAMP:20241009T153220Z UID:2m6mt1p89l2anl74915ur3hsgm@google.com RECURRENCE-ID;VALUE=DATE:20241011 CREATED:20241009T153058Z LAST-MODIFIED:20241009T153205Z SEQUENCE:1 STATUS:CONFIRMED SUMMARY:TestCal_AllDayRepeatingEvent TRANSP:TRANSPARENT END:VEVENT END:VCALENDAR ================================================ FILE: tests/mocks/RepeatingEvent.Oct21.ics ================================================ BEGIN:VCALENDAR BEGIN:VEVENT DTSTART;TZID=Europe/Berlin:20241028T000000 DTEND;TZID=Europe/Berlin:20241028T010000 RRULE:FREQ=DAILY;COUNT=3 DTSTAMP:20241020T093758Z UID:053fdshnnibo92lu97rsoeqoti@google.com CREATED:20241020T093230Z LAST-MODIFIED:20241020T093230Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:RepeatingEventWeekAfterToday TRANSP:OPAQUE END:VEVENT BEGIN:VEVENT DTSTART;TZID=Europe/Berlin:20241021T000000 DTEND;TZID=Europe/Berlin:20241021T010000 RRULE:FREQ=DAILY;COUNT=3 DTSTAMP:20241020T093758Z UID:1a6kk47pp61k4td2h9rlf0lv69@google.com CREATED:20241020T093255Z LAST-MODIFIED:20241020T093437Z SEQUENCE:1 STATUS:CONFIRMED SUMMARY:RepeatingEventDayAfterToday TRANSP:OPAQUE END:VEVENT END:VCALENDAR ================================================ FILE: tests/mocks/bad_rrule.ics ================================================ BEGIN:VCALENDAR BEGIN:VEVENT DTSTAMP:20210413T203456Z UID:E689AEB8C02C4E2CADD8C7D3D303CEAD0 DTSTART;TZID="Amsterdam, Belgrade, Berlin, Brussels, Budapest, Madrid, Paris, Prague, Stockholm":20210415T190000 DTEND;TZID="Amsterdam, Belgrade, Berlin, Brussels, Budapest, Madrid, Paris, Prague, Stockholm":20210415T210000 CLASS:PUBLIC LOCATION:albert heijn SUMMARY:xxx xxxx SEQUENCE:10 RRULE:FREQ=DAILY;UNTIL=20210418T170000Z EXDATE;TZID="Amsterdam, Belgrade, Berlin, Brussels, Budapest, Madrid, Paris, Prague, Stockholm":20210417T190000 EXDATE;TZID="Amsterdam, Belgrade, Berlin, Brussels, Budapest, Madrid, Paris, Prague, Stockholm":20210416T190000 EXDATE;TZID="Amsterdam, Belgrade, Berlin, Brussels, Budapest, Madrid, Paris, Prague, Stockholm":20210415T190000 BEGIN:VALARM ACTION:DISPLAY TRIGGER;RELATED=START:-PT15M END:VALARM END:VEVENT END:VCALENDAR ================================================ FILE: tests/mocks/calendar_duplicates_1.ics ================================================ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//MagicMirror//Test Calendar//EN CALSCALE:GREGORIAN METHOD:PUBLISH X-WR-CALNAME:Duplicates Test Calendar 1 BEGIN:VEVENT UID:duplicate-event-1@magicmirror.test DTSTART:20240916T100000Z DTEND:20240916T110000Z DTSTAMP:20240915T000000Z SUMMARY:Duplicate Event 1 STATUS:CONFIRMED END:VEVENT BEGIN:VEVENT UID:duplicate-event-2@magicmirror.test DTSTART:20240917T140000Z DTEND:20240917T150000Z DTSTAMP:20240915T000000Z SUMMARY:Duplicate Event 2 STATUS:CONFIRMED END:VEVENT BEGIN:VEVENT UID:duplicate-event-3@magicmirror.test DTSTART:20240918T080000Z DTEND:20240918T090000Z DTSTAMP:20240915T000000Z SUMMARY:Duplicate Event 3 STATUS:CONFIRMED END:VEVENT BEGIN:VEVENT UID:duplicate-event-4@magicmirror.test DTSTART:20240919T120000Z DTEND:20240919T130000Z DTSTAMP:20240915T000000Z SUMMARY:Duplicate Event 4 STATUS:CONFIRMED END:VEVENT BEGIN:VEVENT UID:duplicate-event-5@magicmirror.test DTSTART:20240920T160000Z DTEND:20240920T170000Z DTSTAMP:20240915T000000Z SUMMARY:Duplicate Event 5 STATUS:CONFIRMED END:VEVENT BEGIN:VEVENT UID:duplicate-event-6@magicmirror.test DTSTART:20240921T100000Z DTEND:20240921T110000Z DTSTAMP:20240915T000000Z SUMMARY:Duplicate Event 6 STATUS:CONFIRMED END:VEVENT BEGIN:VEVENT UID:duplicate-event-7@magicmirror.test DTSTART:20240922T140000Z DTEND:20240922T150000Z DTSTAMP:20240915T000000Z SUMMARY:Duplicate Event 7 STATUS:CONFIRMED END:VEVENT BEGIN:VEVENT UID:duplicate-event-8@magicmirror.test DTSTART:20240923T080000Z DTEND:20240923T090000Z DTSTAMP:20240915T000000Z SUMMARY:Duplicate Event 8 STATUS:CONFIRMED END:VEVENT BEGIN:VEVENT UID:duplicate-event-9@magicmirror.test DTSTART:20240924T120000Z DTEND:20240924T130000Z DTSTAMP:20240915T000000Z SUMMARY:Duplicate Event 9 STATUS:CONFIRMED END:VEVENT BEGIN:VEVENT UID:duplicate-event-10@magicmirror.test DTSTART:20240925T160000Z DTEND:20240925T170000Z DTSTAMP:20240915T000000Z SUMMARY:Duplicate Event 10 STATUS:CONFIRMED END:VEVENT END:VCALENDAR ================================================ FILE: tests/mocks/calendar_duplicates_2.ics ================================================ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//MagicMirror//Test Calendar//EN CALSCALE:GREGORIAN METHOD:PUBLISH X-WR-CALNAME:Duplicates Test Calendar 2 BEGIN:VEVENT UID:duplicate-event-1-clone@magicmirror.test DTSTART:20240916T100000Z DTEND:20240916T110000Z DTSTAMP:20240915T000000Z SUMMARY:Duplicate Event 1 STATUS:CONFIRMED END:VEVENT BEGIN:VEVENT UID:duplicate-event-2-clone@magicmirror.test DTSTART:20240917T140000Z DTEND:20240917T150000Z DTSTAMP:20240915T000000Z SUMMARY:Duplicate Event 2 STATUS:CONFIRMED END:VEVENT BEGIN:VEVENT UID:duplicate-event-3-clone@magicmirror.test DTSTART:20240918T080000Z DTEND:20240918T090000Z DTSTAMP:20240915T000000Z SUMMARY:Duplicate Event 3 STATUS:CONFIRMED END:VEVENT BEGIN:VEVENT UID:duplicate-event-4-clone@magicmirror.test DTSTART:20240919T120000Z DTEND:20240919T130000Z DTSTAMP:20240915T000000Z SUMMARY:Duplicate Event 4 STATUS:CONFIRMED END:VEVENT BEGIN:VEVENT UID:duplicate-event-5-clone@magicmirror.test DTSTART:20240920T160000Z DTEND:20240920T170000Z DTSTAMP:20240915T000000Z SUMMARY:Duplicate Event 5 STATUS:CONFIRMED END:VEVENT BEGIN:VEVENT UID:duplicate-event-6-clone@magicmirror.test DTSTART:20240921T100000Z DTEND:20240921T110000Z DTSTAMP:20240915T000000Z SUMMARY:Duplicate Event 6 STATUS:CONFIRMED END:VEVENT BEGIN:VEVENT UID:duplicate-event-7-clone@magicmirror.test DTSTART:20240922T140000Z DTEND:20240922T150000Z DTSTAMP:20240915T000000Z SUMMARY:Duplicate Event 7 STATUS:CONFIRMED END:VEVENT BEGIN:VEVENT UID:duplicate-event-8-clone@magicmirror.test DTSTART:20240923T080000Z DTEND:20240923T090000Z DTSTAMP:20240915T000000Z SUMMARY:Duplicate Event 8 STATUS:CONFIRMED END:VEVENT BEGIN:VEVENT UID:duplicate-event-9-clone@magicmirror.test DTSTART:20240924T120000Z DTEND:20240924T130000Z DTSTAMP:20240915T000000Z SUMMARY:Duplicate Event 9 STATUS:CONFIRMED END:VEVENT BEGIN:VEVENT UID:duplicate-event-10-clone@magicmirror.test DTSTART:20240925T160000Z DTEND:20240925T170000Z DTSTAMP:20240915T000000Z SUMMARY:Duplicate Event 10 STATUS:CONFIRMED END:VEVENT END:VCALENDAR ================================================ FILE: tests/mocks/calendar_test.ics ================================================ BEGIN:VCALENDAR PRODID:-//Google Inc//Google Calendar 70.9054//EN VERSION:2.0 CALSCALE:GREGORIAN METHOD:PUBLISH X-WR-CALNAME:MagicMirrorTest X-WR-TIMEZONE:America/Santiago X-WR-CALDESC:Testing propose MagicMirror BEGIN:VTIMEZONE TZID:America/Santiago X-LIC-LOCATION:America/Santiago BEGIN:STANDARD TZOFFSETFROM:-0300 TZOFFSETTO:-0400 TZNAME:-04 DTSTART:19700510T000000 RDATE:19700510T030000 RDATE:19710509T030000 RDATE:19720514T030000 RDATE:19730513T030000 RDATE:19740512T030000 RDATE:19750511T030000 RDATE:19760509T030000 RDATE:19770515T030000 RDATE:19780514T030000 RDATE:19790513T030000 RDATE:19800511T030000 RDATE:19810510T030000 RDATE:19820509T030000 RDATE:19830515T030000 RDATE:19840513T030000 RDATE:19850512T030000 RDATE:19860511T030000 RDATE:19870510T030000 RDATE:19880515T030000 RDATE:19890514T030000 RDATE:19900513T030000 RDATE:19910512T030000 RDATE:19920510T030000 RDATE:19930509T030000 RDATE:19940515T030000 RDATE:19950514T030000 RDATE:19960512T030000 RDATE:19970511T030000 RDATE:19980510T030000 RDATE:19990509T030000 RDATE:20000514T030000 RDATE:20010513T030000 RDATE:20020512T030000 RDATE:20030511T030000 RDATE:20040509T030000 RDATE:20050515T030000 RDATE:20060514T030000 RDATE:20070513T030000 RDATE:20080511T030000 RDATE:20090510T030000 RDATE:20100509T030000 RDATE:20110515T030000 RDATE:20120513T030000 RDATE:20130512T030000 RDATE:20140511T030000 RDATE:20150510T030000 RDATE:20160515T030000 RDATE:20170514T030000 RDATE:20180513T030000 RDATE:20190512T030000 RDATE:20200510T030000 RDATE:20210509T030000 RDATE:20220515T030000 RDATE:20230514T030000 RDATE:20240512T030000 RDATE:20250511T030000 RDATE:20260510T030000 RDATE:20270509T030000 RDATE:20280514T030000 RDATE:20290513T030000 RDATE:20300512T030000 RDATE:20310511T030000 RDATE:20320509T030000 RDATE:20330515T030000 RDATE:20340514T030000 RDATE:20350513T030000 RDATE:20360511T030000 RDATE:20370510T030000 END:STANDARD BEGIN:STANDARD TZOFFSETFROM:-0300 TZOFFSETTO:-0400 TZNAME:-04 DTSTART:20380509T000000 RRULE:FREQ=YEARLY;BYMONTH=5;BYDAY=2SU END:STANDARD BEGIN:DAYLIGHT TZOFFSETFROM:-0400 TZOFFSETTO:-0300 TZNAME:-03 DTSTART:19700809T000000 RDATE:19700809T040000 RDATE:19710815T040000 RDATE:19720813T040000 RDATE:19730812T040000 RDATE:19740811T040000 RDATE:19750810T040000 RDATE:19760815T040000 RDATE:19770814T040000 RDATE:19780813T040000 RDATE:19790812T040000 RDATE:19800810T040000 RDATE:19810809T040000 RDATE:19820815T040000 RDATE:19830814T040000 RDATE:19840812T040000 RDATE:19850811T040000 RDATE:19860810T040000 RDATE:19870809T040000 RDATE:19880814T040000 RDATE:19890813T040000 RDATE:19900812T040000 RDATE:19910811T040000 RDATE:19920809T040000 RDATE:19930815T040000 RDATE:19940814T040000 RDATE:19950813T040000 RDATE:19960811T040000 RDATE:19970810T040000 RDATE:19980809T040000 RDATE:19990815T040000 RDATE:20000813T040000 RDATE:20010812T040000 RDATE:20020811T040000 RDATE:20030810T040000 RDATE:20040815T040000 RDATE:20050814T040000 RDATE:20060813T040000 RDATE:20070812T040000 RDATE:20080810T040000 RDATE:20090809T040000 RDATE:20100815T040000 RDATE:20110814T040000 RDATE:20120812T040000 RDATE:20130811T040000 RDATE:20140810T040000 RDATE:20150809T040000 RDATE:20160814T040000 RDATE:20170813T040000 RDATE:20180812T040000 RDATE:20190811T040000 RDATE:20200809T040000 RDATE:20210815T040000 RDATE:20220814T040000 RDATE:20230813T040000 RDATE:20240811T040000 RDATE:20250810T040000 RDATE:20260809T040000 RDATE:20270815T040000 RDATE:20280813T040000 RDATE:20290812T040000 RDATE:20300811T040000 RDATE:20310810T040000 RDATE:20320815T040000 RDATE:20330814T040000 RDATE:20340813T040000 RDATE:20350812T040000 RDATE:20360810T040000 RDATE:20370809T040000 END:DAYLIGHT BEGIN:DAYLIGHT TZOFFSETFROM:-0400 TZOFFSETTO:-0300 TZNAME:-03 DTSTART:20380815T000000 RRULE:FREQ=YEARLY;BYMONTH=8;BYDAY=2SU END:DAYLIGHT END:VTIMEZONE BEGIN:VEVENT DTSTART;TZID=America/Santiago:20170309T100000 DTEND;TZID=America/Santiago:20170309T110000 RRULE:FREQ=MONTHLY;INTERVAL=30;BYMONTHDAY=9 DTSTAMP:20170310T172720Z UID:80rl9kuu5bq49gme99eklov27k@google.com CREATED:20170310T172400Z DESCRIPTION: LAST-MODIFIED:20170310T172400Z LOCATION: SEQUENCE:0 STATUS:CONFIRMED SUMMARY:TestEvent TRANSP:OPAQUE END:VEVENT END:VCALENDAR ================================================ FILE: tests/mocks/calendar_test_clone.ics ================================================ BEGIN:VCALENDAR PRODID:-//Google Inc//Google Calendar 70.9054//EN VERSION:2.0 CALSCALE:GREGORIAN METHOD:PUBLISH X-WR-CALNAME:MagicMirrorTest X-WR-TIMEZONE:America/Santiago X-WR-CALDESC:Testing propose MagicMirror BEGIN:VTIMEZONE TZID:America/Santiago X-LIC-LOCATION:America/Santiago BEGIN:STANDARD TZOFFSETFROM:-0300 TZOFFSETTO:-0400 TZNAME:-04 DTSTART:19700510T000000 RDATE:19700510T030000 RDATE:19710509T030000 RDATE:19720514T030000 RDATE:19730513T030000 RDATE:19740512T030000 RDATE:19750511T030000 RDATE:19760509T030000 RDATE:19770515T030000 RDATE:19780514T030000 RDATE:19790513T030000 RDATE:19800511T030000 RDATE:19810510T030000 RDATE:19820509T030000 RDATE:19830515T030000 RDATE:19840513T030000 RDATE:19850512T030000 RDATE:19860511T030000 RDATE:19870510T030000 RDATE:19880515T030000 RDATE:19890514T030000 RDATE:19900513T030000 RDATE:19910512T030000 RDATE:19920510T030000 RDATE:19930509T030000 RDATE:19940515T030000 RDATE:19950514T030000 RDATE:19960512T030000 RDATE:19970511T030000 RDATE:19980510T030000 RDATE:19990509T030000 RDATE:20000514T030000 RDATE:20010513T030000 RDATE:20020512T030000 RDATE:20030511T030000 RDATE:20040509T030000 RDATE:20050515T030000 RDATE:20060514T030000 RDATE:20070513T030000 RDATE:20080511T030000 RDATE:20090510T030000 RDATE:20100509T030000 RDATE:20110515T030000 RDATE:20120513T030000 RDATE:20130512T030000 RDATE:20140511T030000 RDATE:20150510T030000 RDATE:20160515T030000 RDATE:20170514T030000 RDATE:20180513T030000 RDATE:20190512T030000 RDATE:20200510T030000 RDATE:20210509T030000 RDATE:20220515T030000 RDATE:20230514T030000 RDATE:20240512T030000 RDATE:20250511T030000 RDATE:20260510T030000 RDATE:20270509T030000 RDATE:20280514T030000 RDATE:20290513T030000 RDATE:20300512T030000 RDATE:20310511T030000 RDATE:20320509T030000 RDATE:20330515T030000 RDATE:20340514T030000 RDATE:20350513T030000 RDATE:20360511T030000 RDATE:20370510T030000 END:STANDARD BEGIN:STANDARD TZOFFSETFROM:-0300 TZOFFSETTO:-0400 TZNAME:-04 DTSTART:20380509T000000 RRULE:FREQ=YEARLY;BYMONTH=5;BYDAY=2SU END:STANDARD BEGIN:DAYLIGHT TZOFFSETFROM:-0400 TZOFFSETTO:-0300 TZNAME:-03 DTSTART:19700809T000000 RDATE:19700809T040000 RDATE:19710815T040000 RDATE:19720813T040000 RDATE:19730812T040000 RDATE:19740811T040000 RDATE:19750810T040000 RDATE:19760815T040000 RDATE:19770814T040000 RDATE:19780813T040000 RDATE:19790812T040000 RDATE:19800810T040000 RDATE:19810809T040000 RDATE:19820815T040000 RDATE:19830814T040000 RDATE:19840812T040000 RDATE:19850811T040000 RDATE:19860810T040000 RDATE:19870809T040000 RDATE:19880814T040000 RDATE:19890813T040000 RDATE:19900812T040000 RDATE:19910811T040000 RDATE:19920809T040000 RDATE:19930815T040000 RDATE:19940814T040000 RDATE:19950813T040000 RDATE:19960811T040000 RDATE:19970810T040000 RDATE:19980809T040000 RDATE:19990815T040000 RDATE:20000813T040000 RDATE:20010812T040000 RDATE:20020811T040000 RDATE:20030810T040000 RDATE:20040815T040000 RDATE:20050814T040000 RDATE:20060813T040000 RDATE:20070812T040000 RDATE:20080810T040000 RDATE:20090809T040000 RDATE:20100815T040000 RDATE:20110814T040000 RDATE:20120812T040000 RDATE:20130811T040000 RDATE:20140810T040000 RDATE:20150809T040000 RDATE:20160814T040000 RDATE:20170813T040000 RDATE:20180812T040000 RDATE:20190811T040000 RDATE:20200809T040000 RDATE:20210815T040000 RDATE:20220814T040000 RDATE:20230813T040000 RDATE:20240811T040000 RDATE:20250810T040000 RDATE:20260809T040000 RDATE:20270815T040000 RDATE:20280813T040000 RDATE:20290812T040000 RDATE:20300811T040000 RDATE:20310810T040000 RDATE:20320815T040000 RDATE:20330814T040000 RDATE:20340813T040000 RDATE:20350812T040000 RDATE:20360810T040000 RDATE:20370809T040000 END:DAYLIGHT BEGIN:DAYLIGHT TZOFFSETFROM:-0400 TZOFFSETTO:-0300 TZNAME:-03 DTSTART:20380815T000000 RRULE:FREQ=YEARLY;BYMONTH=8;BYDAY=2SU END:DAYLIGHT END:VTIMEZONE BEGIN:VEVENT DTSTART;TZID=America/Santiago:20170309T100000 DTEND;TZID=America/Santiago:20170309T110000 RRULE:FREQ=MONTHLY;INTERVAL=30;BYMONTHDAY=9 DTSTAMP:20170310T172720Z UID:80rl9kuu5bq49gme99eklov27k@google.com CREATED:20170310T172400Z DESCRIPTION: LAST-MODIFIED:20170310T172400Z LOCATION: SEQUENCE:0 STATUS:CONFIRMED SUMMARY:TestEvent TRANSP:OPAQUE END:VEVENT END:VCALENDAR ================================================ FILE: tests/mocks/calendar_test_full_day_events.ics ================================================ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//ical.marudot.com//iCal Event Maker CALSCALE:GREGORIAN BEGIN:VTIMEZONE TZID:Europe/Berlin LAST-MODIFIED:20231222T233358Z TZURL:https://www.tzurl.org/zoneinfo-outlook/Europe/Berlin X-LIC-LOCATION:Europe/Berlin BEGIN:DAYLIGHT TZNAME:CEST TZOFFSETFROM:+0100 TZOFFSETTO:+0200 DTSTART:19700329T020000 RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU END:DAYLIGHT BEGIN:STANDARD TZNAME:CET TZOFFSETFROM:+0200 TZOFFSETTO:+0100 DTSTART:19701025T030000 RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU END:STANDARD END:VTIMEZONE BEGIN:VEVENT DTSTAMP:20240306T225415Z UID:1709765647426-75770@ical.marudot.com DTSTART;VALUE=DATE:20240306 RRULE:FREQ=DAILY DTEND;VALUE=DATE:20240307 SUMMARY:daily full days END:VEVENT END:VCALENDAR ================================================ FILE: tests/mocks/calendar_test_icons.ics ================================================ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//ical.marudot.com//iCal Event Maker X-WR-CALNAME:TestEvents NAME:TestEvents CALSCALE:GREGORIAN BEGIN:VTIMEZONE TZID:Europe/Berlin TZURL:http://tzurl.org/zoneinfo-outlook/Europe/Berlin X-LIC-LOCATION:Europe/Berlin BEGIN:DAYLIGHT TZOFFSETFROM:+0100 TZOFFSETTO:+0200 TZNAME:CEST DTSTART:19700329T020000 RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU END:DAYLIGHT BEGIN:STANDARD TZOFFSETFROM:+0200 TZOFFSETTO:+0100 TZNAME:CET DTSTART:19701025T030000 RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU END:STANDARD END:VTIMEZONE BEGIN:VEVENT DTSTAMP:20200719T094531Z UID:20200719T094531Z-1871115387@marudot.com DTSTART;TZID=Europe/Berlin:20300101T120000 DTEND;TZID=Europe/Berlin:20300101T130000 SUMMARY:TestEvent END:VEVENT BEGIN:VEVENT DTSTAMP:20200719T094531Z UID:20200719T094531Z-1929725136@marudot.com DTSTART;TZID=Europe/Berlin:20300701T120000 RRULE:FREQ=YEARLY;BYMONTH=7;BYMONTHDAY=1 DTEND;TZID=Europe/Berlin:20300701T130000 SUMMARY:TestEventRepeat END:VEVENT BEGIN:VEVENT DTSTAMP:20200719T094531Z UID:20200719T094531Z-371801474@marudot.com DTSTART;VALUE=DATE:20300401 DTEND;VALUE=DATE:20300402 SUMMARY:TestEventDay END:VEVENT BEGIN:VEVENT DTSTAMP:20200719T094531Z UID:20200719T094531Z-133401084@marudot.com DTSTART;VALUE=DATE:20301001 RRULE:FREQ=YEARLY;BYMONTH=10;BYMONTHDAY=1 DTEND;VALUE=DATE:20301002 SUMMARY:TestEventRepeatDay END:VEVENT BEGIN:VEVENT DTSTAMP:20200721T094531Z UID:20200719T094531Z-167389794@marudot.com DTSTART;TZID=Europe/Berlin:20301112T120000 DTEND;TZID=Europe/Berlin:20301112T130000 SUMMARY:TestEventCustomEventIcon END:VEVENT END:VCALENDAR ================================================ FILE: tests/mocks/calendar_test_multi_day_starting_today.ics ================================================ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//ical.marudot.com//iCal Event Maker CALSCALE:GREGORIAN BEGIN:VTIMEZONE TZID:Europe/Berlin LAST-MODIFIED:20231222T233358Z TZURL:https://www.tzurl.org/zoneinfo-outlook/Europe/Berlin X-LIC-LOCATION:Europe/Berlin BEGIN:DAYLIGHT TZNAME:CEST TZOFFSETFROM:+0100 TZOFFSETTO:+0200 DTSTART:19700329T020000 RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU END:DAYLIGHT BEGIN:STANDARD TZNAME:CET TZOFFSETFROM:+0200 TZOFFSETTO:+0100 DTSTART:19701025T030000 RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU END:STANDARD END:VTIMEZONE BEGIN:VEVENT DTSTAMP:20240306T222634Z UID:1709763965312-82782@ical.marudot.com DTSTART;VALUE=DATE:20240301 RRULE:FREQ=DAILY DTEND;VALUE=DATE:20240303 SUMMARY:2 day events END:VEVENT END:VCALENDAR ================================================ FILE: tests/mocks/calendar_test_recurring.ics ================================================ BEGIN:VCALENDAR PRODID:-//Google Inc//Google Calendar 70.9054//EN VERSION:2.0 CALSCALE:GREGORIAN METHOD:PUBLISH X-WR-CALNAME:xxx@gmail.com X-WR-TIMEZONE:Europe/Zurich BEGIN:VTIMEZONE TZID:Etc/UTC X-LIC-LOCATION:Etc/UTC BEGIN:STANDARD TZOFFSETFROM:+0000 TZOFFSETTO:+0000 TZNAME:GMT DTSTART:19700101T000000 END:STANDARD END:VTIMEZONE BEGIN:VEVENT DTSTART;VALUE=DATE:20210325 DTEND;VALUE=DATE:20210326 RRULE:FREQ=YEARLY;WKST=MO;INTERVAL=1 DTSTAMP:20210421T154106Z UID:zzz@google.com REATED:20200831T200244Z DESCRIPTION: LAST-MODIFIED:20200831T200244Z LOCATION: SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Birthday TRANSP:OPAQUE BEGIN:VALARM ACTION:DISPLAY DESCRIPTION:This is an event reminder TRIGGER:-P0DT7H0M0S END:VALARM END:VEVENT ================================================ FILE: tests/mocks/chicago-nyedge.ics ================================================ 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 ================================================ FILE: tests/mocks/chicago_late_in_timezone.ics ================================================ BEGIN:VEVENT CREATED:20240904T053053Z DTEND;TZID=America/Chicago:20240910T211500 DTSTAMP:20240925T005517Z DTSTART;TZID=America/Chicago:20240910T201500 LAST-MODIFIED:20240925T005515Z LOCATION:Dance Class RELATED-TO;RELTYPE=X-CALENDARSERVER-RECURRENCE-SET:2D48CA37-FCE5-4E16-871 9-1F47160BDBA3 RRULE:FREQ=WEEKLY;UNTIL=20250601T011500Z SEQUENCE:3 SUMMARY:Wife Barre Class UID:39669340-7AFD-4685-9BD6-6CE4B715486E X-APPLE-CREATOR-IDENTITY:com.apple.mobilecal END:VEVENT ================================================ FILE: tests/mocks/compliments_file.json ================================================ { "anytime": ["test in morning"] } ================================================ FILE: tests/mocks/compliments_test.json ================================================ { "anytime": ["Remote compliment file works!"] } ================================================ FILE: tests/mocks/diff_tz_start_end.ics ================================================ BEGIN:VCALENDAR BEGIN:VEVENT DTSTART:20241029T100000Z DTEND:20241030T230000Z DTSTAMP:20241022T203806Z UID:04ivnntdi20rqsk0iesabsdhmj@google.com CREATED:20241022T203738Z LAST-MODIFIED:20241022T203738Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:start/end on diff tz TRANSP:OPAQUE END:VEVENT END:VCALENDAR ================================================ FILE: tests/mocks/end_of_day_berlin_moved.ics ================================================ BEGIN:VCALENDAR PRODID:-//Google Inc//Google Calendar 70.9054//EN VERSION:2.0 CALSCALE:GREGORIAN METHOD:PUBLISH X-WR-CALNAME:test for mirror X-WR-TIMEZONE:America/Chicago X-WR-CALDESC:used to test mirror BEGIN:VTIMEZONE TZID:Europe/Berlin X-LIC-LOCATION:Europe/Berlin BEGIN:DAYLIGHT TZOFFSETFROM:+0100 TZOFFSETTO:+0200 TZNAME:GMT+2 DTSTART:19700329T020000 RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU END:DAYLIGHT BEGIN:STANDARD TZOFFSETFROM:+0200 TZOFFSETTO:+0100 TZNAME:GMT+1 DTSTART:19701025T030000 RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU END:STANDARD END:VTIMEZONE BEGIN:VEVENT DTSTART;TZID=Europe/Berlin:20241021T230000 DTEND;TZID=Europe/Berlin:20241022T000000 RRULE:FREQ=DAILY;WKST=SU;COUNT=3 DTSTAMP:20241019T133432Z UID:0kj3dtvgskhhpli1392n111145@google.com CREATED:20241018T213040Z LAST-MODIFIED:20241018T213126Z SEQUENCE:1 STATUS:CONFIRMED SUMMARY:test TRANSP:OPAQUE END:VEVENT BEGIN:VEVENT DTSTART;TZID=Europe/Berlin:20241024T230000 DTEND;TZID=Europe/Berlin:20241025T000000 DTSTAMP:20241019T133432Z UID:0kj3dtvgskhhpli1392n111145@google.com RECURRENCE-ID;TZID=Europe/Berlin:20241021T230000 CREATED:20241018T213040Z LAST-MODIFIED:20241018T213126Z SEQUENCE:2 STATUS:CONFIRMED SUMMARY:test TRANSP:OPAQUE END:VEVENT END:VCALENDAR ================================================ FILE: tests/mocks/event_with_time_over_multiple_days_non_repeating.ics ================================================ BEGIN:VCALENDAR BEGIN:VEVENT DTSTART:20241026T010000Z DTEND:20241026T110000Z DTSTAMP:20241024T153358Z UID:4maud6s79m41a99pj2g7j5km0a@google.com CREATED:20241024T153313Z LAST-MODIFIED:20241024T153330Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:Sleep over at Bobs TRANSP:OPAQUE END:VEVENT END:VCALENDAR ================================================ FILE: tests/mocks/exdate_and_recurrence_together.ics ================================================ BEGIN:VCALENDAR BEGIN:VEVENT DTSTART;TZID=America/Los_Angeles:20241023T143000 DTEND;TZID=America/Los_Angeles:20241023T153000 RRULE:FREQ=DAILY;COUNT=4 EXDATE;TZID=America/Los_Angeles:20241025T143000 DTSTAMP:20241021T193426Z UID:18rd721lfqpue2o08icsqek198@google.com CREATED:20241021T192450Z DESCRIPTION:we will move one entry and delete another  ending w 3 of the 4  start/end\, middle moved after end and 3rd deleted LAST-MODIFIED:20241021T193419Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:recurrence and exclusion together TRANSP:OPAQUE END:VEVENT BEGIN:VEVENT DTSTART;TZID=America/Los_Angeles:20241022T143000 DTEND;TZID=America/Los_Angeles:20241022T153000 DTSTAMP:20241021T193426Z UID:18rd721lfqpue2o08icsqek198@google.com RECURRENCE-ID;TZID=America/Los_Angeles:20241023T143000 CREATED:20241021T192450Z DESCRIPTION:we will move one entry and delete another  ending w 3 of the 4  start/end\, middle moved after end and 3rd deleted LAST-MODIFIED:20241021T193419Z SEQUENCE:1 STATUS:CONFIRMED SUMMARY:recurrence and exclusion together TRANSP:OPAQUE END:VEVENT BEGIN:VEVENT DTSTART;TZID=America/Los_Angeles:20241027T143000 DTEND;TZID=America/Los_Angeles:20241027T153000 DTSTAMP:20241021T193426Z UID:18rd721lfqpue2o08icsqek198@google.com RECURRENCE-ID;TZID=America/Los_Angeles:20241024T143000 CREATED:20241021T192450Z DESCRIPTION:we will move one entry and delete another  ending w 3 of the 4  start/end\, middle moved after end and 3rd deleted LAST-MODIFIED:20241021T193419Z SEQUENCE:1 STATUS:CONFIRMED SUMMARY:recurrence and exclusion together TRANSP:OPAQUE END:VEVENT END:VCALENDAR ================================================ FILE: tests/mocks/exdate_la_at_midnight_dst.ics ================================================ BEGIN:VEVENT DTSTART;TZID=America/Los_Angeles:20231025T170000 DTEND;TZID=America/Los_Angeles:20231025T180000 RRULE:FREQ=WEEKLY;BYDAY=WE EXDATE;TZID=America/Los_Angeles:20231101T170000 EXDATE;TZID=America/Los_Angeles:20231108T170000 DTSTAMP:20231025T233434Z UID:sdflbkasuhdb5fkauglkb@google.com CREATED:20230306T193128Z LAST-MODIFIED:20231024T222515Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:My Event TRANSP:OPAQUE END:VEVENT ================================================ FILE: tests/mocks/exdate_la_at_midnight_std.ics ================================================ BEGIN:VEVENT DTSTART;TZID=America/Los_Angeles:20231025T160000 DTEND;TZID=America/Los_Angeles:20231025T170000 RRULE:FREQ=WEEKLY;BYDAY=WE EXDATE;TZID=America/Los_Angeles:20231101T160000 EXDATE;TZID=America/Los_Angeles:20231108T160000 DTSTAMP:20231025T233434Z UID:sdflbkasuhdb5fkauglkb@google.com CREATED:20230306T193128Z LAST-MODIFIED:20231024T222515Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:My Event TRANSP:OPAQUE END:VEVENT ================================================ FILE: tests/mocks/exdate_la_before_midnight.ics ================================================ BEGIN:VEVENT DTSTART;TZID=America/Los_Angeles:20231025T150000 DTEND;TZID=America/Los_Angeles:20231025T160000 RRULE:FREQ=WEEKLY;BYDAY=WE EXDATE;TZID=America/Los_Angeles:20231101T150000 EXDATE;TZID=America/Los_Angeles:20231108T150000 DTSTAMP:20231025T233434Z UID:sdflbkasuhdb5fkauglkb@google.com CREATED:20230306T193128Z LAST-MODIFIED:20231024T222515Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:My Event TRANSP:OPAQUE END:VEVENT ================================================ FILE: tests/mocks/exdate_syd_at_midnight_dst.ics ================================================ BEGIN:VEVENT DTSTART;TZID=Australia/Sydney:20230920T110000 DTEND;TZID=Australia/Sydney:20230920T111000 RRULE:FREQ=WEEKLY;BYDAY=WE EXDATE;TZID=Australia/Sydney:20230927T110000 EXDATE;TZID=Australia/Sydney:20231004T110000 DTSTAMP:20231025T233434Z UID:sdflbkasuhdb5fkauglkb@google.com CREATED:20230306T193128Z LAST-MODIFIED:20231024T222515Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:My Event TRANSP:OPAQUE END:VEVENT ================================================ FILE: tests/mocks/exdate_syd_at_midnight_std.ics ================================================ BEGIN:VEVENT DTSTART;TZID=Australia/Sydney:20230920T100000 DTEND;TZID=Australia/Sydney:20230920T110000 RRULE:FREQ=WEEKLY;BYDAY=WE EXDATE;TZID=Australia/Sydney:20230927T100000 EXDATE;TZID=Australia/Sydney:20231004T100000 DTSTAMP:20231025T233434Z UID:sdflbkasuhdb5fkauglkb@google.com CREATED:20230306T193128Z LAST-MODIFIED:20231024T222515Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:My Event TRANSP:OPAQUE END:VEVENT ================================================ FILE: tests/mocks/exdate_syd_before_midnight.ics ================================================ BEGIN:VEVENT DTSTART;TZID=Australia/Sydney:20230920T090000 DTEND;TZID=Australia/Sydney:20230920T100000 RRULE:FREQ=WEEKLY;BYDAY=WE EXDATE;TZID=Australia/Sydney:20230927T090000 EXDATE;TZID=Australia/Sydney:20231004T090000 DTSTAMP:20231025T233434Z UID:sdflbkasuhdb5fkauglkb@google.com CREATED:20230306T193128Z LAST-MODIFIED:20231024T222515Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:My Event TRANSP:OPAQUE END:VEVENT ================================================ FILE: tests/mocks/fullday_event_over_multiple_days_nonrepeating.ics ================================================ BEGIN:VCALENDAR BEGIN:VEVENT DTSTART;VALUE=DATE:20241025 DTEND;VALUE=DATE:20241031 DTSTAMP:20241023T141110Z UID:60nobfcu0ct96jgsh5nhcia24b@google.com CREATED:20241023T141019Z DESCRIPTION:test for all day end viewing LAST-MODIFIED:20241023T141019Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:simple all day event over many days (not repeating) TRANSP:TRANSPARENT END:VEVENT END:VCALENDAR ================================================ FILE: tests/mocks/fullday_until.ics ================================================ BEGIN:VCALENDAR BEGIN:VEVENT DESCRIPTION:\n RRULE:FREQ=YEARLY;UNTIL=20250505T230000Z;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 ================================================ FILE: tests/mocks/germany_at_end_of_day_repeating.ics ================================================ BEGIN:VCALENDAR BEGIN:VEVENT DTSTART;TZID=Europe/Berlin:20241022T230000 DTEND;TZID=Europe/Berlin:20241023T000000 RRULE:FREQ=DAILY;WKST=MO;COUNT=4 DTSTAMP:20241009T153220Z UID:2m6mt1p89l2anl74915ur3hsgm@google.com CREATED:20241009T153058Z LAST-MODIFIED:20241009T153205Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:TestCal_AllDayRepeatingEvent TRANSP:TRANSPARENT END:VEVENT END:VCALENDAR ================================================ FILE: tests/mocks/newsfeed_test.xml ================================================ Rodrigo Ramírez Norambuena https://rodrigoramirez.com Temas sobre Linux, VoIP, Open Source, tecnología y lo relacionado. Fri, 21 Oct 2016 21:30:22 +0000 es-ES hourly 1 https://wordpress.org/?v=4.7.3 QPanel 0.13.0 https://rodrigoramirez.com/qpanel-0-13-0/ https://rodrigoramirez.com/qpanel-0-13-0/#comments Tue, 20 Sep 2016 11:16:08 +0000 https://rodrigoramirez.com/?p=1299 Ya está disponible la versión 0.13.0 de QPanel Para instalar esta nueva versión, la debes descargar de https://github.com/roramirez/qpanel/tree/0.13.0 En al README.md puedes encontrar las instrucciones para hacer que funcione en tu sistema. En esta nueva versión cuenta con los siguientes cambios: Se establece un limite para el reciclado del tiempo de conexión a la base […]

La entrada QPanel 0.13.0 aparece primero en Rodrigo Ramírez Norambuena.

]]>
Panel monitor callcenter | Qpanel Monitor ColasYa está disponible la versión 0.13.0 de QPanel

Para instalar esta nueva versión, la debes descargar de

En al README.md puedes encontrar las instrucciones para hacer que funcione en tu sistema.

En esta nueva versión cuenta con los siguientes cambios:

  • Se establece un limite para el reciclado del tiempo de conexión a la base de datos que contenga QueueLog. Esto evita problemas en bases de datos como MySQL que finaliza o da timeout a las conexiones.
  • Ahora la py-asterisk va dentro del archivo requirements.txt y no como submodulo del proyecto.
  • Se remueven la mayoría de las libs externas para Javascript y CSS para manejarlos desde ahora con Bower.
  • Se incluye un script para WSGI que permite su utilización con Apache.
  • Actualización para los idiomas Ruso y Portugues.

Si deseas colaborar con el proyecto puedes agregar nuevas sugerencias mediante un issue ó colaborar mediante mediante un Pull Request.

Ahora si necesitas soporte comercial para instalaciones, personalizaciones o nuevas características  lo puedes solicitar en https://boxtub.com/qpanel/

 

La entrada QPanel 0.13.0 aparece primero en Rodrigo Ramírez Norambuena.

]]>
https://rodrigoramirez.com/qpanel-0-13-0/feed/ 3
Problema VirtualBox “starting virtual machine” … https://rodrigoramirez.com/problema-virtualbox-starting-virtual-machine/ https://rodrigoramirez.com/problema-virtualbox-starting-virtual-machine/#respond Sat, 10 Sep 2016 22:50:13 +0000 https://rodrigoramirez.com/?p=1284 Después de una actualización de Debian, de la rama stretch/sid, tuve un problema con VirtualBox.  La versión que se actualizó fue a la virtualbox 5.1.4-dfsg-1+b1. El gran problema era que ninguna maquina virtual quería arrancar, se quedaba en un largo limbo con el mensaje “starting virtual machine”, como el de la imagen de a continuación. […]

La entrada Problema VirtualBox “starting virtual machine” … aparece primero en Rodrigo Ramírez Norambuena.

]]>
Después de una actualización de Debian, de la rama stretch/sid, tuve un problema con VirtualBox.  La versión que se actualizó fue a la virtualbox 5.1.4-dfsg-1+b1. El gran problema era que ninguna maquina virtual quería arrancar, se quedaba en un largo limbo con el mensaje “starting virtual machine”, como el de la imagen de a continuación.

Starting virtual machine ... VirtualBox

Ninguna, pero ninguna maquina arrancó, se quedaban en ese mensaje. Fue de esos instantes en que sudas helado … 😉

Con un poco de investigación fue a parar al archivo ~/.VirtualBox/VBoxSVC.log que indicaba

$ tail -f ~/.VirtualBox/VBoxSVC.log
 00:08:32.932717 nspr-7 Failed to open "/dev/vboxdrvu", errno=13, rc=VERR_VM_DRIVER_NOT_ACCESSIBLE
 00:08:33.555836 nspr-6 Failed to open "/dev/vboxdrvu", errno=13, rc=VERR_VM_DRIVER_NOT_ACCESSIBLE

 

Fui… algo de donde agarrarse. Mirando un poco mas se trataba de problemas con los permisos al vboxdrvu, mirando indicaba que tenía 0600.

 

$ ls -lh /dev/vboxdrvu
 crw------- 1 root root 10, 56 Sep 10 12:47 /dev/vboxdrvu

 

El tema es que deben estar en 0666,  le cambias los permisos y eso soluciona el problema 🙂

$ sudo chmod 0666 /dev/vboxdrvu
$ ls -lh /dev/vboxdrvu
 crw-rw-rw- 1 root root 10, 56 Sep 10 12:47 /dev/vboxdrvu

La entrada Problema VirtualBox “starting virtual machine” … aparece primero en Rodrigo Ramírez Norambuena.

]]>
https://rodrigoramirez.com/problema-virtualbox-starting-virtual-machine/feed/ 0
Mejorando la consola interactiva de Python https://rodrigoramirez.com/mejorando-la-consola-interactiva-python/ https://rodrigoramirez.com/mejorando-la-consola-interactiva-python/#comments Tue, 06 Sep 2016 04:24:43 +0000 https://rodrigoramirez.com/?p=1247 Cuando estás desarrollando en Python es muy cool estar utilizando la consola interactiva para ir probando cosas antes de ponerlas dentro del archivo de código fuente. La consola de Python funciona y cumple su cometido. Solo al tipear  python  te permite entrar en modo interactivo e ir probando cosas. El punto es que a veces […]

La entrada Mejorando la consola interactiva de Python aparece primero en Rodrigo Ramírez Norambuena.

]]>
Cuando estás desarrollando en Python es muy cool estar utilizando la consola interactiva para ir probando cosas antes de ponerlas dentro del archivo de código fuente.

La consola de Python funciona y cumple su cometido. Solo al tipear  python  te permite entrar en modo interactivo e ir probando cosas.

El punto es que a veces uno necesita ir un poco más allá. Como autocomentado de código o resaltado de sintaxis, para eso tengo dos truco que utilizo generalmente.

Truco a)

Este permite añadirle algunos esteriodes a la consolta, en realidad uno, el autocompletado. Esto es de gran ayuda para ir conociendo los metodo que puede tener un objecto, funciones u operaciones.

Para esto se ocupo rlcompleterreadline.

 

Lo que hace que hacer luego de tipear python es agregar lo siguiente dentro de la consola interativa

import rlcompleter, readline
readline.parse_and_bind(‘tab:complete’)

Ya con esto te permite autocomentar código 🙂

 

Truco b)

Esto es mejorar un poco más. Es utilizar embed de IPython,  ya en la consola digita (copias o pegas) lo siguiente

from IPython import embed
embed()

Y el resultado será lo que se ve a continuación… bueno, no?

 

 

Si no quieres estar escribiendo cada vez que entras, agregas estas instrucciones en tu archivo  ~/.pythonrc.py  y lo hará cada vez que entras en el modo interactivo de la consola de Python. Lo que si, tu archivo pythonrc.py debe estar seteado en variable de entorno PYTHONSTARTUP

ejemplo

export  PYTHONSTARTUP=~/.pythonrc.py

O lo agregas a un bashrc, zshrc o la shell que ocupes.

La entrada Mejorando la consola interactiva de Python aparece primero en Rodrigo Ramírez Norambuena.

]]>
https://rodrigoramirez.com/mejorando-la-consola-interactiva-python/feed/ 4
QPanel 0.12.0 con estadísticas https://rodrigoramirez.com/qpanel-0-12-0-estadisticas/ https://rodrigoramirez.com/qpanel-0-12-0-estadisticas/#respond Mon, 22 Aug 2016 04:19:03 +0000 https://rodrigoramirez.com/?p=1268 Ya está disponible una nueva versión de QPanel, esta es la 0.12.0 Para instalar esta nueva versión, debes visitar la siguiente URL https://github.com/roramirez/qpanel/tree/0.12.0 En esta nueva versión las funcionalidades agregadas son: Permite remover los agentes de las cola Posibilidad de cancelar llamadas que están en espera de atención Estadísticas por rango de fecha obtenidas desde […]

La entrada QPanel 0.12.0 con estadísticas aparece primero en Rodrigo Ramírez Norambuena.

]]>
Panel monitor callcenter | Qpanel Monitor ColasYa está disponible una nueva versión de QPanel, esta es la 0.12.0

Para instalar esta nueva versión, debes visitar la siguiente URL

En esta nueva versión las funcionalidades agregadas son:

  • Permite remover los agentes de las cola
  • Posibilidad de cancelar llamadas que están en espera de atención
  • Estadísticas por rango de fecha obtenidas desde el queue_log de Asterisk
  • Se actualiza a Flask 0.11

Si deseas colaborar con el proyecto puedes agregar nuevas sugerencias mediante un issue ó colaborar mediante mediante un Pull Request

La entrada QPanel 0.12.0 con estadísticas aparece primero en Rodrigo Ramírez Norambuena.

]]>
https://rodrigoramirez.com/qpanel-0-12-0-estadisticas/feed/ 0
QPanel 0.11.0 con Spy, Whisper y mas https://rodrigoramirez.com/qpanel-spy-supervisor/ https://rodrigoramirez.com/qpanel-spy-supervisor/#comments Thu, 21 Jul 2016 01:53:21 +0000 https://rodrigoramirez.com/?p=1245 Ya está disponible una nueva versión de QPanel, esta es la 0.11.0 Para instalar esta nueva versión, debes visitar la siguiente URL https://github.com/roramirez/qpanel/tree/0.11.0 Esta versión hemos agregado  algunas funcionalidades que los usuarios  han ido solicitando. Para esta versión es posible realizar Spy, Whisper o Barge a un canal para la supervisión de los miembros que […]

La entrada QPanel 0.11.0 con Spy, Whisper y mas aparece primero en Rodrigo Ramírez Norambuena.

]]>
Panel monitor callcenter | Qpanel Monitor ColasYa está disponible una nueva versión de QPanel, esta es la 0.11.0

Para instalar esta nueva versión, debes visitar la siguiente URL

Esta versión hemos agregado  algunas funcionalidades que los usuarios  han ido solicitando.

Para esta versión es posible realizar Spy, Whisper o Barge a un canal para la supervisión de los miembros que están en una cola.

También el sistema de plantillas se hecho una refactorización para eliminar exceso de codigo HTML usando uno de base.

Se han agregado una suite de tests unitarios que al contar del avance del proyecto deberían ir incrementando.

Se ha solucionado un bug con la actualización del color del estado del agente cuando es uno nuevo agregado a la cola.

 

El proyecto siempre está abierto a nuevas sugerencias las cuales puedes agregar mediante un issue.

La entrada QPanel 0.11.0 con Spy, Whisper y mas aparece primero en Rodrigo Ramírez Norambuena.

]]>
https://rodrigoramirez.com/qpanel-spy-supervisor/feed/ 4
Añadir Swap a un sistema https://rodrigoramirez.com/crear-swap/ https://rodrigoramirez.com/crear-swap/#respond Fri, 15 Jul 2016 05:07:43 +0000 https://rodrigoramirez.com/?p=1234 Algo que me toma generalmente hacer es cuando trabajo con maquina virtuales es asignar una cantidad determinada de Swap. La  memoria swap es un espacio de intercambio en disco para cuando el sistema ya no puede utilizar más memoria RAM. El problema para mi es que algunos sistemas de maquinas virtuales no asignan por defecto […]

La entrada Añadir Swap a un sistema aparece primero en Rodrigo Ramírez Norambuena.

]]>
Algo que me toma generalmente hacer es cuando trabajo con maquina virtuales es asignar una cantidad determinada de Swap.

La  memoria swap es un espacio de intercambio en disco para cuando el sistema ya no puede utilizar más memoria RAM.

El problema para mi es que algunos sistemas de maquinas virtuales no asignan por defecto un espacio para la Swap, lo que te lleva a que el sistema pueda tener crash durante la ejecución.

Para comprobar la asignación de memoria, al ejecutar el comando free nos debería mostrar como algo similar a lo siguiente

 

$  free -m
             total       used       free     shared    buffers     cached
Mem:           494        488          6          1         54         75
-/+ buffers/cache:        357        136
Swap:            0          0          0

En la zona de swap indica que no asignada, valor 0.

Para asignar swap al sistema se debe  un archivo en disco para que sea utilizado como espacio de intercambio, en este caso lo vamos  crear uno  de 3GB en la raíz del sistema

fallocate -l 3G /swapfile

Comprobamos que ha sido creado

$ ls -lh /swapfile
-rw-r--r-- 1 root root 3.0G Jul 11 13:10 /swapfile

Habilitación del archivo Swap

Ahora nos toca habilitar el archivo creado. Para eso le asignaremos los permisos

chmod 600 /swapfile

Lo siguiente es para convertir el  archivo para swap

mkswap /swapfile

Para habilitar y asignarla eso como memoria swap al sistema usamos

swapon /swapfile

Ya con esto podrémos ver en nuestro sistema la memoria asignada para swap

$ free -m
             total       used       free     shared    buffers     cached
Mem:           494        486          7          1         51         77
-/+ buffers/cache:        358        136
Swap:         3071          0       3071

 

Para que al reiniciar el sistema esto se mantenga, debemos agregar la siguiente línea al archivo /etc/fstab

/swapfile none swap sw 0 0

 

Podemos editar /etc/fstab con algún editor como vim, nano o podemos agregar la linea directamente en la desde la cli de la siguiente manera

echo "/swapfile none swap sw 0 0" >> /etc/fstab

 

 

La entrada Añadir Swap a un sistema aparece primero en Rodrigo Ramírez Norambuena.

]]>
https://rodrigoramirez.com/crear-swap/feed/ 0
QPanel 0.10.0 con vista consolidada https://rodrigoramirez.com/qpanel-0-10-0-vista-consolidada/ https://rodrigoramirez.com/qpanel-0-10-0-vista-consolidada/#respond Mon, 20 Jun 2016 19:32:55 +0000 https://rodrigoramirez.com/?p=1227 Ya con la release numero 28 la nueva versión 0.10.0 de QPanel ya está disponible. Para instalar esta nueva versión, debes visitar la siguiente URL https://github.com/roramirez/qpanel/tree/0.10.0 Esta versión versión nos preocupamos de realizar mejoras, refactorizaciones y agregamos una nueva funcionalidad. La nueva funcionalidad incluida es  que ahora es posible contar con una vista consolidada para […]

La entrada QPanel 0.10.0 con vista consolidada aparece primero en Rodrigo Ramírez Norambuena.

]]>
Panel monitor callcenter | Qpanel Monitor ColasYa con la release numero 28 la nueva versión 0.10.0 de QPanel ya está disponible.

Para instalar esta nueva versión, debes visitar la siguiente URL

Esta versión versión nos preocupamos de realizar mejoras, refactorizaciones y agregamos una nueva funcionalidad.

La nueva funcionalidad incluida es  que ahora es posible contar con una vista consolidada para la información de todas las colas. Que hace tener un mejor control y visualización de lo que está pasando en las colas.

El proyecto siempre está abierto a nuevas sugerencias las cuales puedes agregar mediante un issue.

La entrada QPanel 0.10.0 con vista consolidada aparece primero en Rodrigo Ramírez Norambuena.

]]>
https://rodrigoramirez.com/qpanel-0-10-0-vista-consolidada/feed/ 0
Nerdearla 2016, WebRTC Glue https://rodrigoramirez.com/nerdearla-2016/ https://rodrigoramirez.com/nerdearla-2016/#respond Wed, 15 Jun 2016 17:55:41 +0000 https://rodrigoramirez.com/?p=1218 Días atrás estuve participando en el evento llamado Nerdearla en Buenos Aires.  El ambiente era genial si eres de esas personas que desde niño sintio curiosidad por ver como funcionan las cosas, donde desarmabas para volver armar lo juguetes. Habían muchas cosas interesantes tanto en las presentaciones, co-working y workshop que se hubieron. Si te […]

La entrada Nerdearla 2016, WebRTC Glue aparece primero en Rodrigo Ramírez Norambuena.

]]>
Días atrás estuve participando en el evento llamado Nerdearla en Buenos Aires.  El ambiente era genial si eres de esas personas que desde niño sintio curiosidad por ver como funcionan las cosas, donde desarmabas para volver armar lo juguetes.

Habían muchas cosas interesantes tanto en las presentaciones, co-working y workshop que se hubieron. Si te lo perdiste te recomiendo que estés pendiente para el proximo año.

 

Te podias encontrar con una nuestra como estaKaypro II

Puedes dar un vistaso a lo registrado por algunos usuarios en Twitter

El primer día hice un workshop denominado WebRTC Glue, donde muestra como hacer como unificar la experiencia de atención del centro de contacto directamente en la web. Es una presentación práctica donde puedes ver los ejemplos y usarlos como gustes. Están en el repositorio en Gitlab. La presentación la puedes ver aquí

 

WebRTC Glue

Haber si nos vemos el próximo año.

 

Update: Puedes ver una parte sin la demostración del workshop


 

La entrada Nerdearla 2016, WebRTC Glue aparece primero en Rodrigo Ramírez Norambuena.

]]>
https://rodrigoramirez.com/nerdearla-2016/feed/ 0
QPanel 0.9.0 https://rodrigoramirez.com/qpanel-0-9-0/ https://rodrigoramirez.com/qpanel-0-9-0/#respond Mon, 09 May 2016 18:40:23 +0000 https://rodrigoramirez.com/?p=1206 El Panel monitor callcenter para colas de Asterisk ya cuenta con una nueva versión, la 0.9.0 Para instalar esta nueva versión, debes visitar la siguiente URL https://github.com/roramirez/qpanel/tree/0.9.0 Esta versión versión nos preocupamos de realizar mejoras y refactorizaciones en el codigo para dar un mejor rendimiento, como también de la compatibilidad con la versión 11 de […]

La entrada QPanel 0.9.0 aparece primero en Rodrigo Ramírez Norambuena.

]]>
Panel monitor callcenter | Qpanel Monitor ColasEl Panel monitor callcenter para colas de Asterisk ya cuenta con una nueva versión, la 0.9.0

Para instalar esta nueva versión, debes visitar la siguiente URL

Esta versión versión nos preocupamos de realizar mejoras y refactorizaciones en el codigo para dar un mejor rendimiento, como también de la compatibilidad con la versión 11 de Asterisk.

Dentro de las cosas que podamos mencionar:

  •  Actualización del repositorio y versión de py-asterisk, biblioteca para trabajar con Asterisk. Acá la ocupamos principalmente para uso del Manager.
  • Portación de parche de funcionalidades como pausa, tiempo, razón de una pausa para Asterisk 11.
  • Cambio del comportamiento en el conteo cuando el participante en una cola está ocupado (busy)

El proyecto siempre está abierto a nuevas sugerencias las cuales puedes agregar mediante un issue.

La entrada QPanel 0.9.0 aparece primero en Rodrigo Ramírez Norambuena.

]]>
https://rodrigoramirez.com/qpanel-0-9-0/feed/ 0
Mandar un email desde la shell https://rodrigoramirez.com/mandar-un-email-desde-la-shell/ https://rodrigoramirez.com/mandar-un-email-desde-la-shell/#comments Wed, 13 Apr 2016 13:05:13 +0000 https://rodrigoramirez.com/?p=1172 Dejo esto por acá ya que es algo que siempre me olvido como es. El tema es enviar un email mediante el comando mail en un servidor con Linux. Si usas mail a secas te va pidiendo los datos para crear el correo, principalmente el body del correo. Para automatizar esto a través de un […]

La entrada Mandar un email desde la shell aparece primero en Rodrigo Ramírez Norambuena.

]]>
Dejo esto por acá ya que es algo que siempre me olvido como es. El tema es enviar un email mediante el comando mail en un servidor con Linux.

Si usas mail a secas te va pidiendo los datos para crear el correo, principalmente el body del correo. Para automatizar esto a través de un echo le pasas por pipe a mail

echo "Cuerpo del mensaje" | mail -s Asunto a@rodrigoramirez.com

La entrada Mandar un email desde la shell aparece primero en Rodrigo Ramírez Norambuena.

]]>
https://rodrigoramirez.com/mandar-un-email-desde-la-shell/feed/ 4
================================================ FILE: tests/mocks/rrule_until.ics ================================================ BEGIN:VEVENT DTSTART;TZID=America/Los_Angeles:20240229T160000 DTEND;TZID=America/Los_Angeles:20240229T190000 RRULE:FREQ=WEEKLY;WKST=MO;UNTIL=20240307T075959Z;BYDAY=TH DTSTAMP:20240307T180618Z CREATED:20231231T000501Z LAST-MODIFIED:20231231T005623Z SEQUENCE:2 STATUS:CONFIRMED SUMMARY:My event TRANSP:OPAQUE END:VEVENT BEGIN:VEVENT DTSTART;TZID=America/Los_Angeles:20240307T160000 DTEND;TZID=America/Los_Angeles:20240307T190000 RRULE:FREQ=WEEKLY;WKST=MO;UNTIL=20240316T065959Z;BYDAY=TH DTSTAMP:20240307T180618Z CREATED:20231231T000501Z LAST-MODIFIED:20231231T005623Z SEQUENCE:3 STATUS:CONFIRMED SUMMARY:My event TRANSP:OPAQUE END:VEVENT ================================================ FILE: tests/mocks/sliceMultiDayEvents.ics ================================================ BEGIN:VCALENDAR PRODID:-//Google Inc//Google Calendar 70.9054//EN VERSION:2.0 CALSCALE:GREGORIAN METHOD:PUBLISH X-WR-CALNAME:Dirk Test X-WR-TIMEZONE:Europe/Berlin BEGIN:VEVENT DTSTART;VALUE=DATE:20240918 DTEND;VALUE=DATE:20240919 DTSTAMP:20240916T084410Z UID:2crbv1ijljc2kt9jclkgu5hqa0@google.com CREATED:20240916T083831Z LAST-MODIFIED:20240916T083831Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:1 day single TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20240919 DTEND;VALUE=DATE:20240920 RRULE:FREQ=YEARLY DTSTAMP:20240916T084410Z UID:6gb19havnq6vp2qput51e5rmml@google.com CREATED:20240916T083850Z LAST-MODIFIED:20240916T083850Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:1 day repeat TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20240920 DTEND;VALUE=DATE:20240922 DTSTAMP:20240916T084410Z UID:06e9u1trbqi3jbvstvq4qqutau@google.com CREATED:20240916T083902Z LAST-MODIFIED:20240916T083902Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:2 day single TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20240923 DTEND;VALUE=DATE:20240925 RRULE:FREQ=YEARLY DTSTAMP:20240916T084410Z UID:0ui78rk6hpcv8rmbb6nuonhmgg@google.com CREATED:20240916T083919Z LAST-MODIFIED:20240916T083919Z SEQUENCE:0 STATUS:CONFIRMED SUMMARY:2 day repeat TRANSP:TRANSPARENT END:VEVENT END:VCALENDAR ================================================ FILE: tests/mocks/testNotification/testNotification.js ================================================ 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; } }); ================================================ FILE: tests/mocks/translation_test.json ================================================ { "LOADING": "Loading …", "TODAY": "Today", "TOMORROW": "Tomorrow", "DAYAFTERTOMORROW": "In 2 days", "RUNNING": "Ends in", "EMPTY": "No upcoming events.", "WEEK": "Week {weekNumber}", "N": "N", "NNE": "NNE", "NE": "NE", "ENE": "ENE", "E": "E", "ESE": "ESE", "SE": "SE", "SSE": "SSE", "S": "S", "SSW": "SSW", "SW": "SW", "WSW": "WSW", "W": "W", "WNW": "WNW", "NW": "NW", "NNW": "NNW", "UPDATE_NOTIFICATION": "MagicMirror² update available.", "UPDATE_NOTIFICATION_MODULE": "Update available for MODULE_NAME module.", "UPDATE_INFO_SINGLE": "The current installation is COMMIT_COUNT commit behind on the BRANCH_NAME branch.", "UPDATE_INFO_MULTIPLE": "The current installation is COMMIT_COUNT commits behind on the BRANCH_NAME branch." } ================================================ FILE: tests/mocks/weather_current.json ================================================ { "coord": { "lon": 11.58, "lat": 48.14 }, "weather": [ { "id": 615, "main": "Snow", "description": "light rain and snow", "icon": "13d" }, { "id": 500, "main": "Rain", "description": "light rain", "icon": "10d" } ], "base": "stations", "main": { "temp": 1.49, "pressure": 1005, "humidity": 93.7, "temp_min": 1, "temp_max": 2 }, "visibility": 7000, "wind": { "speed": 11.8, "deg": 250 }, "clouds": { "all": 75 }, "dt": 1547387400, "sys": { "type": 1, "id": 1267, "message": 0.0031, "country": "DE", "sunrise": 1547362817, "sunset": 1547394301 }, "id": 2867714, "name": "Munich", "cod": 200 } ================================================ FILE: tests/mocks/weather_forecast.json ================================================ { "city": { "id": 2867714, "name": "Munich", "coord": { "lon": 11.5754, "lat": 48.1371 }, "country": "DE", "population": 1260391, "timezone": 7200 }, "cod": "200", "message": 0.9653487, "cnt": 7, "list": [ { "dt": 1568372400, "sunrise": 1568350044, "sunset": 1568395948, "temp": { "day": 24.44, "min": 15.35, "max": 24.44, "night": 15.35, "eve": 18, "morn": 23.03 }, "pressure": 1031.65, "humidity": 70, "weather": [ { "id": 801, "main": "Clouds", "description": "few clouds", "icon": "02d" } ], "speed": 3.35, "deg": 314, "clouds": 21 }, { "dt": 1568458800, "sunrise": 1568436525, "sunset": 1568482223, "temp": { "day": 20.81, "min": 13.56, "max": 21.02, "night": 13.56, "eve": 16.6, "morn": 15.88 }, "pressure": 1028.81, "humidity": 72, "weather": [ { "id": 500, "main": "Rain", "description": "light rain", "icon": "10d" } ], "speed": 2.21, "deg": 81, "clouds": 100, "pop": 0.7, "rain": 2.51 }, { "dt": 1568545200, "sunrise": 1568523007, "sunset": 1568568497, "temp": { "day": 22.65, "min": 13.76, "max": 22.88, "night": 15.27, "eve": 17.45, "morn": 13.76 }, "pressure": 1023.75, "humidity": 64, "weather": [ { "id": 800, "main": "Clear", "description": "sky is clear", "icon": "01d" } ], "speed": 1.15, "deg": 7, "clouds": 0 }, { "dt": 1568631600, "sunrise": 1568609489, "sunset": 1568654771, "temp": { "day": 23.45, "min": 13.95, "max": 23.45, "night": 13.95, "eve": 17.75, "morn": 15.21 }, "pressure": 1020.41, "humidity": 64, "weather": [ { "id": 800, "main": "Clear", "description": "sky is clear", "icon": "01d" } ], "speed": 3.07, "deg": 298, "clouds": 7 }, { "dt": 1568718000, "sunrise": 1568695970, "sunset": 1568741045, "temp": { "day": 20.55, "min": 10.95, "max": 20.55, "night": 10.95, "eve": 14.82, "morn": 13.24 }, "pressure": 1019.4, "humidity": 66, "weather": [ { "id": 800, "main": "Clear", "description": "sky is clear", "icon": "01d" } ], "speed": 2.8, "deg": 333, "clouds": 2 }, { "dt": 1568804400, "sunrise": 1568782452, "sunset": 1568827319, "temp": { "day": 18.15, "min": 7.75, "max": 18.15, "night": 7.75, "eve": 12.45, "morn": 9.41 }, "pressure": 1017.56, "humidity": 52, "weather": [ { "id": 800, "main": "Clear", "description": "sky is clear", "icon": "01d" } ], "speed": 2.92, "deg": 34, "clouds": 0 }, { "dt": 1568890800, "sunrise": 1568868934, "sunset": 1568913593, "temp": { "day": 14.85, "min": 5.56, "max": 15.05, "night": 5.56, "eve": 9.56, "morn": 6.25 }, "pressure": 1022.7, "humidity": 59, "weather": [ { "id": 800, "main": "Clear", "description": "sky is clear", "icon": "01d" } ], "speed": 2.89, "deg": 51, "clouds": 1 } ] } ================================================ FILE: tests/mocks/weather_hourly.json ================================================ { "hourly": [ { "dt": 1673204400, "temp": 27.31, "feels_like": 29.59, "pressure": 1013, "humidity": 72, "dew_point": 21.82, "uvi": 0, "clouds": 31, "visibility": 10000, "wind_speed": 2.05, "wind_deg": 200, "wind_gust": 1.91, "weather": [ { "id": 802, "main": "Clouds", "description": "Mäßig bewölkt", "icon": "03n" } ], "pop": 0 }, { "dt": 1673208000, "temp": 27.31, "feels_like": 29.69, "pressure": 1013, "humidity": 73, "dew_point": 22.04, "uvi": 0, "clouds": 30, "visibility": 10000, "wind_speed": 2.14, "wind_deg": 186, "wind_gust": 1.9, "weather": [ { "id": 802, "main": "Clouds", "description": "Mäßig bewölkt", "icon": "03n" } ], "pop": 0 }, { "dt": 1673211600, "temp": 27.29, "feels_like": 29.65, "pressure": 1013, "humidity": 73, "dew_point": 22.03, "uvi": 0, "clouds": 31, "visibility": 10000, "wind_speed": 2.16, "wind_deg": 193, "wind_gust": 1.91, "weather": [ { "id": 802, "main": "Clouds", "description": "Mäßig bewölkt", "icon": "03n" } ], "pop": 0.12 }, { "dt": 1673215200, "temp": 27.21, "feels_like": 29.6, "pressure": 1013, "humidity": 74, "dew_point": 22.17, "uvi": 0, "clouds": 32, "visibility": 10000, "wind_speed": 2.13, "wind_deg": 206, "wind_gust": 1.91, "weather": [ { "id": 500, "main": "Rain", "description": "Leichter Regen", "icon": "10n" } ], "pop": 0.36, "rain": { "1h": 0.13 } }, { "dt": 1673218800, "temp": 27.1, "feels_like": 29.39, "pressure": 1014, "humidity": 74, "dew_point": 22.07, "uvi": 0, "clouds": 38, "visibility": 10000, "wind_speed": 1.41, "wind_deg": 227, "wind_gust": 1.3, "weather": [ { "id": 500, "main": "Rain", "description": "Leichter Regen", "icon": "10n" } ], "pop": 0.44, "rain": { "1h": 0.13 } }, { "dt": 1673222400, "temp": 26.95, "feels_like": 29.19, "pressure": 1013, "humidity": 75, "dew_point": 22.14, "uvi": 0, "clouds": 41, "visibility": 10000, "wind_speed": 1.65, "wind_deg": 227, "wind_gust": 1.5, "weather": [ { "id": 802, "main": "Clouds", "description": "Mäßig bewölkt", "icon": "03n" } ], "pop": 0.52 }, { "dt": 1673226000, "temp": 26.72, "feels_like": 28.83, "pressure": 1012, "humidity": 76, "dew_point": 22.15, "uvi": 0, "clouds": 22, "visibility": 10000, "wind_speed": 1.88, "wind_deg": 218, "wind_gust": 1.71, "weather": [ { "id": 801, "main": "Clouds", "description": "Ein paar Wolken", "icon": "02n" } ], "pop": 0.08 }, { "dt": 1673229600, "temp": 26.57, "feels_like": 26.57, "pressure": 1012, "humidity": 76, "dew_point": 22.05, "uvi": 0, "clouds": 20, "visibility": 10000, "wind_speed": 1.51, "wind_deg": 221, "wind_gust": 1.3, "weather": [ { "id": 801, "main": "Clouds", "description": "Ein paar Wolken", "icon": "02n" } ], "pop": 0.08 }, { "dt": 1673233200, "temp": 26.46, "feels_like": 26.46, "pressure": 1011, "humidity": 77, "dew_point": 22.12, "uvi": 0, "clouds": 32, "visibility": 10000, "wind_speed": 1.71, "wind_deg": 210, "wind_gust": 1.52, "weather": [ { "id": 802, "main": "Clouds", "description": "Mäßig bewölkt", "icon": "03n" } ], "pop": 0.04 }, { "dt": 1673236800, "temp": 26.38, "feels_like": 26.38, "pressure": 1011, "humidity": 78, "dew_point": 22.22, "uvi": 0, "clouds": 49, "visibility": 10000, "wind_speed": 1.84, "wind_deg": 213, "wind_gust": 1.61, "weather": [ { "id": 802, "main": "Clouds", "description": "Mäßig bewölkt", "icon": "03n" } ], "pop": 0 }, { "dt": 1673240400, "temp": 26.32, "feels_like": 26.32, "pressure": 1012, "humidity": 78, "dew_point": 22.12, "uvi": 0, "clouds": 48, "visibility": 10000, "wind_speed": 1.83, "wind_deg": 216, "wind_gust": 1.6, "weather": [ { "id": 802, "main": "Clouds", "description": "Mäßig bewölkt", "icon": "03n" } ], "pop": 0 }, { "dt": 1673244000, "temp": 26.32, "feels_like": 26.32, "pressure": 1012, "humidity": 78, "dew_point": 22.26, "uvi": 0, "clouds": 43, "visibility": 10000, "wind_speed": 2.11, "wind_deg": 205, "wind_gust": 1.72, "weather": [ { "id": 802, "main": "Clouds", "description": "Mäßig bewölkt", "icon": "03n" } ], "pop": 0 }, { "dt": 1673247600, "temp": 26.44, "feels_like": 26.44, "pressure": 1013, "humidity": 79, "dew_point": 22.44, "uvi": 0.53, "clouds": 90, "visibility": 10000, "wind_speed": 2.78, "wind_deg": 207, "wind_gust": 2.51, "weather": [ { "id": 804, "main": "Clouds", "description": "Bedeckt", "icon": "04d" } ], "pop": 0 }, { "dt": 1673251200, "temp": 26.45, "feels_like": 26.45, "pressure": 1013, "humidity": 78, "dew_point": 22.22, "uvi": 2.13, "clouds": 93, "visibility": 10000, "wind_speed": 2.43, "wind_deg": 190, "wind_gust": 2.21, "weather": [ { "id": 804, "main": "Clouds", "description": "Bedeckt", "icon": "04d" } ], "pop": 0 }, { "dt": 1673254800, "temp": 26.54, "feels_like": 26.54, "pressure": 1014, "humidity": 78, "dew_point": 22.32, "uvi": 4.92, "clouds": 68, "visibility": 10000, "wind_speed": 3.04, "wind_deg": 188, "wind_gust": 2.91, "weather": [ { "id": 803, "main": "Clouds", "description": "Überwiegend bewölkt", "icon": "04d" } ], "pop": 0 }, { "dt": 1673258400, "temp": 26.61, "feels_like": 26.61, "pressure": 1013, "humidity": 77, "dew_point": 22.28, "uvi": 8.04, "clouds": 56, "visibility": 10000, "wind_speed": 3.37, "wind_deg": 183, "wind_gust": 3.22, "weather": [ { "id": 803, "main": "Clouds", "description": "Überwiegend bewölkt", "icon": "04d" } ], "pop": 0 }, { "dt": 1673262000, "temp": 26.76, "feels_like": 28.9, "pressure": 1013, "humidity": 76, "dew_point": 22.24, "uvi": 10.6, "clouds": 62, "visibility": 10000, "wind_speed": 3.51, "wind_deg": 175, "wind_gust": 3.4, "weather": [ { "id": 803, "main": "Clouds", "description": "Überwiegend bewölkt", "icon": "04d" } ], "pop": 0 }, { "dt": 1673265600, "temp": 26.91, "feels_like": 29.11, "pressure": 1012, "humidity": 75, "dew_point": 22.24, "uvi": 11.58, "clouds": 54, "visibility": 10000, "wind_speed": 3.82, "wind_deg": 174, "wind_gust": 3.8, "weather": [ { "id": 803, "main": "Clouds", "description": "Überwiegend bewölkt", "icon": "04d" } ], "pop": 0 }, { "dt": 1673269200, "temp": 27.04, "feels_like": 29.27, "pressure": 1011, "humidity": 74, "dew_point": 22.02, "uvi": 10.65, "clouds": 84, "visibility": 10000, "wind_speed": 4.06, "wind_deg": 177, "wind_gust": 4.02, "weather": [ { "id": 803, "main": "Clouds", "description": "Überwiegend bewölkt", "icon": "04d" } ], "pop": 0 }, { "dt": 1673272800, "temp": 27.12, "feels_like": 29.33, "pressure": 1011, "humidity": 73, "dew_point": 21.94, "uvi": 8.07, "clouds": 81, "visibility": 10000, "wind_speed": 3.75, "wind_deg": 187, "wind_gust": 3.6, "weather": [ { "id": 803, "main": "Clouds", "description": "Überwiegend bewölkt", "icon": "04d" } ], "pop": 0 }, { "dt": 1673276400, "temp": 27.17, "feels_like": 29.33, "pressure": 1010, "humidity": 72, "dew_point": 21.8, "uvi": 4.84, "clouds": 87, "visibility": 10000, "wind_speed": 3.35, "wind_deg": 177, "wind_gust": 3.2, "weather": [ { "id": 804, "main": "Clouds", "description": "Bedeckt", "icon": "04d" } ], "pop": 0 }, { "dt": 1673280000, "temp": 27.28, "feels_like": 29.43, "pressure": 1011, "humidity": 71, "dew_point": 21.56, "uvi": 2.16, "clouds": 90, "visibility": 10000, "wind_speed": 2.35, "wind_deg": 177, "wind_gust": 2.21, "weather": [ { "id": 804, "main": "Clouds", "description": "Bedeckt", "icon": "04d" } ], "pop": 0 }, { "dt": 1673283600, "temp": 27.28, "feels_like": 29.43, "pressure": 1011, "humidity": 71, "dew_point": 21.52, "uvi": 0.54, "clouds": 88, "visibility": 10000, "wind_speed": 2.36, "wind_deg": 173, "wind_gust": 2.22, "weather": [ { "id": 804, "main": "Clouds", "description": "Bedeckt", "icon": "04d" } ], "pop": 0 }, { "dt": 1673287200, "temp": 27.34, "feels_like": 29.54, "pressure": 1012, "humidity": 71, "dew_point": 21.62, "uvi": 0, "clouds": 77, "visibility": 10000, "wind_speed": 2.14, "wind_deg": 172, "wind_gust": 2.01, "weather": [ { "id": 803, "main": "Clouds", "description": "Überwiegend bewölkt", "icon": "04d" } ], "pop": 0 }, { "dt": 1673290800, "temp": 27.25, "feels_like": 29.38, "pressure": 1013, "humidity": 71, "dew_point": 21.55, "uvi": 0, "clouds": 47, "visibility": 10000, "wind_speed": 1.62, "wind_deg": 158, "wind_gust": 1.51, "weather": [ { "id": 802, "main": "Clouds", "description": "Mäßig bewölkt", "icon": "03n" } ], "pop": 0 }, { "dt": 1673294400, "temp": 27.25, "feels_like": 29.38, "pressure": 1014, "humidity": 71, "dew_point": 21.52, "uvi": 0, "clouds": 29, "visibility": 10000, "wind_speed": 1.53, "wind_deg": 126, "wind_gust": 1.41, "weather": [ { "id": 802, "main": "Clouds", "description": "Mäßig bewölkt", "icon": "03n" } ], "pop": 0 }, { "dt": 1673298000, "temp": 27.17, "feels_like": 29.24, "pressure": 1015, "humidity": 71, "dew_point": 21.55, "uvi": 0, "clouds": 24, "visibility": 10000, "wind_speed": 1.16, "wind_deg": 115, "wind_gust": 1, "weather": [ { "id": 801, "main": "Clouds", "description": "Ein paar Wolken", "icon": "02n" } ], "pop": 0 }, { "dt": 1673301600, "temp": 27.07, "feels_like": 29.06, "pressure": 1015, "humidity": 71, "dew_point": 21.45, "uvi": 0, "clouds": 21, "visibility": 10000, "wind_speed": 1.13, "wind_deg": 164, "wind_gust": 1, "weather": [ { "id": 801, "main": "Clouds", "description": "Ein paar Wolken", "icon": "02n" } ], "pop": 0 }, { "dt": 1673305200, "temp": 26.99, "feels_like": 29.09, "pressure": 1014, "humidity": 73, "dew_point": 21.77, "uvi": 0, "clouds": 19, "visibility": 10000, "wind_speed": 1.85, "wind_deg": 173, "wind_gust": 1.72, "weather": [ { "id": 801, "main": "Clouds", "description": "Ein paar Wolken", "icon": "02n" } ], "pop": 0 }, { "dt": 1673308800, "temp": 26.83, "feels_like": 28.8, "pressure": 1014, "humidity": 73, "dew_point": 21.66, "uvi": 0, "clouds": 26, "visibility": 10000, "wind_speed": 1.83, "wind_deg": 170, "wind_gust": 1.71, "weather": [ { "id": 802, "main": "Clouds", "description": "Mäßig bewölkt", "icon": "03n" } ], "pop": 0 }, { "dt": 1673312400, "temp": 26.68, "feels_like": 28.54, "pressure": 1013, "humidity": 73, "dew_point": 21.52, "uvi": 0, "clouds": 80, "visibility": 10000, "wind_speed": 0.93, "wind_deg": 164, "wind_gust": 0.9, "weather": [ { "id": 803, "main": "Clouds", "description": "Überwiegend bewölkt", "icon": "04n" } ], "pop": 0 }, { "dt": 1673316000, "temp": 26.54, "feels_like": 26.54, "pressure": 1013, "humidity": 74, "dew_point": 21.46, "uvi": 0, "clouds": 70, "visibility": 10000, "wind_speed": 0.98, "wind_deg": 156, "wind_gust": 0.91, "weather": [ { "id": 803, "main": "Clouds", "description": "Überwiegend bewölkt", "icon": "04n" } ], "pop": 0 }, { "dt": 1673319600, "temp": 26.54, "feels_like": 26.54, "pressure": 1012, "humidity": 75, "dew_point": 21.8, "uvi": 0, "clouds": 52, "visibility": 10000, "wind_speed": 2.26, "wind_deg": 173, "wind_gust": 2.2, "weather": [ { "id": 803, "main": "Clouds", "description": "Überwiegend bewölkt", "icon": "04n" } ], "pop": 0 }, { "dt": 1673323200, "temp": 26.43, "feels_like": 26.43, "pressure": 1012, "humidity": 75, "dew_point": 21.75, "uvi": 0, "clouds": 43, "visibility": 10000, "wind_speed": 2.12, "wind_deg": 173, "wind_gust": 2, "weather": [ { "id": 802, "main": "Clouds", "description": "Mäßig bewölkt", "icon": "03n" } ], "pop": 0 }, { "dt": 1673326800, "temp": 26.38, "feels_like": 26.38, "pressure": 1013, "humidity": 76, "dew_point": 21.91, "uvi": 0, "clouds": 42, "visibility": 10000, "wind_speed": 2.57, "wind_deg": 165, "wind_gust": 2.5, "weather": [ { "id": 802, "main": "Clouds", "description": "Mäßig bewölkt", "icon": "03n" } ], "pop": 0 }, { "dt": 1673330400, "temp": 26.36, "feels_like": 26.36, "pressure": 1013, "humidity": 77, "dew_point": 21.97, "uvi": 0, "clouds": 42, "visibility": 10000, "wind_speed": 2.92, "wind_deg": 167, "wind_gust": 2.91, "weather": [ { "id": 802, "main": "Clouds", "description": "Mäßig bewölkt", "icon": "03n" } ], "pop": 0 }, { "dt": 1673334000, "temp": 26.45, "feels_like": 26.45, "pressure": 1014, "humidity": 77, "dew_point": 22.06, "uvi": 0.52, "clouds": 96, "visibility": 10000, "wind_speed": 3.09, "wind_deg": 185, "wind_gust": 3.1, "weather": [ { "id": 804, "main": "Clouds", "description": "Bedeckt", "icon": "04d" } ], "pop": 0 }, { "dt": 1673337600, "temp": 26.54, "feels_like": 26.54, "pressure": 1014, "humidity": 77, "dew_point": 22.14, "uvi": 2.1, "clouds": 87, "visibility": 10000, "wind_speed": 3.38, "wind_deg": 176, "wind_gust": 3.4, "weather": [ { "id": 804, "main": "Clouds", "description": "Bedeckt", "icon": "04d" } ], "pop": 0 }, { "dt": 1673341200, "temp": 26.63, "feels_like": 26.63, "pressure": 1014, "humidity": 77, "dew_point": 22.24, "uvi": 4.86, "clouds": 83, "visibility": 10000, "wind_speed": 3.4, "wind_deg": 179, "wind_gust": 3.4, "weather": [ { "id": 803, "main": "Clouds", "description": "Überwiegend bewölkt", "icon": "04d" } ], "pop": 0 }, { "dt": 1673344800, "temp": 26.62, "feels_like": 26.62, "pressure": 1014, "humidity": 77, "dew_point": 22.23, "uvi": 8.38, "clouds": 72, "visibility": 10000, "wind_speed": 3.47, "wind_deg": 178, "wind_gust": 3.5, "weather": [ { "id": 803, "main": "Clouds", "description": "Überwiegend bewölkt", "icon": "04d" } ], "pop": 0 }, { "dt": 1673348400, "temp": 26.71, "feels_like": 28.81, "pressure": 1014, "humidity": 76, "dew_point": 22.32, "uvi": 11.06, "clouds": 62, "visibility": 10000, "wind_speed": 3.82, "wind_deg": 178, "wind_gust": 3.81, "weather": [ { "id": 803, "main": "Clouds", "description": "Überwiegend bewölkt", "icon": "04d" } ], "pop": 0 }, { "dt": 1673352000, "temp": 26.81, "feels_like": 29, "pressure": 1013, "humidity": 76, "dew_point": 22.32, "uvi": 12.08, "clouds": 57, "visibility": 10000, "wind_speed": 4.38, "wind_deg": 181, "wind_gust": 4.42, "weather": [ { "id": 803, "main": "Clouds", "description": "Überwiegend bewölkt", "icon": "04d" } ], "pop": 0 }, { "dt": 1673355600, "temp": 26.91, "feels_like": 29.19, "pressure": 1012, "humidity": 76, "dew_point": 22.32, "uvi": 11.21, "clouds": 14, "visibility": 10000, "wind_speed": 4.96, "wind_deg": 183, "wind_gust": 5.01, "weather": [ { "id": 801, "main": "Clouds", "description": "Ein paar Wolken", "icon": "02d" } ], "pop": 0 }, { "dt": 1673359200, "temp": 27.02, "feels_like": 29.32, "pressure": 1012, "humidity": 75, "dew_point": 22.23, "uvi": 8.49, "clouds": 13, "visibility": 10000, "wind_speed": 4.72, "wind_deg": 179, "wind_gust": 4.82, "weather": [ { "id": 801, "main": "Clouds", "description": "Ein paar Wolken", "icon": "02d" } ], "pop": 0 }, { "dt": 1673362800, "temp": 27.03, "feels_like": 29.25, "pressure": 1011, "humidity": 74, "dew_point": 22.14, "uvi": 5.1, "clouds": 14, "visibility": 10000, "wind_speed": 4.15, "wind_deg": 180, "wind_gust": 4.22, "weather": [ { "id": 801, "main": "Clouds", "description": "Ein paar Wolken", "icon": "02d" } ], "pop": 0 }, { "dt": 1673366400, "temp": 27.12, "feels_like": 29.42, "pressure": 1011, "humidity": 74, "dew_point": 22.03, "uvi": 2.21, "clouds": 13, "visibility": 10000, "wind_speed": 3.61, "wind_deg": 174, "wind_gust": 3.71, "weather": [ { "id": 801, "main": "Clouds", "description": "Ein paar Wolken", "icon": "02d" } ], "pop": 0 }, { "dt": 1673370000, "temp": 27.1, "feels_like": 29.29, "pressure": 1012, "humidity": 73, "dew_point": 21.92, "uvi": 0.55, "clouds": 11, "visibility": 10000, "wind_speed": 3.48, "wind_deg": 171, "wind_gust": 3.5, "weather": [ { "id": 801, "main": "Clouds", "description": "Ein paar Wolken", "icon": "02d" } ], "pop": 0 }, { "dt": 1673373600, "temp": 27.18, "feels_like": 29.54, "pressure": 1012, "humidity": 74, "dew_point": 22.05, "uvi": 0, "clouds": 9, "visibility": 10000, "wind_speed": 3.39, "wind_deg": 170, "wind_gust": 3.51, "weather": [ { "id": 800, "main": "Clear", "description": "Klarer Himmel", "icon": "01d" } ], "pop": 0 } ] } ================================================ FILE: tests/mocks/whole_day_moved_over_dst_change_berlin.ics ================================================ BEGIN:VCALENDAR BEGIN:VEVENT DTSTART;VALUE=DATE:20241027 DTEND;VALUE=DATE:20241028 RRULE:FREQ=DAILY;WKST=SU;COUNT=3 DTSTAMP:20241020T152634Z UID:14nv8jl8d6dvdbl477lod4fftf@google.com CREATED:20241020T152434Z LAST-MODIFIED:20241020T152536Z SEQUENCE:1 STATUS:CONFIRMED SUMMARY:test whole day moved TRANSP:TRANSPARENT END:VEVENT BEGIN:VEVENT DTSTART;VALUE=DATE:20241030 DTEND;VALUE=DATE:20241031 DTSTAMP:20241020T152634Z UID:14nv8jl8d6dvdbl477lod4fftf@google.com RECURRENCE-ID;VALUE=DATE:20241028 CREATED:20241020T152434Z LAST-MODIFIED:20241020T152536Z SEQUENCE:2 STATUS:CONFIRMED SUMMARY:test whole day moved TRANSP:TRANSPARENT END:VEVENT END:VCALENDAR ================================================ FILE: tests/unit/classes/class_spec.js ================================================ const path = require("node:path"); const { JSDOM } = require("jsdom"); describe("File js/class", () => { describe("Test function cloneObject", () => { let clone; let dom; beforeAll(() => { return new Promise((done) => { dom = new JSDOM( `\ \