From dbe3ade0dc0a63c7b6f0f0957a66b87da1d86972 Mon Sep 17 00:00:00 2001 From: wehub-resource-sync Date: Mon, 13 Jul 2026 12:19:24 +0800 Subject: [PATCH] chore: import upstream snapshot with attribution --- .editorconfig | 15 + .gitattributes | 56 + .github/CODE_OF_CONDUCT.md | 137 + .github/CONTRIBUTING.md | 34 + .github/FUNDING.yaml | 2 + .github/ISSUE_TEMPLATE/bug_report.yml | 154 + .github/ISSUE_TEMPLATE/change_request.yml | 41 + .github/ISSUE_TEMPLATE/config.yml | 14 + .github/ISSUE_TEMPLATE/feature_request.yml | 67 + .github/PULL_REQUEST_TEMPLATE.md | 21 + .github/SECURITY.md | 31 + .github/dependabot.yaml | 18 + .github/header.png | Bin 0 -> 15203 bytes .github/workflows/automated-tests.yaml | 72 + .github/workflows/codeql.yaml | 37 + .github/workflows/dep-review.yaml | 18 + .github/workflows/electron-rebuild.yaml | 31 + .../workflows/enforce-pullrequest-rules.yaml | 29 + .github/workflows/release-notes.yaml | 33 + .github/workflows/spellcheck.yaml | 31 + .github/workflows/stale.yaml | 22 + .gitignore | 83 + .husky/pre-commit | 5 + .npmrc | 3 + .prettierignore | 8 + CHANGELOG.md | 1879 ++++ Collaboration.md | 85 + LICENSE.md | 17 + README.md | 60 + README.wehub.md | 7 + clientonly/index.js | 167 + cspell.config.json | 383 + css/font-awesome.css | 2 + css/main.css | 266 + css/roboto.css | 671 ++ defaultmodules/alert/README.md | 5 + defaultmodules/alert/alert.js | 144 + defaultmodules/alert/notificationFx.js | 157 + defaultmodules/alert/styles/center.css | 5 + defaultmodules/alert/styles/left.css | 4 + .../alert/styles/notificationFx.css | 929 ++ defaultmodules/alert/styles/right.css | 4 + defaultmodules/alert/templates/alert.njk | 20 + .../alert/templates/notification.njk | 7 + defaultmodules/alert/translations/bg.json | 4 + defaultmodules/alert/translations/da.json | 4 + defaultmodules/alert/translations/de.json | 4 + defaultmodules/alert/translations/el.json | 4 + defaultmodules/alert/translations/en.json | 4 + defaultmodules/alert/translations/eo.json | 4 + defaultmodules/alert/translations/es.json | 4 + defaultmodules/alert/translations/fr.json | 4 + defaultmodules/alert/translations/hu.json | 4 + defaultmodules/alert/translations/nl.json | 4 + defaultmodules/alert/translations/pt-br.json | 4 + defaultmodules/alert/translations/pt.json | 4 + defaultmodules/alert/translations/ru.json | 4 + defaultmodules/alert/translations/th.json | 4 + defaultmodules/calendar/README.md | 6 + defaultmodules/calendar/calendar.css | 15 + defaultmodules/calendar/calendar.js | 1045 ++ defaultmodules/calendar/calendarfetcher.js | 141 + .../calendar/calendarfetcherutils.js | 306 + defaultmodules/calendar/calendarutils.js | 128 + defaultmodules/calendar/debug.js | 40 + defaultmodules/calendar/node_helper.js | 101 + defaultmodules/calendar/windowsZones.json | 237 + defaultmodules/clock/README.md | 6 + defaultmodules/clock/clock.js | 318 + defaultmodules/clock/clock_styles.css | 118 + defaultmodules/clock/faces/face-001.svg | 1 + defaultmodules/clock/faces/face-002.svg | 1 + defaultmodules/clock/faces/face-003.svg | 1 + defaultmodules/clock/faces/face-004.svg | 1 + defaultmodules/clock/faces/face-005.svg | 1 + defaultmodules/clock/faces/face-006.svg | 1 + defaultmodules/clock/faces/face-007.svg | 1 + defaultmodules/clock/faces/face-008.svg | 1 + defaultmodules/clock/faces/face-009.svg | 1 + defaultmodules/clock/faces/face-010.svg | 1 + defaultmodules/clock/faces/face-011.svg | 1 + defaultmodules/clock/faces/face-012.svg | 1 + defaultmodules/compliments/README.md | 6 + defaultmodules/compliments/compliments.js | 316 + defaultmodules/defaultmodules.js | 10 + defaultmodules/helloworld/README.md | 5 + defaultmodules/helloworld/helloworld.js | 14 + defaultmodules/helloworld/helloworld.njk | 5 + defaultmodules/newsfeed/README.md | 6 + defaultmodules/newsfeed/newsfeed.css | 36 + defaultmodules/newsfeed/newsfeed.js | 502 + defaultmodules/newsfeed/newsfeed.njk | 89 + defaultmodules/newsfeed/newsfeedfetcher.js | 248 + defaultmodules/newsfeed/node_helper.js | 99 + defaultmodules/newsfeed/oldconfig.njk | 1 + defaultmodules/updatenotification/README.md | 6 + .../updatenotification/git_helper.js | 218 + .../updatenotification/node_helper.js | 121 + .../updatenotification/update_helper.js | 187 + .../updatenotification/updatenotification.css | 3 + .../updatenotification/updatenotification.js | 127 + .../updatenotification/updatenotification.njk | 41 + defaultmodules/utils.js | 31 + defaultmodules/weather/README.md | 5 + defaultmodules/weather/current.njk | 97 + defaultmodules/weather/forecast.njk | 46 + defaultmodules/weather/hourly.njk | 48 + defaultmodules/weather/node_helper.js | 112 + defaultmodules/weather/provider-utils.js | 181 + defaultmodules/weather/providers/README.md | 3 + .../weather/providers/buienradar.js | 349 + defaultmodules/weather/providers/envcanada.js | 451 + defaultmodules/weather/providers/openmeteo.js | 525 + .../weather/providers/openweathermap.js | 407 + .../weather/providers/pirateweather.js | 271 + defaultmodules/weather/providers/smhi.js | 504 + .../weather/providers/ukmetofficedatahub.js | 330 + .../weather/providers/weatherapi.js | 490 + .../weather/providers/weatherbit.js | 292 + .../weather/providers/weatherflow.js | 302 + .../weather/providers/weathergov.js | 417 + defaultmodules/weather/providers/yr.js | 469 + defaultmodules/weather/weather.css | 49 + defaultmodules/weather/weather.js | 419 + defaultmodules/weather/weatherobject.js | 133 + defaultmodules/weather/weatherutils.js | 203 + eslint.config.mjs | 196 + favicon.svg | 5 + index.html | 59 + js/alias-resolver.js | 31 + js/animateCSS.js | 159 + js/app.js | 348 + js/check_config.js | 16 + js/defaults.js | 93 + js/deprecated.js | 4 + js/electron.js | 211 + js/electron_helper.js | 30 + js/http_fetcher.js | 352 + js/ip_access_control.js | 109 + js/loader.js | 308 + js/logger.js | 114 + js/main.js | 714 ++ js/module.js | 569 + js/node_helper.js | 178 + js/releasenotes.js | 198 + js/server.js | 173 + js/server_functions.js | 272 + js/socketclient.js | 49 + js/suncalc-global.mjs | 8 + js/systeminformation.js | 49 + js/translator.js | 129 + js/utils.js | 284 + js/vendor.js | 14 + jsconfig.json | 10 + module-types.ts | 34 + package-lock.json | 9318 +++++++++++++++++ package.json | 137 + prettier.config.mjs | 14 + serveronly/index.js | 8 + serveronly/watcher.js | 261 + tests/configs/config_functions.js | 35 + tests/configs/config_variables.env | 7 + tests/configs/config_variables.js | 12 + tests/configs/customregions.js | 25 + tests/configs/default.js | 19 + tests/configs/empty_ipWhiteList.js | 9 + tests/configs/modules/alert/welcome_false.js | 18 + tests/configs/modules/alert/welcome_string.js | 18 + tests/configs/modules/alert/welcome_true.js | 18 + .../3_move_first_allday_repeating_event.js | 35 + .../configs/modules/calendar/auth-default.js | 29 + tests/configs/modules/calendar/bad_rrule.js | 24 + tests/configs/modules/calendar/basic-auth.js | 30 + .../calendar/berlin_end_of_day_repeating.js | 33 + .../configs/modules/calendar/berlin_multi.js | 33 + ...n_whole_day_event_moved_over_dst_change.js | 33 + .../calendar/calendarShowEndConfigs.js | 361 + .../configs/modules/calendar/changed-port.js | 29 + .../chicago-looking-at-ny-recurring.js | 33 + .../calendar/chicago_late_in_timezone.js | 33 + .../modules/calendar/countCalendarEvents.js | 39 + tests/configs/modules/calendar/custom.js | 33 + tests/configs/modules/calendar/default.js | 25 + .../modules/calendar/diff_tz_start_end.js | 34 + .../calendar/end_of_day_berlin_moved.js | 33 + .../exdate_and_recurrence_together.js | 33 + .../calendar/exdate_la_at_midnight_dst.js | 36 + .../calendar/exdate_la_at_midnight_std.js | 36 + .../calendar/exdate_la_before_midnight.js | 36 + .../calendar/exdate_syd_at_midnight_dst.js | 36 + .../calendar/exdate_syd_at_midnight_std.js | 36 + .../calendar/exdate_syd_before_midnight.js | 36 + .../modules/calendar/fail-basic-auth.js | 30 + ...y_event_over_multiple_days_nonrepeating.js | 32 + .../configs/modules/calendar/fullday_until.js | 27 + .../germany_at_end_of_day_repeating.js | 33 + .../modules/calendar/long-fullday-event.js | 31 + .../modules/calendar/old-basic-auth.js | 27 + tests/configs/modules/calendar/recurring.js | 26 + tests/configs/modules/calendar/rrule_until.js | 28 + .../calendar/show-duplicates-in-calendar.js | 33 + .../modules/calendar/single-fullday-event.js | 31 + .../modules/calendar/sliceMultiDayEvents.js | 28 + tests/configs/modules/calendar/symboltest.js | 26 + tests/configs/modules/clock/clock_12hr.js | 17 + tests/configs/modules/clock/clock_24hr.js | 15 + tests/configs/modules/clock/clock_analog.js | 20 + .../clock/clock_displaySeconds_false.js | 20 + .../modules/clock/clock_showDateAnalog.js | 22 + .../modules/clock/clock_showPeriodUpper.js | 20 + .../modules/clock/clock_showSunMoon.js | 21 + .../modules/clock/clock_showSunNoEvent.js | 20 + tests/configs/modules/clock/clock_showTime.js | 20 + tests/configs/modules/clock/clock_showWeek.js | 20 + .../modules/clock/clock_showWeek_short.js | 20 + .../modules/clock/de/clock_showWeek.js | 21 + .../modules/clock/de/clock_showWeek_short.js | 21 + tests/configs/modules/clock/es/clock_12hr.js | 18 + tests/configs/modules/clock/es/clock_24hr.js | 17 + .../modules/clock/es/clock_showPeriodUpper.js | 21 + .../modules/clock/es/clock_showWeek.js | 21 + .../modules/clock/es/clock_showWeek_short.js | 21 + .../compliments/compliments_animateCSS.js | 24 + ...ompliments_animateCSS_fallbackToDefault.js | 24 + ...iments_animateCSS_invertedAnimationName.js | 24 + .../compliments/compliments_anytime.js | 25 + .../compliments/compliments_cron_entry.js | 20 + .../modules/compliments/compliments_date.js | 25 + .../compliments/compliments_e2e_cron_entry.js | 20 + .../compliments/compliments_evening.js | 22 + .../modules/compliments/compliments_file.js | 17 + .../compliments/compliments_file_change.js | 19 + .../compliments/compliments_only_anytime.js | 22 + .../compliments/compliments_parts_day.js | 24 + .../modules/compliments/compliments_remote.js | 18 + .../compliments_specialDayUnique_false.js | 24 + .../compliments_specialDayUnique_true.js | 24 + tests/configs/modules/display.js | 26 + .../configs/modules/helloworld/helloworld.js | 18 + .../modules/helloworld/helloworld_default.js | 15 + tests/configs/modules/newsfeed/basic_html.js | 28 + tests/configs/modules/newsfeed/default.js | 25 + .../configs/modules/newsfeed/ignore_items.js | 26 + .../configs/modules/newsfeed/incorrect_url.js | 25 + .../configs/modules/newsfeed/notifications.js | 26 + .../modules/newsfeed/prohibited_words.js | 27 + tests/configs/modules/positions.js | 25 + .../weather/currentweather_compliments.js | 31 + .../modules/weather/currentweather_default.js | 24 + .../modules/weather/currentweather_options.js | 27 + .../modules/weather/currentweather_units.js | 25 + .../weather/forecastweather_absolute.js | 25 + .../weather/forecastweather_default.js | 24 + .../weather/forecastweather_options.js | 27 + .../modules/weather/forecastweather_units.js | 26 + .../modules/weather/hourlyweather_default.js | 24 + .../modules/weather/hourlyweather_options.js | 25 + .../hourlyweather_showPrecipitation.js | 26 + tests/configs/noIpWhiteList.js | 9 + tests/configs/port_8090.js | 8 + tests/configs/without_modules.js | 9 + tests/e2e/animateCSS_spec.js | 107 + tests/e2e/clientonly_spec.js | 179 + tests/e2e/config_functions_spec.js | 21 + tests/e2e/config_variables_spec.js | 34 + tests/e2e/custom_module_regions_spec.js | 32 + tests/e2e/env_spec.js | 30 + tests/e2e/fonts_spec.js | 29 + tests/e2e/helpers/basic-auth.js | 29 + tests/e2e/helpers/global-setup.js | 188 + tests/e2e/helpers/weather-functions.js | 108 + tests/e2e/ipWhitelist_spec.js | 47 + tests/e2e/modules/alert_spec.js | 53 + tests/e2e/modules/calendar_spec.js | 194 + tests/e2e/modules/clock_de_spec.js | 36 + tests/e2e/modules/clock_es_spec.js | 85 + tests/e2e/modules/clock_spec.js | 183 + tests/e2e/modules/compliments_spec.js | 136 + tests/e2e/modules/helloworld_spec.js | 34 + tests/e2e/modules/newsfeed_spec.js | 260 + tests/e2e/modules/weather_current_spec.js | 98 + tests/e2e/modules/weather_forecast_spec.js | 128 + tests/e2e/modules/weather_hourly_spec.js | 74 + tests/e2e/modules_display_spec.js | 25 + tests/e2e/modules_empty_spec.js | 24 + tests/e2e/modules_position_spec.js | 27 + tests/e2e/port_spec.js | 36 + tests/e2e/serveronly_spec.js | 51 + tests/e2e/translations_spec.js | 242 + tests/e2e/vendor_spec.js | 23 + tests/electron/env_spec.js | 38 + tests/electron/helpers/global-setup.js | 94 + tests/electron/helpers/weather-setup.js | 102 + tests/electron/modules/calendar_spec.js | 416 + tests/electron/modules/compliments_spec.js | 105 + tests/electron/modules/weather_spec.js | 37 + tests/mocks/12_events.ics | 164 + .../3_move_first_allday_repeating_event.ics | 35 + tests/mocks/RepeatingEvent.Oct21.ics | 28 + tests/mocks/bad_rrule.ics | 20 + tests/mocks/calendar_duplicates_1.ics | 87 + tests/mocks/calendar_duplicates_2.ics | 87 + tests/mocks/calendar_test.ics | 190 + tests/mocks/calendar_test_clone.ics | 190 + tests/mocks/calendar_test_full_day_events.ics | 33 + tests/mocks/calendar_test_icons.ics | 63 + ...calendar_test_multi_day_starting_today.ics | 33 + tests/mocks/calendar_test_recurring.ics | 37 + tests/mocks/chicago-nyedge.ics | 15 + tests/mocks/chicago_late_in_timezone.ics | 15 + tests/mocks/compliments_file.json | 3 + tests/mocks/compliments_test.json | 3 + tests/mocks/diff_tz_start_end.ics | 14 + tests/mocks/end_of_day_berlin_moved.ics | 54 + ..._time_over_multiple_days_non_repeating.ics | 14 + ...nt_with_time_over_multiple_days_yearly.ics | 18 + .../mocks/event_with_time_same_day_yearly.ics | 18 + .../mocks/exdate_and_recurrence_together.ics | 48 + tests/mocks/exdate_la_at_midnight_dst.ics | 15 + tests/mocks/exdate_la_at_midnight_std.ics | 15 + tests/mocks/exdate_la_before_midnight.ics | 15 + tests/mocks/exdate_syd_at_midnight_dst.ics | 15 + tests/mocks/exdate_syd_at_midnight_std.ics | 15 + tests/mocks/exdate_syd_before_midnight.ics | 15 + ..._event_over_multiple_days_nonrepeating.ics | 15 + tests/mocks/fullday_until.ics | 28 + .../mocks/germany_at_end_of_day_repeating.ics | 15 + tests/mocks/newsfeed_basic_html.xml | 15 + tests/mocks/newsfeed_test.xml | 410 + tests/mocks/rrule_until.ics | 24 + tests/mocks/sliceMultiDayEvents.ics | 58 + .../testNotification/testNotification.js | 59 + tests/mocks/translation_test.json | 33 + tests/mocks/weather_current.json | 48 + tests/mocks/weather_envcanada.xml | 871 ++ tests/mocks/weather_envcanada_index.html | 427 + tests/mocks/weather_forecast.json | 202 + tests/mocks/weather_onecall_current.json | 28 + tests/mocks/weather_onecall_forecast.json | 149 + tests/mocks/weather_onecall_hourly.json | 1118 ++ tests/mocks/weather_openmeteo_current.json | 218 + .../weather_openmeteo_current_weather.json | 84 + tests/mocks/weather_owm_current.json | 28 + tests/mocks/weather_owm_forecast.json | 180 + tests/mocks/weather_owm_onecall.json | 970 ++ tests/mocks/weather_pirateweather.json | 1665 +++ tests/mocks/weather_smhi.json | 1986 ++++ tests/mocks/weather_ukmetoffice.json | 1062 ++ tests/mocks/weather_ukmetoffice_daily.json | 419 + tests/mocks/weather_weatherbit.json | 45 + tests/mocks/weather_weatherbit_forecast.json | 290 + tests/mocks/weather_weatherbit_hourly.json | 1 + tests/mocks/weather_weatherflow.json | 4875 +++++++++ tests/mocks/weather_weathergov_current.json | 151 + tests/mocks/weather_weathergov_forecast.json | 304 + tests/mocks/weather_weathergov_hourly.json | 4250 ++++++++ tests/mocks/weather_weathergov_points.json | 89 + tests/mocks/weather_weathergov_stations.json | 1793 ++++ tests/mocks/weather_yr.json | 707 ++ ...whole_day_moved_over_dst_change_berlin.ics | 28 + tests/unit/classes/deprecated_spec.js | 15 + tests/unit/classes/module_spec.js | 299 + tests/unit/classes/systeminformation_spec.js | 7 + tests/unit/classes/translator_spec.js | 230 + tests/unit/classes/utils_spec.js | 83 + .../updatenotification_spec.js.snap | 100 + tests/unit/functions/cmp_versions_spec.js | 71 + tests/unit/functions/electron_helper_spec.js | 72 + tests/unit/functions/http_fetcher_spec.js | 579 + .../unit/functions/ip_access_control_spec.js | 79 + tests/unit/functions/server_functions_spec.js | 331 + tests/unit/functions/update_helper_spec.js | 119 + .../unit/functions/updatenotification_spec.js | 367 + .../unit/global_vars/defaults_modules_spec.js | 15 + tests/unit/global_vars/root_path_spec.js | 28 + .../default/calendar/calendar_fetcher_spec.js | 130 + .../calendar_fetcher_utils_bad_rrule.js | 32 + .../calendar/calendar_fetcher_utils_spec.js | 523 + .../default/calendar/calendar_utils_spec.js | 155 + .../default/compliments/compliments_spec.js | 153 + .../default/newsfeed/newsfeedfetcher_spec.js | 83 + tests/unit/modules/default/utils_spec.js | 54 + .../default/weather/node_helper_spec.js | 116 + .../default/weather/provider_utils_spec.js | 167 + .../weather/providers/buienradar_spec.js | 243 + .../weather/providers/envcanada_spec.js | 309 + .../weather/providers/openmeteo_spec.js | 343 + .../weather/providers/openweathermap_spec.js | 554 + .../weather/providers/pirateweather_spec.js | 366 + .../default/weather/providers/smhi_spec.js | 210 + .../providers/ukmetofficedatahub_spec.js | 348 + .../weather/providers/weatherapi_spec.js | 313 + .../weather/providers/weatherbit_spec.js | 247 + .../weather/providers/weatherflow_spec.js | 267 + .../weather/providers/weathergov_spec.js | 442 + .../default/weather/providers/yr_spec.js | 286 + .../default/weather/weather_object_spec.js | 50 + .../default/weather/weather_providers_spec.js | 189 + .../default/weather/weather_utils_spec.js | 163 + tests/utils/vitest-setup.js | 39 + translations/af.json | 54 + translations/ar.json | 52 + translations/bg.json | 54 + translations/ca.json | 53 + translations/cs.json | 54 + translations/cv.json | 53 + translations/cy.json | 53 + translations/da.json | 54 + translations/de.json | 55 + translations/el.json | 54 + translations/en.json | 53 + translations/eo.json | 55 + translations/es.json | 55 + translations/et.json | 54 + translations/fi.json | 54 + translations/fr.json | 55 + translations/fy.json | 53 + translations/gl.json | 54 + translations/gu.json | 54 + translations/he.json | 54 + translations/hi.json | 54 + translations/hr.json | 54 + translations/hu.json | 53 + translations/id.json | 53 + translations/is.json | 54 + translations/it.json | 53 + translations/ja.json | 54 + translations/ko.json | 54 + translations/lt.json | 54 + translations/ms-my.json | 54 + translations/nb.json | 54 + translations/nl.json | 54 + translations/nn.json | 54 + translations/pl.json | 54 + translations/ps.json | 41 + translations/pt-br.json | 53 + translations/pt.json | 55 + translations/ro.json | 54 + translations/ru.json | 54 + translations/sk.json | 54 + translations/sv.json | 54 + translations/th.json | 54 + translations/tlh.json | 54 + translations/tr.json | 53 + translations/translations.js | 51 + translations/uk.json | 54 + translations/zh-cn.json | 54 + translations/zh-tw.json | 54 + vitest.config.mjs | 105 + 449 files changed, 71828 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/CODE_OF_CONDUCT.md create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/FUNDING.yaml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/change_request.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/SECURITY.md create mode 100644 .github/dependabot.yaml create mode 100644 .github/header.png create mode 100644 .github/workflows/automated-tests.yaml create mode 100644 .github/workflows/codeql.yaml create mode 100644 .github/workflows/dep-review.yaml create mode 100644 .github/workflows/electron-rebuild.yaml create mode 100644 .github/workflows/enforce-pullrequest-rules.yaml create mode 100644 .github/workflows/release-notes.yaml create mode 100644 .github/workflows/spellcheck.yaml create mode 100644 .github/workflows/stale.yaml create mode 100644 .gitignore create mode 100755 .husky/pre-commit create mode 100644 .npmrc create mode 100644 .prettierignore create mode 100644 CHANGELOG.md create mode 100644 Collaboration.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 README.wehub.md create mode 100644 clientonly/index.js create mode 100644 cspell.config.json create mode 100644 css/font-awesome.css create mode 100644 css/main.css create mode 100644 css/roboto.css create mode 100644 defaultmodules/alert/README.md create mode 100644 defaultmodules/alert/alert.js create mode 100644 defaultmodules/alert/notificationFx.js create mode 100644 defaultmodules/alert/styles/center.css create mode 100644 defaultmodules/alert/styles/left.css create mode 100644 defaultmodules/alert/styles/notificationFx.css create mode 100644 defaultmodules/alert/styles/right.css create mode 100644 defaultmodules/alert/templates/alert.njk create mode 100644 defaultmodules/alert/templates/notification.njk create mode 100644 defaultmodules/alert/translations/bg.json create mode 100644 defaultmodules/alert/translations/da.json create mode 100644 defaultmodules/alert/translations/de.json create mode 100644 defaultmodules/alert/translations/el.json create mode 100644 defaultmodules/alert/translations/en.json create mode 100644 defaultmodules/alert/translations/eo.json create mode 100644 defaultmodules/alert/translations/es.json create mode 100644 defaultmodules/alert/translations/fr.json create mode 100644 defaultmodules/alert/translations/hu.json create mode 100644 defaultmodules/alert/translations/nl.json create mode 100644 defaultmodules/alert/translations/pt-br.json create mode 100644 defaultmodules/alert/translations/pt.json create mode 100644 defaultmodules/alert/translations/ru.json create mode 100644 defaultmodules/alert/translations/th.json create mode 100644 defaultmodules/calendar/README.md create mode 100644 defaultmodules/calendar/calendar.css create mode 100644 defaultmodules/calendar/calendar.js create mode 100644 defaultmodules/calendar/calendarfetcher.js create mode 100644 defaultmodules/calendar/calendarfetcherutils.js create mode 100644 defaultmodules/calendar/calendarutils.js create mode 100644 defaultmodules/calendar/debug.js create mode 100644 defaultmodules/calendar/node_helper.js create mode 100644 defaultmodules/calendar/windowsZones.json create mode 100644 defaultmodules/clock/README.md create mode 100644 defaultmodules/clock/clock.js create mode 100644 defaultmodules/clock/clock_styles.css create mode 100644 defaultmodules/clock/faces/face-001.svg create mode 100644 defaultmodules/clock/faces/face-002.svg create mode 100644 defaultmodules/clock/faces/face-003.svg create mode 100644 defaultmodules/clock/faces/face-004.svg create mode 100644 defaultmodules/clock/faces/face-005.svg create mode 100644 defaultmodules/clock/faces/face-006.svg create mode 100644 defaultmodules/clock/faces/face-007.svg create mode 100644 defaultmodules/clock/faces/face-008.svg create mode 100644 defaultmodules/clock/faces/face-009.svg create mode 100644 defaultmodules/clock/faces/face-010.svg create mode 100644 defaultmodules/clock/faces/face-011.svg create mode 100644 defaultmodules/clock/faces/face-012.svg create mode 100644 defaultmodules/compliments/README.md create mode 100644 defaultmodules/compliments/compliments.js create mode 100644 defaultmodules/defaultmodules.js create mode 100644 defaultmodules/helloworld/README.md create mode 100644 defaultmodules/helloworld/helloworld.js create mode 100644 defaultmodules/helloworld/helloworld.njk create mode 100644 defaultmodules/newsfeed/README.md create mode 100644 defaultmodules/newsfeed/newsfeed.css create mode 100644 defaultmodules/newsfeed/newsfeed.js create mode 100644 defaultmodules/newsfeed/newsfeed.njk create mode 100644 defaultmodules/newsfeed/newsfeedfetcher.js create mode 100644 defaultmodules/newsfeed/node_helper.js create mode 100644 defaultmodules/newsfeed/oldconfig.njk create mode 100644 defaultmodules/updatenotification/README.md create mode 100644 defaultmodules/updatenotification/git_helper.js create mode 100644 defaultmodules/updatenotification/node_helper.js create mode 100644 defaultmodules/updatenotification/update_helper.js create mode 100644 defaultmodules/updatenotification/updatenotification.css create mode 100644 defaultmodules/updatenotification/updatenotification.js create mode 100644 defaultmodules/updatenotification/updatenotification.njk create mode 100644 defaultmodules/utils.js create mode 100644 defaultmodules/weather/README.md create mode 100644 defaultmodules/weather/current.njk create mode 100644 defaultmodules/weather/forecast.njk create mode 100644 defaultmodules/weather/hourly.njk create mode 100644 defaultmodules/weather/node_helper.js create mode 100644 defaultmodules/weather/provider-utils.js create mode 100644 defaultmodules/weather/providers/README.md create mode 100644 defaultmodules/weather/providers/buienradar.js create mode 100644 defaultmodules/weather/providers/envcanada.js create mode 100644 defaultmodules/weather/providers/openmeteo.js create mode 100644 defaultmodules/weather/providers/openweathermap.js create mode 100644 defaultmodules/weather/providers/pirateweather.js create mode 100644 defaultmodules/weather/providers/smhi.js create mode 100644 defaultmodules/weather/providers/ukmetofficedatahub.js create mode 100644 defaultmodules/weather/providers/weatherapi.js create mode 100644 defaultmodules/weather/providers/weatherbit.js create mode 100644 defaultmodules/weather/providers/weatherflow.js create mode 100644 defaultmodules/weather/providers/weathergov.js create mode 100644 defaultmodules/weather/providers/yr.js create mode 100644 defaultmodules/weather/weather.css create mode 100644 defaultmodules/weather/weather.js create mode 100644 defaultmodules/weather/weatherobject.js create mode 100644 defaultmodules/weather/weatherutils.js create mode 100644 eslint.config.mjs create mode 100644 favicon.svg create mode 100644 index.html create mode 100644 js/alias-resolver.js create mode 100644 js/animateCSS.js create mode 100644 js/app.js create mode 100644 js/check_config.js create mode 100644 js/defaults.js create mode 100644 js/deprecated.js create mode 100644 js/electron.js create mode 100644 js/electron_helper.js create mode 100644 js/http_fetcher.js create mode 100644 js/ip_access_control.js create mode 100644 js/loader.js create mode 100644 js/logger.js create mode 100644 js/main.js create mode 100644 js/module.js create mode 100644 js/node_helper.js create mode 100644 js/releasenotes.js create mode 100644 js/server.js create mode 100644 js/server_functions.js create mode 100644 js/socketclient.js create mode 100644 js/suncalc-global.mjs create mode 100644 js/systeminformation.js create mode 100644 js/translator.js create mode 100644 js/utils.js create mode 100644 js/vendor.js create mode 100644 jsconfig.json create mode 100644 module-types.ts create mode 100644 package-lock.json create mode 100755 package.json create mode 100644 prettier.config.mjs create mode 100644 serveronly/index.js create mode 100644 serveronly/watcher.js create mode 100644 tests/configs/config_functions.js create mode 100644 tests/configs/config_variables.env create mode 100644 tests/configs/config_variables.js create mode 100644 tests/configs/customregions.js create mode 100644 tests/configs/default.js create mode 100644 tests/configs/empty_ipWhiteList.js create mode 100644 tests/configs/modules/alert/welcome_false.js create mode 100644 tests/configs/modules/alert/welcome_string.js create mode 100644 tests/configs/modules/alert/welcome_true.js create mode 100644 tests/configs/modules/calendar/3_move_first_allday_repeating_event.js create mode 100644 tests/configs/modules/calendar/auth-default.js create mode 100644 tests/configs/modules/calendar/bad_rrule.js create mode 100644 tests/configs/modules/calendar/basic-auth.js create mode 100644 tests/configs/modules/calendar/berlin_end_of_day_repeating.js create mode 100644 tests/configs/modules/calendar/berlin_multi.js create mode 100644 tests/configs/modules/calendar/berlin_whole_day_event_moved_over_dst_change.js create mode 100644 tests/configs/modules/calendar/calendarShowEndConfigs.js create mode 100644 tests/configs/modules/calendar/changed-port.js create mode 100644 tests/configs/modules/calendar/chicago-looking-at-ny-recurring.js create mode 100644 tests/configs/modules/calendar/chicago_late_in_timezone.js create mode 100644 tests/configs/modules/calendar/countCalendarEvents.js create mode 100644 tests/configs/modules/calendar/custom.js create mode 100644 tests/configs/modules/calendar/default.js create mode 100644 tests/configs/modules/calendar/diff_tz_start_end.js create mode 100644 tests/configs/modules/calendar/end_of_day_berlin_moved.js create mode 100644 tests/configs/modules/calendar/exdate_and_recurrence_together.js create mode 100644 tests/configs/modules/calendar/exdate_la_at_midnight_dst.js create mode 100644 tests/configs/modules/calendar/exdate_la_at_midnight_std.js create mode 100644 tests/configs/modules/calendar/exdate_la_before_midnight.js create mode 100644 tests/configs/modules/calendar/exdate_syd_at_midnight_dst.js create mode 100644 tests/configs/modules/calendar/exdate_syd_at_midnight_std.js create mode 100644 tests/configs/modules/calendar/exdate_syd_before_midnight.js create mode 100644 tests/configs/modules/calendar/fail-basic-auth.js create mode 100644 tests/configs/modules/calendar/fullday_event_over_multiple_days_nonrepeating.js create mode 100644 tests/configs/modules/calendar/fullday_until.js create mode 100644 tests/configs/modules/calendar/germany_at_end_of_day_repeating.js create mode 100644 tests/configs/modules/calendar/long-fullday-event.js create mode 100644 tests/configs/modules/calendar/old-basic-auth.js create mode 100644 tests/configs/modules/calendar/recurring.js create mode 100644 tests/configs/modules/calendar/rrule_until.js create mode 100644 tests/configs/modules/calendar/show-duplicates-in-calendar.js create mode 100644 tests/configs/modules/calendar/single-fullday-event.js create mode 100644 tests/configs/modules/calendar/sliceMultiDayEvents.js create mode 100644 tests/configs/modules/calendar/symboltest.js create mode 100644 tests/configs/modules/clock/clock_12hr.js create mode 100644 tests/configs/modules/clock/clock_24hr.js create mode 100644 tests/configs/modules/clock/clock_analog.js create mode 100644 tests/configs/modules/clock/clock_displaySeconds_false.js create mode 100644 tests/configs/modules/clock/clock_showDateAnalog.js create mode 100644 tests/configs/modules/clock/clock_showPeriodUpper.js create mode 100644 tests/configs/modules/clock/clock_showSunMoon.js create mode 100644 tests/configs/modules/clock/clock_showSunNoEvent.js create mode 100644 tests/configs/modules/clock/clock_showTime.js create mode 100644 tests/configs/modules/clock/clock_showWeek.js create mode 100644 tests/configs/modules/clock/clock_showWeek_short.js create mode 100644 tests/configs/modules/clock/de/clock_showWeek.js create mode 100644 tests/configs/modules/clock/de/clock_showWeek_short.js create mode 100644 tests/configs/modules/clock/es/clock_12hr.js create mode 100644 tests/configs/modules/clock/es/clock_24hr.js create mode 100644 tests/configs/modules/clock/es/clock_showPeriodUpper.js create mode 100644 tests/configs/modules/clock/es/clock_showWeek.js create mode 100644 tests/configs/modules/clock/es/clock_showWeek_short.js create mode 100644 tests/configs/modules/compliments/compliments_animateCSS.js create mode 100644 tests/configs/modules/compliments/compliments_animateCSS_fallbackToDefault.js create mode 100644 tests/configs/modules/compliments/compliments_animateCSS_invertedAnimationName.js create mode 100644 tests/configs/modules/compliments/compliments_anytime.js create mode 100644 tests/configs/modules/compliments/compliments_cron_entry.js create mode 100644 tests/configs/modules/compliments/compliments_date.js create mode 100644 tests/configs/modules/compliments/compliments_e2e_cron_entry.js create mode 100644 tests/configs/modules/compliments/compliments_evening.js create mode 100644 tests/configs/modules/compliments/compliments_file.js create mode 100644 tests/configs/modules/compliments/compliments_file_change.js create mode 100644 tests/configs/modules/compliments/compliments_only_anytime.js create mode 100644 tests/configs/modules/compliments/compliments_parts_day.js create mode 100644 tests/configs/modules/compliments/compliments_remote.js create mode 100644 tests/configs/modules/compliments/compliments_specialDayUnique_false.js create mode 100644 tests/configs/modules/compliments/compliments_specialDayUnique_true.js create mode 100644 tests/configs/modules/display.js create mode 100644 tests/configs/modules/helloworld/helloworld.js create mode 100644 tests/configs/modules/helloworld/helloworld_default.js create mode 100644 tests/configs/modules/newsfeed/basic_html.js create mode 100644 tests/configs/modules/newsfeed/default.js create mode 100644 tests/configs/modules/newsfeed/ignore_items.js create mode 100644 tests/configs/modules/newsfeed/incorrect_url.js create mode 100644 tests/configs/modules/newsfeed/notifications.js create mode 100644 tests/configs/modules/newsfeed/prohibited_words.js create mode 100644 tests/configs/modules/positions.js create mode 100644 tests/configs/modules/weather/currentweather_compliments.js create mode 100644 tests/configs/modules/weather/currentweather_default.js create mode 100644 tests/configs/modules/weather/currentweather_options.js create mode 100644 tests/configs/modules/weather/currentweather_units.js create mode 100644 tests/configs/modules/weather/forecastweather_absolute.js create mode 100644 tests/configs/modules/weather/forecastweather_default.js create mode 100644 tests/configs/modules/weather/forecastweather_options.js create mode 100644 tests/configs/modules/weather/forecastweather_units.js create mode 100644 tests/configs/modules/weather/hourlyweather_default.js create mode 100644 tests/configs/modules/weather/hourlyweather_options.js create mode 100644 tests/configs/modules/weather/hourlyweather_showPrecipitation.js create mode 100644 tests/configs/noIpWhiteList.js create mode 100644 tests/configs/port_8090.js create mode 100644 tests/configs/without_modules.js create mode 100644 tests/e2e/animateCSS_spec.js create mode 100644 tests/e2e/clientonly_spec.js create mode 100644 tests/e2e/config_functions_spec.js create mode 100644 tests/e2e/config_variables_spec.js create mode 100644 tests/e2e/custom_module_regions_spec.js create mode 100644 tests/e2e/env_spec.js create mode 100644 tests/e2e/fonts_spec.js create mode 100644 tests/e2e/helpers/basic-auth.js create mode 100644 tests/e2e/helpers/global-setup.js create mode 100644 tests/e2e/helpers/weather-functions.js create mode 100644 tests/e2e/ipWhitelist_spec.js create mode 100644 tests/e2e/modules/alert_spec.js create mode 100644 tests/e2e/modules/calendar_spec.js create mode 100644 tests/e2e/modules/clock_de_spec.js create mode 100644 tests/e2e/modules/clock_es_spec.js create mode 100644 tests/e2e/modules/clock_spec.js create mode 100644 tests/e2e/modules/compliments_spec.js create mode 100644 tests/e2e/modules/helloworld_spec.js create mode 100644 tests/e2e/modules/newsfeed_spec.js create mode 100644 tests/e2e/modules/weather_current_spec.js create mode 100644 tests/e2e/modules/weather_forecast_spec.js create mode 100644 tests/e2e/modules/weather_hourly_spec.js create mode 100644 tests/e2e/modules_display_spec.js create mode 100644 tests/e2e/modules_empty_spec.js create mode 100644 tests/e2e/modules_position_spec.js create mode 100644 tests/e2e/port_spec.js create mode 100644 tests/e2e/serveronly_spec.js create mode 100644 tests/e2e/translations_spec.js create mode 100644 tests/e2e/vendor_spec.js create mode 100644 tests/electron/env_spec.js create mode 100644 tests/electron/helpers/global-setup.js create mode 100644 tests/electron/helpers/weather-setup.js create mode 100644 tests/electron/modules/calendar_spec.js create mode 100644 tests/electron/modules/compliments_spec.js create mode 100644 tests/electron/modules/weather_spec.js create mode 100644 tests/mocks/12_events.ics create mode 100644 tests/mocks/3_move_first_allday_repeating_event.ics create mode 100644 tests/mocks/RepeatingEvent.Oct21.ics create mode 100644 tests/mocks/bad_rrule.ics create mode 100644 tests/mocks/calendar_duplicates_1.ics create mode 100644 tests/mocks/calendar_duplicates_2.ics create mode 100644 tests/mocks/calendar_test.ics create mode 100644 tests/mocks/calendar_test_clone.ics create mode 100644 tests/mocks/calendar_test_full_day_events.ics create mode 100644 tests/mocks/calendar_test_icons.ics create mode 100644 tests/mocks/calendar_test_multi_day_starting_today.ics create mode 100644 tests/mocks/calendar_test_recurring.ics create mode 100644 tests/mocks/chicago-nyedge.ics create mode 100644 tests/mocks/chicago_late_in_timezone.ics create mode 100644 tests/mocks/compliments_file.json create mode 100644 tests/mocks/compliments_test.json create mode 100644 tests/mocks/diff_tz_start_end.ics create mode 100644 tests/mocks/end_of_day_berlin_moved.ics create mode 100644 tests/mocks/event_with_time_over_multiple_days_non_repeating.ics create mode 100644 tests/mocks/event_with_time_over_multiple_days_yearly.ics create mode 100644 tests/mocks/event_with_time_same_day_yearly.ics create mode 100644 tests/mocks/exdate_and_recurrence_together.ics create mode 100644 tests/mocks/exdate_la_at_midnight_dst.ics create mode 100644 tests/mocks/exdate_la_at_midnight_std.ics create mode 100644 tests/mocks/exdate_la_before_midnight.ics create mode 100644 tests/mocks/exdate_syd_at_midnight_dst.ics create mode 100644 tests/mocks/exdate_syd_at_midnight_std.ics create mode 100644 tests/mocks/exdate_syd_before_midnight.ics create mode 100644 tests/mocks/fullday_event_over_multiple_days_nonrepeating.ics create mode 100644 tests/mocks/fullday_until.ics create mode 100644 tests/mocks/germany_at_end_of_day_repeating.ics create mode 100644 tests/mocks/newsfeed_basic_html.xml create mode 100644 tests/mocks/newsfeed_test.xml create mode 100644 tests/mocks/rrule_until.ics create mode 100644 tests/mocks/sliceMultiDayEvents.ics create mode 100644 tests/mocks/testNotification/testNotification.js create mode 100644 tests/mocks/translation_test.json create mode 100644 tests/mocks/weather_current.json create mode 100644 tests/mocks/weather_envcanada.xml create mode 100644 tests/mocks/weather_envcanada_index.html create mode 100644 tests/mocks/weather_forecast.json create mode 100644 tests/mocks/weather_onecall_current.json create mode 100644 tests/mocks/weather_onecall_forecast.json create mode 100644 tests/mocks/weather_onecall_hourly.json create mode 100644 tests/mocks/weather_openmeteo_current.json create mode 100644 tests/mocks/weather_openmeteo_current_weather.json create mode 100644 tests/mocks/weather_owm_current.json create mode 100644 tests/mocks/weather_owm_forecast.json create mode 100644 tests/mocks/weather_owm_onecall.json create mode 100644 tests/mocks/weather_pirateweather.json create mode 100644 tests/mocks/weather_smhi.json create mode 100644 tests/mocks/weather_ukmetoffice.json create mode 100644 tests/mocks/weather_ukmetoffice_daily.json create mode 100644 tests/mocks/weather_weatherbit.json create mode 100644 tests/mocks/weather_weatherbit_forecast.json create mode 100644 tests/mocks/weather_weatherbit_hourly.json create mode 100644 tests/mocks/weather_weatherflow.json create mode 100644 tests/mocks/weather_weathergov_current.json create mode 100644 tests/mocks/weather_weathergov_forecast.json create mode 100644 tests/mocks/weather_weathergov_hourly.json create mode 100644 tests/mocks/weather_weathergov_points.json create mode 100644 tests/mocks/weather_weathergov_stations.json create mode 100644 tests/mocks/weather_yr.json create mode 100644 tests/mocks/whole_day_moved_over_dst_change_berlin.ics create mode 100644 tests/unit/classes/deprecated_spec.js create mode 100644 tests/unit/classes/module_spec.js create mode 100644 tests/unit/classes/systeminformation_spec.js create mode 100644 tests/unit/classes/translator_spec.js create mode 100644 tests/unit/classes/utils_spec.js create mode 100644 tests/unit/functions/__snapshots__/updatenotification_spec.js.snap create mode 100644 tests/unit/functions/cmp_versions_spec.js create mode 100644 tests/unit/functions/electron_helper_spec.js create mode 100644 tests/unit/functions/http_fetcher_spec.js create mode 100644 tests/unit/functions/ip_access_control_spec.js create mode 100644 tests/unit/functions/server_functions_spec.js create mode 100644 tests/unit/functions/update_helper_spec.js create mode 100644 tests/unit/functions/updatenotification_spec.js create mode 100644 tests/unit/global_vars/defaults_modules_spec.js create mode 100644 tests/unit/global_vars/root_path_spec.js create mode 100644 tests/unit/modules/default/calendar/calendar_fetcher_spec.js create mode 100644 tests/unit/modules/default/calendar/calendar_fetcher_utils_bad_rrule.js create mode 100644 tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js create mode 100644 tests/unit/modules/default/calendar/calendar_utils_spec.js create mode 100644 tests/unit/modules/default/compliments/compliments_spec.js create mode 100644 tests/unit/modules/default/newsfeed/newsfeedfetcher_spec.js create mode 100644 tests/unit/modules/default/utils_spec.js create mode 100644 tests/unit/modules/default/weather/node_helper_spec.js create mode 100644 tests/unit/modules/default/weather/provider_utils_spec.js create mode 100644 tests/unit/modules/default/weather/providers/buienradar_spec.js create mode 100644 tests/unit/modules/default/weather/providers/envcanada_spec.js create mode 100644 tests/unit/modules/default/weather/providers/openmeteo_spec.js create mode 100644 tests/unit/modules/default/weather/providers/openweathermap_spec.js create mode 100644 tests/unit/modules/default/weather/providers/pirateweather_spec.js create mode 100644 tests/unit/modules/default/weather/providers/smhi_spec.js create mode 100644 tests/unit/modules/default/weather/providers/ukmetofficedatahub_spec.js create mode 100644 tests/unit/modules/default/weather/providers/weatherapi_spec.js create mode 100644 tests/unit/modules/default/weather/providers/weatherbit_spec.js create mode 100644 tests/unit/modules/default/weather/providers/weatherflow_spec.js create mode 100644 tests/unit/modules/default/weather/providers/weathergov_spec.js create mode 100644 tests/unit/modules/default/weather/providers/yr_spec.js create mode 100644 tests/unit/modules/default/weather/weather_object_spec.js create mode 100644 tests/unit/modules/default/weather/weather_providers_spec.js create mode 100644 tests/unit/modules/default/weather/weather_utils_spec.js create mode 100644 tests/utils/vitest-setup.js create mode 100644 translations/af.json create mode 100644 translations/ar.json create mode 100644 translations/bg.json create mode 100644 translations/ca.json create mode 100644 translations/cs.json create mode 100644 translations/cv.json create mode 100644 translations/cy.json create mode 100644 translations/da.json create mode 100644 translations/de.json create mode 100644 translations/el.json create mode 100644 translations/en.json create mode 100644 translations/eo.json create mode 100644 translations/es.json create mode 100644 translations/et.json create mode 100644 translations/fi.json create mode 100644 translations/fr.json create mode 100644 translations/fy.json create mode 100644 translations/gl.json create mode 100644 translations/gu.json create mode 100644 translations/he.json create mode 100644 translations/hi.json create mode 100644 translations/hr.json create mode 100644 translations/hu.json create mode 100644 translations/id.json create mode 100644 translations/is.json create mode 100644 translations/it.json create mode 100644 translations/ja.json create mode 100644 translations/ko.json create mode 100644 translations/lt.json create mode 100644 translations/ms-my.json create mode 100644 translations/nb.json create mode 100644 translations/nl.json create mode 100644 translations/nn.json create mode 100644 translations/pl.json create mode 100644 translations/ps.json create mode 100644 translations/pt-br.json create mode 100644 translations/pt.json create mode 100644 translations/ro.json create mode 100644 translations/ru.json create mode 100644 translations/sk.json create mode 100644 translations/sv.json create mode 100644 translations/th.json create mode 100644 translations/tlh.json create mode 100644 translations/tr.json create mode 100644 translations/translations.js create mode 100644 translations/uk.json create mode 100644 translations/zh-cn.json create mode 100644 translations/zh-tw.json create mode 100644 vitest.config.mjs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..db01ad9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +# 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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e65801a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,56 @@ +# .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 \ No newline at end of file diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..9793189 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,137 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of + any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement: +Contact [Rejas](https://forum.magicmirror.builders/user/rejas), +[Karsten](https://forum.magicmirror.builders/user/karsten13), +[Sam](https://forum.magicmirror.builders/user/sdetweil) or +[Kristjan](https://forum.magicmirror.builders/user/kristjanesperanto) +via private message in the forum. + +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..00c515e --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,34 @@ +# 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. + +And we use [ESLint](https://eslint.org) to lint our JavaScript, Markdown, and CSS files. The configuration is in our `eslint.config.mjs` file. + +To check for formatting and linting errors, use `node --run test:lint` +To fix formatting and linting errors, use `node --run lint:fix`. + +## 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:lint` – run linter and formatter checks +- `test:spelling` – run the spell checker + +You can invoke any script with `node --run + + +
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + diff --git a/js/alias-resolver.js b/js/alias-resolver.js new file mode 100644 index 0000000..602e066 --- /dev/null +++ b/js/alias-resolver.js @@ -0,0 +1,31 @@ +// Internal alias mapping for default and 3rd party modules. +// Provides short require identifiers: "logger" and "node_helper". +// For a future ESM migration, replace this with a public export/import surface. + +const path = require("node:path"); +const Module = require("node: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 +} diff --git a/js/animateCSS.js b/js/animateCSS.js new file mode 100644 index 0000000..eacb51d --- /dev/null +++ b/js/animateCSS.js @@ -0,0 +1,159 @@ +/* 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, addAnimateCSS, removeAnimateCSS }; diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..c79d40e --- /dev/null +++ b/js/app.js @@ -0,0 +1,348 @@ +// Load lightweight internal alias resolver +require("./alias-resolver"); + +const fs = require("node:fs"); +const path = require("node:path"); +const Spawn = require("node:child_process").spawn; +const Log = require("logger"); + +// global absolute root path +global.root_path = path.resolve(`${__dirname}/../`); + +// used to control fetch timeout for node_helpers +const { setGlobalDispatcher, Agent } = require("undici"); + +const Server = require("./server"); +const Utils = require("./utils"); +const { ConfigError } = require("./utils"); + +const { getEnvVarsAsObj } = require("#server_functions"); +// common timeout value, provide environment override in case +const fetch_timeout = process.env.mmFetchTimeout !== undefined ? process.env.mmFetchTimeout : 30000; + +// Get version number. +global.version = require(`${global.root_path}/package.json`).version; +global.mmTestMode = process.env.mmTestMode === "true"; +Log.log(`Starting MagicMirror: v${global.version}`); + +// Log system information in a subprocess so it is shown even on early startup failures. +Spawn("node ./js/systeminformation.js", { + env: { + ...process.env, + ELECTRON_VERSION: `${process.versions.electron}`, + USED_NODE_VERSION: `${process.versions.node}` + }, + cwd: this.root_path, + shell: true, + detached: true, + stdio: "inherit" +}); + +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; + let defaultModules; + let env; + + /** + * 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]; + let moduleFolder = path.resolve(`${global.root_path}/${env.modulesDir}`, module); + + if (defaultModules.includes(moduleName)) { + const defaultModuleFolder = path.resolve(`${global.root_path}/${global.defaultModulesDir}/`, 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 { + 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 { + 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 () { + try { + const configObj = Utils.loadConfig(); + global.config = configObj.fullConf; + // Keep a copy of the redacted config to later verify module secret permissions + global.configRedacted = configObj.redactedConf; + const config = global.config; + Utils.checkConfigFile(configObj); + + global.defaultModulesDir = config.defaultModulesDir; + defaultModules = require(`${global.root_path}/${global.defaultModulesDir}/defaultmodules`); + + Log.setLogLevel(config.logLevel); + + env = getEnvVarsAsObj(); + // check for deprecated css/custom.css and move it to new location + if ((!fs.existsSync(`${global.root_path}/${env.customCss}`)) && (fs.existsSync(`${global.root_path}/css/custom.css`))) { + try { + fs.renameSync(`${global.root_path}/css/custom.css`, `${global.root_path}/${env.customCss}`); + Log.warn(`WARNING! Your custom css file was moved from ${global.root_path}/css/custom.css to ${global.root_path}/${env.customCss}`); + } catch { + Log.warn("WARNING! Your custom css file is currently located in the css folder. Please move it to the config folder!"); + } + } + + // 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(configObj); + 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 global.config; + } catch (err) { + // planned ConfigErrors already logged their message before throwing + if (!(err instanceof ConfigError)) { + Log.error("Unexpected error during startup:", err); + } + + const int32 = new Int32Array(new SharedArrayBuffer(4)); + // wait 1000ms before exiting so that child processes (e.g. systeminformation) have some additional time + Atomics.wait(int32, 0, 0, 1000); + process.exit(1); + } + }; + + /** + * 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(); diff --git a/js/check_config.js b/js/check_config.js new file mode 100644 index 0000000..3ed042c --- /dev/null +++ b/js/check_config.js @@ -0,0 +1,16 @@ +// 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 Log = require("logger"); + +const rootPath = path.resolve(`${__dirname}/../`); +const Utils = require(`${rootPath}/js/utils.js`); + +try { + Utils.checkConfigFile(); +} catch (error) { + const message = error && error.message ? error.message : error; + Log.error(`Unexpected error: ${message}`); + process.exit(1); +} diff --git a/js/defaults.js b/js/defaults.js new file mode 100644 index 0000000..4d923cd --- /dev/null +++ b/js/defaults.js @@ -0,0 +1,93 @@ +/* global mmPort */ + +const address = "localhost"; +let port = 8080; +if (typeof mmPort !== "undefined") { + port = mmPort; +} +const defaults = { + address: address, + port: port, + basePath: "/", + useHttps: false, // Support HTTPS or not, default "false" will use HTTP + httpsPrivateKey: "", // HTTPS private key path, only required when useHttps is true + httpsCertificate: "", // HTTPS Certificate path, only required when useHttps is true + tls: null, // Legacy compatibility option for Electron URL selection (prefer useHttps) + electronOptions: {}, + electronSwitches: [], + ignoreXOriginHeader: false, // Remove X-Frame-Options response header in Electron + ignoreContentSecurityPolicy: false, // Remove Content-Security-Policy response header in Electron + ipWhitelist: ["127.0.0.1", "::ffff:127.0.0.1", "::1"], + cors: "disabled", // or "allowAll" or "allowWhitelist" + corsDomainWhitelist: [], // example: ["api.mapbox.com"] + watchTargets: [], + + language: "en", + logLevel: ["INFO", "LOG", "WARN", "ERROR"], + timeFormat: 24, + units: "metric", + zoom: 1, + customCss: "config/custom.css", + foreignModulesDir: "modules", + defaultModulesDir: "defaultmodules", + hideConfigSecrets: false, + // 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; +} diff --git a/js/deprecated.js b/js/deprecated.js new file mode 100644 index 0000000..8c8ef3f --- /dev/null +++ b/js/deprecated.js @@ -0,0 +1,4 @@ +module.exports = { + configs: [], + clock: ["secondsColor"] +}; diff --git a/js/electron.js b/js/electron.js new file mode 100644 index 0000000..9266fb0 --- /dev/null +++ b/js/electron.js @@ -0,0 +1,211 @@ +"use strict"; + +const electron = require("electron"); +const core = require("./app"); +const Log = require("./logger"); +const { applyElectronSwitches } = require("./electron_helper"); + +// 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 = { width: 800, height: 600 }; + try { + electronSize = electron.screen.getPrimaryDisplay().workAreaSize; + } catch { + Log.warn("Could not get display size, using defaults ..."); + } + + applyElectronSwitches(app.commandLine, config.electronSwitches); + let electronOptionsDefaults = { + width: electronSize.width, + height: electronSize.height, + icon: "favicon.svg", + x: 0, + y: 0, + darkTheme: true, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + zoomFactor: config.zoom + }, + backgroundColor: "#000000" + }; + + 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 === "::") ? (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", () => { + mainWindow.webContents.sendInputEvent({ type: "mouseMove", x: 0, y: 0 }); + }); + + // Set responders for window events. + mainWindow.on("closed", function () { + mainWindow = null; + }); + + //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(); + }); + }); +} diff --git a/js/electron_helper.js b/js/electron_helper.js new file mode 100644 index 0000000..6eca6e9 --- /dev/null +++ b/js/electron_helper.js @@ -0,0 +1,30 @@ +const Log = require("./logger"); + +/** + * Applies Electron command-line switches from config. + * @param {object} commandLine Electron commandLine API + * @param {Array} [electronSwitches] User-configured switches + */ +function applyElectronSwitches (commandLine, electronSwitches) { + if (electronSwitches === undefined) return; + if (!Array.isArray(electronSwitches)) { + Log.error(`electronSwitches must be an array of strings or objects, got: ${JSON.stringify(electronSwitches)}`); + return; + } + + for (const sw of electronSwitches) { + if (typeof sw === "string") { + commandLine.appendSwitch(sw); + Log.debug(`Activated switch: ${sw}`); + } else if (sw && typeof sw === "object" && !Array.isArray(sw)) { + for (const [name, value] of Object.entries(sw)) { + commandLine.appendSwitch(name, String(value)); + Log.debug(`Activated switch: ${name}=${value}`); + } + } else { + Log.error(`Invalid electronSwitches entry: ${JSON.stringify(sw)}`); + } + } +} + +module.exports = { applyElectronSwitches }; diff --git a/js/http_fetcher.js b/js/http_fetcher.js new file mode 100644 index 0000000..eca4896 --- /dev/null +++ b/js/http_fetcher.js @@ -0,0 +1,352 @@ +const { EventEmitter } = require("node:events"); +const { fetch: undiciFetch, Agent } = require("undici"); +const Log = require("logger"); +const { getUserAgent } = require("#server_functions"); + +const FIFTEEN_MINUTES = 15 * 60 * 1000; +const THIRTY_MINUTES = 30 * 60 * 1000; +const MAX_SERVER_BACKOFF = 3; +const DEFAULT_TIMEOUT = 30000; // 30 seconds + +/** + * Maps errorType to MagicMirror translation keys. + * This allows HTTPFetcher to provide ready-to-use translation keys, + * eliminating the need to call NodeHelper.checkFetchError(). + */ +const ERROR_TYPE_TO_TRANSLATION = { + AUTH_FAILURE: "MODULE_ERROR_UNAUTHORIZED", + RATE_LIMITED: "MODULE_ERROR_RATE_LIMITED", + SERVER_ERROR: "MODULE_ERROR_SERVER_ERROR", + CLIENT_ERROR: "MODULE_ERROR_CLIENT_ERROR", + NETWORK_ERROR: "MODULE_ERROR_NO_CONNECTION", + UNKNOWN_ERROR: "MODULE_ERROR_UNSPECIFIED" +}; + +/** + * HTTPFetcher - Centralized HTTP fetching with intelligent error handling + * + * Features: + * - Automatic retry strategies based on HTTP status codes + * - Exponential backoff for server errors + * - Retry-After header parsing for rate limiting + * - Authentication support (Basic, Bearer) + * - Self-signed certificate support + * @augments EventEmitter + * @fires HTTPFetcher#response - When fetch succeeds (including 304 Not Modified) + * @fires HTTPFetcher#error - When fetch fails or returns non-ok response + * @example + * const fetcher = new HTTPFetcher(url, { reloadInterval: 60000 }); + * fetcher.on('response', (response) => { ... }); + * fetcher.on('error', (errorInfo) => { ... }); + * fetcher.startPeriodicFetch(); + */ +class HTTPFetcher extends EventEmitter { + + /** + * Calculates exponential backoff delay for retries + * @param {number} attempt - Attempt number (1-based) + * @param {object} options - Configuration options + * @param {number} [options.baseDelay] - Initial delay in ms (default: 15s) + * @param {number} [options.maxDelay] - Maximum delay in ms (default: 5min) + * @returns {number} Delay in milliseconds + * @example + * HTTPFetcher.calculateBackoffDelay(1) // 15000 (15s) + * HTTPFetcher.calculateBackoffDelay(2) // 30000 (30s) + * HTTPFetcher.calculateBackoffDelay(3) // 60000 (60s) + * HTTPFetcher.calculateBackoffDelay(6) // 300000 (5min, capped) + */ + static calculateBackoffDelay (attempt, { baseDelay = 15000, maxDelay = 300000 } = {}) { + return Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay); + } + + /** + * Creates a new HTTPFetcher instance + * @param {string} url - The URL to fetch + * @param {object} options - Configuration options + * @param {number} [options.reloadInterval] - Time in ms between fetches (default: 5 min) + * @param {object} [options.auth] - Authentication options + * @param {string} [options.auth.method] - 'basic' or 'bearer' + * @param {string} [options.auth.user] - Username for basic auth + * @param {string} [options.auth.pass] - Password or token + * @param {boolean} [options.selfSignedCert] - Accept self-signed certificates + * @param {object} [options.headers] - Additional headers to send + * @param {number} [options.maxRetries] - Max retries for 5xx errors (default: 3) + * @param {number} [options.timeout] - Request timeout in ms (default: 30000) + * @param {string} [options.logContext] - Optional context for log messages (e.g., provider name) + */ + constructor (url, options = {}) { + super(); + + this.url = url; + this.reloadInterval = options.reloadInterval || 5 * 60 * 1000; + this.auth = options.auth || null; + this.selfSignedCert = options.selfSignedCert || false; + this.customHeaders = options.headers || {}; + this.maxRetries = options.maxRetries || MAX_SERVER_BACKOFF; + this.timeout = options.timeout || DEFAULT_TIMEOUT; + this.logContext = options.logContext ? `[${options.logContext}] ` : ""; + + this.reloadTimer = null; + this.serverErrorCount = 0; + this.networkErrorCount = 0; + } + + /** + * Clears any pending reload timer + */ + clearTimer () { + if (this.reloadTimer) { + clearTimeout(this.reloadTimer); + this.reloadTimer = null; + } + } + + /** + * Schedules the next fetch. + * If no delay is provided, uses reloadInterval. + * If delay is provided but very short (< 1 second), clamps to reloadInterval + * to prevent hammering servers. + * @param {number} [delay] - Delay in milliseconds + */ + scheduleNextFetch (delay) { + let nextDelay = delay ?? this.reloadInterval; + + // Only clamp if delay is unreasonably short (< 1 second) + // This allows respecting Retry-After headers while preventing abuse + if (nextDelay < 1000) { + nextDelay = this.reloadInterval; + } + + // Don't schedule in test mode + if (process.env.mmTestMode === "true") { + return; + } + + this.reloadTimer = setTimeout(() => this.fetch(), nextDelay); + } + + /** + * Starts periodic fetching + */ + startPeriodicFetch () { + this.fetch(); + } + + /** + * Builds the options object for fetch + * @returns {object} Options object containing headers (and dispatcher if needed) + */ + getRequestOptions () { + const headers = { + "User-Agent": getUserAgent(), + ...this.customHeaders + }; + const options = { headers }; + + if (this.selfSignedCert) { + options.dispatcher = new Agent({ + connect: { + 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) { + // Try parsing as seconds + const seconds = Number(retryAfter); + if (!Number.isNaN(seconds) && seconds >= 0) { + return seconds * 1000; + } + + // Try parsing as HTTP-date + const retryDate = Date.parse(retryAfter); + if (!Number.isNaN(retryDate)) { + return Math.max(0, retryDate - Date.now()); + } + + return null; + } + + /** + * Returns a shortened version of the URL for log messages. + * @returns {string} Shortened URL + */ + #shortenUrl () { + try { + const urlObj = new URL(this.url); + return `${urlObj.origin}${urlObj.pathname}${urlObj.search.length > 50 ? "?..." : urlObj.search}`; + } catch { + return this.url; + } + } + + /** + * Determines the retry delay for a non-ok response + * @param {Response} response - The fetch Response object + * @returns {{delay: number, errorInfo: object}} Computed retry delay and error info + */ + #getDelayForResponse (response) { + const { status } = response; + let delay = this.reloadInterval; + let message; + let errorType = "UNKNOWN_ERROR"; + + if (status === 401 || status === 403) { + errorType = "AUTH_FAILURE"; + delay = Math.max(this.reloadInterval * 5, THIRTY_MINUTES); + message = `Authentication failed (${status}). Check your API key. Waiting ${Math.round(delay / 60000)} minutes before retry.`; + Log.error(`${this.logContext}${this.#shortenUrl()} - ${message}`); + } else if (status === 429) { + errorType = "RATE_LIMITED"; + 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); + message = `Rate limited (429). Retrying in ${Math.round(delay / 60000)} minutes.`; + Log.warn(`${this.logContext}${this.#shortenUrl()} - ${message}`); + } else if (status >= 500) { + errorType = "SERVER_ERROR"; + this.serverErrorCount = Math.min(this.serverErrorCount + 1, this.maxRetries); + if (this.serverErrorCount >= this.maxRetries) { + delay = this.reloadInterval; + message = `Server error (${status}). Max retries reached, retrying at configured interval (${Math.round(delay / 1000)}s).`; + } else { + delay = HTTPFetcher.calculateBackoffDelay(this.serverErrorCount, { + maxDelay: this.reloadInterval + }); + message = `Server error (${status}). Retry #${this.serverErrorCount} in ${Math.round(delay / 1000)}s.`; + } + Log.error(`${this.logContext}${this.#shortenUrl()} - ${message}`); + } else if (status >= 400) { + errorType = "CLIENT_ERROR"; + delay = Math.max(this.reloadInterval * 2, FIFTEEN_MINUTES); + message = `Client error (${status}). Retrying in ${Math.round(delay / 60000)} minutes.`; + Log.error(`${this.logContext}${this.#shortenUrl()} - ${message}`); + } else { + message = `Unexpected HTTP status ${status}.`; + Log.error(`${this.logContext}${this.#shortenUrl()} - ${message}`); + } + + return { + delay, + errorInfo: this.#createErrorInfo(message, status, errorType, delay) + }; + } + + /** + * Creates a standardized error info object + * @param {string} message - Error message + * @param {number|null} status - HTTP status code or null for network errors + * @param {string} errorType - Error type: AUTH_FAILURE, RATE_LIMITED, SERVER_ERROR, CLIENT_ERROR, NETWORK_ERROR + * @param {number} retryAfter - Delay until next retry in ms + * @param {Error} [originalError] - The original error if any + * @returns {object} Error info object with translationKey for direct use + */ + #createErrorInfo (message, status, errorType, retryAfter, originalError = null) { + return { + message, + status, + errorType, + translationKey: ERROR_TYPE_TO_TRANSLATION[errorType] || "MODULE_ERROR_UNSPECIFIED", + retryAfter, + retryCount: errorType === "NETWORK_ERROR" ? this.networkErrorCount : this.serverErrorCount, + url: this.url, + originalError + }; + } + + /** + * Performs the HTTP fetch and emits appropriate events + * @fires HTTPFetcher#response + * @fires HTTPFetcher#error + */ + async fetch () { + this.clearTimer(); + + let nextDelay = this.reloadInterval; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const requestOptions = this.getRequestOptions(); + // Use undici.fetch when a custom dispatcher is present (e.g. selfSignedCert), + // because Node's global fetch and npm undici@8 Agents are incompatible. + // For regular requests, use globalThis.fetch so MSW and other interceptors work. + const fetchFn = requestOptions.dispatcher ? undiciFetch : globalThis.fetch; + const response = await fetchFn(this.url, { + ...requestOptions, + signal: controller.signal + }); + + const isSuccessfulResponse = response.ok || response.status === 304; + + if (isSuccessfulResponse) { + // Reset error counts on success + this.serverErrorCount = 0; + this.networkErrorCount = 0; + + /** + * Response event - fired when fetch succeeds (including 304) + * @event HTTPFetcher#response + * @type {Response} + */ + this.emit("response", response); + } else { + const { delay, errorInfo } = this.#getDelayForResponse(response); + nextDelay = delay; + this.emit("error", errorInfo); + } + } catch (error) { + const isTimeout = error.name === "AbortError"; + const message = isTimeout ? `Request timeout after ${this.timeout}ms` : `Network error: ${error.message}`; + + this.networkErrorCount = Math.min(this.networkErrorCount + 1, this.maxRetries); + const exhausted = this.networkErrorCount >= this.maxRetries; + + if (exhausted) { + nextDelay = this.reloadInterval; + Log.error(`${this.logContext}${this.#shortenUrl()} - ${message} Max retries reached, retrying at configured interval (${Math.round(nextDelay / 1000)}s).`); + } else { + nextDelay = HTTPFetcher.calculateBackoffDelay(this.networkErrorCount, { + maxDelay: this.reloadInterval + }); + const retryMsg = `${this.logContext}${this.#shortenUrl()} - ${message} Retry #${this.networkErrorCount} in ${Math.round(nextDelay / 1000)}s.`; + if (this.networkErrorCount <= 2) { + Log.warn(retryMsg); + } else { + Log.error(retryMsg); + } + } + + const errorInfo = this.#createErrorInfo( + message, + null, + "NETWORK_ERROR", + nextDelay, + error + ); + this.emit("error", errorInfo); + } finally { + clearTimeout(timeoutId); + } + + this.scheduleNextFetch(nextDelay); + } +} + +module.exports = HTTPFetcher; diff --git a/js/ip_access_control.js b/js/ip_access_control.js new file mode 100644 index 0000000..3357d37 --- /dev/null +++ b/js/ip_access_control.js @@ -0,0 +1,109 @@ +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 { + Log.warn(`Invalid whitelist entry: ${entry}`); + return false; + } + }); + } catch { + Log.warn(`Failed to parse client IP: ${clientIp}`); + return false; + } +} + +/** + * Resolves a client IP for both Express and Socket.IO requests. + * If the direct peer is loopback, trust the first X-Forwarded-For value (local reverse proxy case). + * Otherwise ignore X-Forwarded-For to prevent spoofing. + * @param {object} req - Incoming request object (Express request or Socket.IO handshake request) + * @returns {string} The resolved client IP address + */ +function resolveClientIp (req) { + const directIp = req.socket?.remoteAddress || req.connection?.remoteAddress || req.ip; + const LOOPBACK_WHITELIST = ["127.0.0.1", "::ffff:127.0.0.1", "::1"]; + + if (isAllowed(directIp, LOOPBACK_WHITELIST)) { + const forwardedFor = req.headers?.["x-forwarded-for"]; + if (typeof forwardedFor === "string" && forwardedFor.trim().length > 0) { + return forwardedFor.split(",")[0].trim(); + } + } + + return directIp; +} + +/** + * 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 = resolveClientIp(req); + + if (isAllowed(clientIp, whitelist)) { + res.header("Access-Control-Allow-Origin", "*"); + next(); + } else { + Log.warn(`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."); + } + }; +} + +/** + * Creates a Socket.IO `allowRequest` handler that enforces the same IP whitelist as the HTTP middleware. + * This closes the gap where Socket.IO handshakes bypassed the Express-only `ipAccessControl` middleware. + * @param {string[]} whitelist - Array of allowed IP addresses or CIDR ranges + * @returns {(req: object, callback: (err: string | null, success: boolean) => void) => void} Socket.IO allowRequest handler + */ +function socketIpAccessControl (whitelist) { + // Empty whitelist means allow all + if (!Array.isArray(whitelist) || whitelist.length === 0) { + return function (req, callback) { + callback(null, true); // allow the connection + }; + } + + return function (req, callback) { + const clientIp = resolveClientIp(req); + if (isAllowed(clientIp, whitelist)) { + callback(null, true); // allow the connection + } else { + Log.warn(`IP ${clientIp} is not allowed to connect to the mirror socket`); + callback("This device is not allowed to access your mirror.", false); + } + }; +} + +module.exports = { ipAccessControl, socketIpAccessControl }; diff --git a/js/loader.js b/js/loader.js new file mode 100644 index 0000000..494ed5a --- /dev/null +++ b/js/loader.js @@ -0,0 +1,308 @@ +/* global defaultModules, vendor */ + +/* Module state */ + +const loadedModuleFiles = []; +const loadedFiles = []; +const moduleObjects = []; + +/** + * Get environment variables from config. + * @returns {object} Env vars with modulesDir and customCss paths from config. + */ +function getEnvVarsFromConfig () { + return { + modulesDir: config.foreignModulesDir || "modules", + defaultModulesDir: config.defaultModulesDir || "defaultmodules", + customCss: config.customCss || "config/custom.css" + }; +} + +/** + * Retrieve object of env variables. + * @returns {object} with key: values as assembled in js/server_functions.js + */ +async function getEnvVars () { + // 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. + */ +async function startModules () { + 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 + */ +function getAllModules () { + 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. + */ +async function getModuleData () { + 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 = `${envVars.defaultModulesDir}/${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 + */ +async function loadModule (module) { + const url = module.path + module.file; + + /** + * @returns {Promise} + */ + async function afterLoad () { + 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. + */ +async function bootstrapModule (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 + */ +function loadFile (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 "mjs": + return new Promise((resolve) => { + Log.log(`Load module script: ${fileName}`); + script = document.createElement("script"); + script.type = "module"; + script.src = fileName; + script.onload = function () { + resolve(); + }; + script.onerror = function () { + Log.error("Error on loading module 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 */ + +export const Loader = { + + /** + * 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 defined in js/vendor.js. + * @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 + */ + loadFileForModule (fileName, module) { + if (loadedFiles.indexOf(fileName.toLowerCase()) !== -1) { + Log.log(`File already loaded: ${fileName}`); + return Promise.resolve(); + } + + 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 defined in js/vendor.js. + // Load it from its location. + 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)); + } +}; diff --git a/js/logger.js b/js/logger.js new file mode 100644 index 0000000..28d6db5 --- /dev/null +++ b/js/logger.js @@ -0,0 +1,114 @@ +// Logger for MagicMirror² — works both in Node.js (CommonJS) and the browser (global). +(function () { + if (typeof module !== "undefined") { + if (process.env.mmTestMode !== "true") { + const { styleText } = require("node:util"); + + const LABEL_COLORS = { error: "red", warn: "yellow", debug: "bgBlue", info: "blue" }; + const MSG_COLORS = { error: "red", warn: "yellow", info: "blue" }; + + const formatTimestamp = () => { + const d = new Date(); + const pad2 = (n) => String(n).padStart(2, "0"); + const pad3 = (n) => String(n).padStart(3, "0"); + const date = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; + const time = `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}.${pad3(d.getMilliseconds())}`; + return `[${date} ${time}]`; + }; + + const getCallerPrefix = () => { + try { + const lines = new Error().stack.split("\n"); + for (const line of lines) { + if (line.includes("node:") || line.includes("js/logger.js") || line.includes("node_modules")) continue; + const match = line.match(/\((.+?\.js):\d+:\d+\)/) || line.match(/at\s+(.+?\.js):\d+:\d+/); + if (match) { + const file = match[1]; + const baseName = file.replace(/.*\/(.*)\.js/, "$1"); + const parentDir = file.replace(/.*\/(.*)\/.*\.js/, "$1"); + return styleText("gray", parentDir === "js" ? `[${baseName}]` : `[${parentDir}]`); + } + } + } catch { /* ignore */ } + return styleText("gray", "[unknown]"); + }; + + // Patch console methods to prepend timestamp, level label, and caller prefix. + for (const method of ["debug", "log", "info", "warn", "error"]) { + const original = console[method].bind(console); + const labelRaw = `[${method.toUpperCase()}]`.padEnd(7); + const label = LABEL_COLORS[method] ? styleText(LABEL_COLORS[method], labelRaw) : labelRaw; + console[method] = (...args) => { + const prefix = `${formatTimestamp()} ${label} ${getCallerPrefix()}`; + const msgColor = MSG_COLORS[method]; + if (msgColor && args.length > 0 && typeof args[0] === "string") { + original(prefix, styleText(msgColor, args[0]), ...args.slice(1)); + } else { + original(prefix, ...args); + } + }; + } + } + // Node, CommonJS + module.exports = makeLogger(); + } else { + // Browser globals + window.Log = makeLogger(); + } + + /** + * Creates the logger object. Logging is disabled when running in test mode + * (Node.js) or inside jsdom (browser). + * @returns {object} The logger object with log level methods. + */ + function makeLogger () { + const enableLog = typeof module !== "undefined" + ? process.env.mmTestMode !== "true" + : typeof window === "object" && window.name !== "jsdom"; + + let logLevel; + + if (enableLog) { + logLevel = { + debug: console.debug.bind(console), + log: console.log.bind(console), + info: console.info.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), + group: console.group.bind(console), + groupCollapsed: console.groupCollapsed.bind(console), + groupEnd: console.groupEnd.bind(console), + time: console.time.bind(console), + timeEnd: console.timeEnd.bind(console), + timeStamp: console.timeStamp.bind(console) + }; + + // Only these methods are affected by setLogLevel. + // Utility methods (group, time, etc.) are always active. + logLevel.setLogLevel = function (newLevel) { + for (const key of ["debug", "log", "info", "warn", "error"]) { + const disabled = newLevel && !newLevel.includes(key.toUpperCase()); + logLevel[key] = disabled ? function () {} : console[key].bind(console); + } + }; + } else { + logLevel = { + debug () {}, + log () {}, + info () {}, + warn () {}, + error () {}, + group () {}, + groupCollapsed () {}, + groupEnd () {}, + time () {}, + timeEnd () {}, + timeStamp () {} + }; + + logLevel.setLogLevel = function () {}; + } + + return logLevel; + } +}()); diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..7d2c95e --- /dev/null +++ b/js/main.js @@ -0,0 +1,714 @@ +/* global addAnimateCSS, removeAnimateCSS, AnimateCSSIn, AnimateCSSOut, modulePositions, io */ + +// eslint-disable-next-line import-x/extensions +import { Loader } from "./loader.js"; + +let modules = []; + +/** + * Create dom objects for all modules that are configured for a specific position. + */ +async function createDomObjects () { + 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); + + domCreationPromises.push(createModuleDom(module, haveAnimateIn)); + }); + + updateWrapperStates(); + + try { + await Promise.all(domCreationPromises); + _sendNotification("DOM_OBJECTS_CREATED"); + } catch (error) { + Log.error(error); + } +} + +/** + * Create and render a module DOM, then notify the module. + * @param {Module} module The module to render. + * @param {string|null} haveAnimateIn Optional animateIn animation name. + * @returns {Promise} Resolved when module DOM is created. + */ +async function createModuleDom (module, haveAnimateIn) { + if (haveAnimateIn) { + await _updateDom(module, { options: { speed: 1000, animate: { in: haveAnimateIn } } }, true); + } else { + await _updateDom(module, 0); + } + + _sendNotification("MODULE_DOM_CREATED", null, null, module); +} + +/** + * Select the wrapper dom object for a specific position. + * @param {string} position The name of the position. + * @returns {HTMLElement | void} the wrapper element + */ +function selectWrapper (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. + */ +function _sendNotification (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. + */ +async function _updateDom (module, updateOptions, createAnimatedDom = false) { + 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(); + const newContent = await module.getDom(); + await updateDomWithContent(module, speed, newHeader, newContent, animateOut, animateIn, createAnimatedDom); +} + +/** + * Update the dom with the specified content + * @param {Module} module The module that needs an update. + * @param {number} [speed] The (optional) number of milliseconds 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] If true, apply content and trigger only animateIn (used on first start). + * @returns {Promise} Resolved after the module DOM update is applied or hide/show transition is scheduled. + */ +async function updateDomWithContent (module, speed, newHeader, newContent, animateOut, animateIn, createAnimatedDom = false) { + if (module.hidden || !speed) { + updateModuleContent(module, newHeader, newContent); + return; + } + + if (!moduleNeedsUpdate(module, newHeader, newContent)) { + return; + } + + if (createAnimatedDom && animateIn !== null) { + Log.debug(`${module.identifier} createAnimatedDom (${animateIn})`); + updateModuleContent(module, newHeader, newContent); + if (!module.hidden) { + _showModule(module, speed, null, { animate: animateIn }); + } + return; + } + + await new Promise((resolve) => _hideModule(module, speed / 2, resolve, { animate: animateOut })); + updateModuleContent(module, newHeader, newContent); + if (!module.hidden) { + await new Promise((resolve) => _showModule(module, speed / 2, resolve, { animate: animateIn })); + } +} + +/** + * 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 + */ +function moduleNeedsUpdate (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. + */ +function updateModuleContent (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 {() => void} callback Called when the animation is done. + * @param {object} [options] Optional settings for the hide method. + */ +function _hideModule (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 {() => void} callback Called when the animation is done. + * @param {object} [options] Optional settings for the show method. + */ +function _showModule (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. + void 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. + */ +function updateWrapperStates () { + 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 from the server (already combined with the system defaults). + */ +async function loadConfig () { + try { + const res = await fetch(new URL("config/", `${location.origin}${config.basePath}`)); + + // The server tags functions as { __mmFunction: "" } because + // JSON.stringify can't serialise live functions. This reviver turns + // those tagged objects back into callable functions. + config = JSON.parse(await res.text(), (key, value) => { + if (value && typeof value === "object" && typeof value.__mmFunction === "string") { + try { + return new Function(`return (${value.__mmFunction})`)(); + } catch { + Log.warn(`Failed to revive function for config key "${key}".`); + } + } + return value; + }); + } catch (error) { + Log.error("Unable to retrieve config", error); + } +} + +/** + * Adds special selectors on a collection of modules. + * @param {Module[]} modules Array of modules. + */ +function setSelectionMethodsForModules (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. + */ + function withClass (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. + */ + function exceptWithClass (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. + */ + function modulesByClass (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. + */ + function exceptModule (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. + */ + function enumerate (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 }); + } +} + +export const MM = { + + /* Public Methods */ + + /** + * Main init method. + */ + async init () { + Log.info("Initializing MagicMirror²."); + await loadConfig(); + + Log.setLogLevel(config.logLevel); + + await globalThis.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) + */ + async 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. + await _updateDom(module, updateOptions); + // 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 {() => void} 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 {() => void} 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 +}; + +// Legacy global bridge for third-party modules that reference window.MM directly. +if (!globalThis.MM) globalThis.MM = MM; + +MM.init(); diff --git a/js/module.js b/js/module.js new file mode 100644 index 0000000..a10edc5 --- /dev/null +++ b/js/module.js @@ -0,0 +1,569 @@ +/* global nunjucks */ + +// eslint-disable-next-line import-x/extensions +import { Loader } from "./loader.js"; +// eslint-disable-next-line import-x/extensions +import { MMSocket } from "./socketclient.js"; + +/* + * Module Blueprint. + * @typedef {Object} Module + */ +export class Module { + + /** + * Initializes per-instance mutable state. + */ + constructor () { + // Timer reference used for showHide animation callbacks. + this.showHideTimer = null; + + /* + * Array to store lockStrings. These strings are used to lock + * visibility when hiding and showing module. + */ + this.lockStrings = []; + + /* + * Storage of the nunjucks Environment. + * This should not be referenced directly. + * Use the nunjucksEnvironment() method to get it. + */ + this._nunjucksEnvironment = null; + } + + /** + ********************************************************* + * All methods (and properties) below can be overridden. * + ********************************************************* + */ + + /** + * Called when the module is instantiated. + */ + init () { + } + + /** + * Called when the module is started. + */ + 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 {() => void} 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 {() => void} 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 + ); + } +} + +globalThis.Module = Module; + +/** + * 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); + const className = typeof name === "string" && name.trim() ? name : "AnonymousModule"; + + // Note that we clone the definition. Otherwise the objects are shared, which gives problems. + const SubClass = { + [className]: class extends Module { + constructor () { + super(); + Object.assign(this, clonedDefinition); + if (typeof this.init === "function") { + this.init(); + } + } + } + }[className]; + + return new SubClass(); +}; + +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; +}; + +/** + * 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 + */ +export 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; +} + +/** + * Define the clone method for later use. Helper Method. + * @param {object} obj Object to be cloned + * @returns {object} the cloned object + */ +export function cloneObject (obj) { + if (obj === null || typeof obj !== "object") { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map((item) => cloneObject(item)); + } + + const tag = Object.prototype.toString.call(obj); + + if (tag === "[object RegExp]") { + return new RegExp(obj); + } + + if (tag === "[object Date]") { + return new Date(obj.getTime()); + } + + const proto = Object.getPrototypeOf(obj); + const isPlainObject = proto === null || Object.getPrototypeOf(proto) === null; + + // Avoid calling class constructors without "new". Preserve unknown objects by reference. + if (!isPlainObject) { + return obj; + } + + const temp = {}; + for (const key of Object.keys(obj)) { + temp[key] = cloneObject(obj[key]); + } + + return temp; +} diff --git a/js/node_helper.js b/js/node_helper.js new file mode 100644 index 0000000..1883bd6 --- /dev/null +++ b/js/node_helper.js @@ -0,0 +1,178 @@ +const express = require("express"); +const Log = require("logger"); +const { replaceSecretPlaceholder } = require("#server_functions"); + +/** + * Determine which secrets a module is allowed to restore. A module may only + * restore the `**SECRET_***` placeholders that appear in its own config — the + * exact inverse of how the config is redacted before it is sent to the browser. + * @param {string} moduleName - Name of the module. + * @returns {Set} The secret names the module may restore. + */ +function getAllowedSecrets (moduleName) { + const modules = global.configRedacted?.modules || []; + const moduleConfig = modules.find((m) => m.module === moduleName); + const allowed = new Set(); + if (moduleConfig) { + // Stringify the config to easily find all expected **SECRET_*** placeholders + for (const [, secretName] of JSON.stringify(moduleConfig).matchAll(/\*\*(SECRET_[^*]+)\*\*/g)) { + allowed.add(secretName); + } + } + return allowed; +} + +class NodeHelper { + 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) => { + if (config?.hideConfigSecrets && payload && typeof payload === "object") { + try { + // Calculate exactly which secrets this module is allowed to receive + const allowedSecrets = getAllowedSecrets(this.name); + // Expand only these safe, module-specific secrets in the payload + const payloadStr = replaceSecretPlaceholder(JSON.stringify(payload), allowedSecrets); + this.socketNotificationReceived(notification, JSON.parse(payloadStr)); + } catch (e) { + Log.error("Error substituting variables in payload: ", e); + this.socketNotificationReceived(notification, payload); + } + } else { + this.socketNotificationReceived(notification, payload); + } + }); + }); + } + + /** + * Check the status of a fetch response. + * @param {Response} response The fetch response. + * @returns {Response} The fetch response if ok. + */ + static checkFetchStatus (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 + */ + static checkFetchError (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; + } + + /** + * Create a new NodeHelper subclass with the given module definition. + * @param {object} moduleDefinition Methods and properties for the helper. + * @returns {typeof NodeHelper} A new subclass of NodeHelper. + */ + static create (moduleDefinition) { + return class extends NodeHelper { + constructor () { + super(); + Object.assign(this, moduleDefinition); + this.init(); + } + }; + } +} + +module.exports = NodeHelper; diff --git a/js/releasenotes.js b/js/releasenotes.js new file mode 100644 index 0000000..13c66ab --- /dev/null +++ b/js/releasenotes.js @@ -0,0 +1,198 @@ +/* eslint no-console: "off" */ +const util = require("node:util"); +const exec = util.promisify(require("node:child_process").exec); +const fs = require("node:fs"); + +const createReleaseNotes = async () => { + let repoName = "MagicMirrorOrg/MagicMirror"; + if (process.env.GITHUB_REPOSITORY) { + repoName = process.env.GITHUB_REPOSITORY; + } + const baseUrl = `https://api.github.com/repos/${repoName}`; + + const getOptions = (type) => { + if (process.env.GITHUB_TOKEN) { + return { method: `${type}`, headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } }; + } else { + return { method: `${type}` }; + } + }; + + const execShell = async (command) => { + const { stdout = "", stderr = "" } = await exec(command); + if (stderr) console.error(`Error in execShell executing command ${command}: ${stderr}`); + return stdout; + }; + + // Check Draft Release + const draftReleases = []; + const jsonReleases = await fetch(`${baseUrl}/releases`, getOptions("GET")).then((res) => res.json()); + for (const rel of jsonReleases) { + if (rel.draft && rel.tag_name === "" && rel.published_at === null && rel.name === "unreleased") draftReleases.push(rel); + } + + let draftReleaseId = 0; + if (draftReleases.length > 1) { + throw new Error("More than one draft release found, exiting."); + } else { + if (draftReleases[0]) draftReleaseId = draftReleases[0].id; + } + + // Get last Git Tag + const gitTag = await execShell("git describe --tags `git rev-list --tags --max-count=1`"); + const lastTag = gitTag.toString().replaceAll("\n", ""); + console.info(`latest tag is ${lastTag}`); + + // Get Git Commits + const gitOut = await execShell(`git log develop --pretty=format:"%H --- %s" --after="$(git log -1 --format=%aI ${lastTag})"`); + console.info(gitOut); + const commits = gitOut.toString().split("\n"); + + // Get Node engine version from package.json + const nodeVersion = JSON.parse(fs.readFileSync("package.json")).engines.node; + + // Search strings + const labelArr = ["alert", "calendar", "clock", "compliments", "helloworld", "newsfeed", "updatenotification", "weather", "envcanada", "openmeteo", "openweathermap", "smhi", "ukmetoffice", "yr", "eslint", "bump", "dependencies", "deps", "logg", "translation", "test", "ci"]; + + // Map search strings to categories + const getFirstLabel = (text) => { + let res; + labelArr.every((item) => { + const labelIncl = text.includes(item); + if (labelIncl) { + switch (item) { + case "ci": + case "test": + res = "testing"; + break; + case "logg": + res = "logging"; + break; + case "eslint": + case "bump": + case "deps": + res = "dependencies"; + break; + case "envcanada": + case "openmeteo": + case "openweathermap": + case "smhi": + case "ukmetoffice": + case "yr": + case "weather": + res = "modules/weather"; + break; + case "alert": + res = "modules/alert"; + break; + case "calendar": + res = "modules/calendar"; + break; + case "clock": + res = "modules/clock"; + break; + case "compliments": + res = "modules/compliments"; + break; + case "helloworld": + res = "modules/helloworld"; + break; + case "newsfeed": + res = "modules/newsfeed"; + break; + case "updatenotification": + res = "modules/updatenotification"; + break; + default: + res = item; + break; + } + return false; + } else { + return true; + } + }); + if (!res) res = "core"; + return res; + }; + + const grouped = {}; + const contrib = []; + const sha = []; + + // Loop through each Commit + for (const item of commits) { + + const cm = item.trim(); + // ignore `prepare release` line + if (cm.length > 0 && !cm.match(/^.* --- prepare .*-develop$/gi)) { + + const [ref, title] = cm.split(" --- "); + + const groupTitle = getFirstLabel(title.toLowerCase()); + + if (!grouped[groupTitle]) { + grouped[groupTitle] = []; + } + + grouped[groupTitle].push(`- ${title}`); + + sha.push(ref); + } + } + + // function to remove duplicates + const sortedArr = (arr) => { + return arr.filter((item, + index) => (arr.indexOf(item) === index && item !== "@dependabot[bot]")).sort(function (a, b) { + return a.toLowerCase().localeCompare(b.toLowerCase()); + }); + }; + + // Get Contributors logins + for (const ref of sha) { + const jsonRes = await fetch(`${baseUrl}/commits/${ref}`, getOptions("GET")).then((res) => res.json()); + + if (jsonRes && jsonRes.author && jsonRes.author.login) contrib.push(`@${jsonRes.author.login}`); + } + + // Build Markdown content + let markdown = "## Release Notes\n"; + + markdown += `Thanks to: ${sortedArr(contrib).join(", ")}\n`; + markdown += `> ⚠️ This release needs nodejs version ${nodeVersion}\n`; + markdown += "\n"; + markdown += `[Compare to previous Release ${lastTag}](https://github.com/${repoName}/compare/${lastTag}...develop)\n\n`; + + const sorted = Object.keys(grouped) + .sort() // Sort the keys alphabetically + .reduce((obj, key) => { + obj[key] = grouped[key]; // Rebuild the object with sorted keys + return obj; + }, {}); + + for (const group in sorted) { + markdown += `\n### [${group}]\n`; + markdown += `${sorted[group].join("\n")}\n`; + } + + console.info(markdown); + + // Create Github Release + if (process.env.GITHUB_TOKEN) { + if (draftReleaseId > 0) { + // delete release + await fetch(`${baseUrl}/releases/${draftReleaseId}`, getOptions("DELETE")); + console.info(`Old Release with id ${draftReleaseId} deleted.`); + } + + const relContent = getOptions("POST"); + relContent.body = JSON.stringify( + { tag_name: "", name: "unreleased", body: `${markdown}`, draft: true } + ); + const createRelease = await fetch(`${baseUrl}/releases`, relContent).then((res) => res.json()); + console.info(`New release created with id ${createRelease.id}, GitHub-Url: ${createRelease.html_url}`); + } +}; + +createReleaseNotes(); diff --git a/js/server.js b/js/server.js new file mode 100644 index 0000000..bb27a65 --- /dev/null +++ b/js/server.js @@ -0,0 +1,173 @@ +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 { ipAccessControl, socketIpAccessControl } = require("./ip_access_control"); + +const vendor = require("./vendor"); + +const { getHtml, getVersion, getEnvVars, cors } = require("#server_functions"); + +/** + * Server + * @param {object} configObj The MM config full and redacted + * @class + */ +function Server (configObj) { + const config = configObj.fullConf; + 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, { + allowRequest: socketIpAccessControl(config.ipWhitelist), + 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)); + + if (config.hideConfigSecrets) { + app.get("/config/config.env", (req, res) => { + res.status(404).send("\n\n\n\nError\n\n\n
Cannot GET /config/config.env
\n\n"); + }); + } + + let directories = ["/config", "/css", "/favicon.svg", "/defaultmodules", "/modules", "/node_modules/animate.css", "/node_modules/@fontsource", "/node_modules/@fortawesome", "/node_modules/suncalc", "/translations", "/tests/configs", "/tests/mocks"]; + for (const value of Object.values(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))); + } + + const startUp = new Date(); + const getStartup = (req, res) => res.send(startUp); + + const getConfig = (req, res) => { + const obj = config.hideConfigSecrets ? configObj.redactedConf : configObj.fullConf; + // Functions can't survive JSON.stringify, so we wrap them in a + // tagged object { __mmFunction: "" }. The client-side + // JSON reviver in main.js recognises this tag and reconstructs + // the live function from the source string. + const jsonString = JSON.stringify(obj, (key, value) => { + if (typeof value === "function") { + return { __mmFunction: value.toString() }; + } + return value; + }); + res.set("Content-Type", "application/json"); + res.send(jsonString); + }; + + app.get("/config", (req, res) => getConfig(req, res)); + + app.get("/cors", async (req, res) => await cors(req, res)); + + app.get("/version", (req, res) => getVersion(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; diff --git a/js/server_functions.js b/js/server_functions.js new file mode 100644 index 0000000..55f9b81 --- /dev/null +++ b/js/server_functions.js @@ -0,0 +1,272 @@ +const dns = require("node:dns"); +const fs = require("node:fs"); +const path = require("node:path"); +const ipaddr = require("ipaddr.js"); +const undici = require("undici"); +const Log = require("logger"); + +const startUp = new Date(); + +/** + * Gets the startup time. + * @param {Request} req - the request + * @param {Response} res - the result + */ +function getStartup (req, res) { + res.send(startUp); +} + +/** + * Replace `**SECRET_ABC**` placeholders with the value of `process.env.SECRET_ABC`. + * + * If `allowedSecrets` is given, only those secret names are restored and every + * other placeholder is left untouched. Without it, all secrets are restored + * (used by the CORS proxy, which only runs on the trusted server side). + * @param {string} input - String that may contain `**SECRET_***` placeholders. + * @param {Set} [allowedSecrets] - Secret names that may be restored. + * @returns {string} The input with the allowed placeholders replaced. + */ +function replaceSecretPlaceholder (input, allowedSecrets) { + if (global.config.cors === "allowAll") { + if (input.includes("**SECRET_")) { + Log.error("Replacing secrets doesn't work with CORS `allowAll`, you need to set `cors` to `disabled` or `allowWhitelist` in `config.js`"); + } + return input; + } + return input.replaceAll(/\*\*(SECRET_[^*]+)\*\*/g, (placeholder, secretName) => { + // Block replacing secrets that are not explicitly allowed. + if (allowedSecrets && !allowedSecrets.has(secretName)) { + return placeholder; + } + + // Load the real value from the environment. Fallback to placeholder if missing. + return process.env[secretName] || placeholder; + }); +} + +/** + * 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) { + if (global.config.cors === "disabled") { + Log.error("CORS is disabled, you need to enable it in `config.js` by setting `cors` to `allowAll` or `allowWhitelist`"); + return res.status(403).json({ error: "CORS proxy is disabled" }); + } + let url; + try { + const urlRegEx = "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]; + if (typeof global.config !== "undefined") { + if (config.hideConfigSecrets) { + url = replaceSecretPlaceholder(url); + } + } + + // Validate protocol before attempting connection (non-http/https are never allowed) + let parsed; + try { + parsed = new URL(url); + } catch { + Log.warn(`SSRF blocked (invalid URL): ${url}`); + return res.status(403).json({ error: "Forbidden: private or reserved addresses are not allowed" }); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + Log.warn(`SSRF blocked (protocol): ${url}`); + return res.status(403).json({ error: "Forbidden: private or reserved addresses are not allowed" }); + } + + // Block localhost by hostname before even creating the dispatcher (no DNS needed). + if (parsed.hostname.toLowerCase() === "localhost") { + Log.warn(`SSRF blocked (localhost): ${url}`); + return res.status(403).json({ error: "Forbidden: private or reserved addresses are not allowed" }); + } + + // Whitelist check: if enabled, only allow explicitly listed domains + if (global.config.cors === "allowWhitelist" && !global.config.corsDomainWhitelist.includes(parsed.hostname.toLowerCase())) { + Log.warn(`CORS blocked (not in whitelist): ${url}`); + return res.status(403).json({ error: "Forbidden: domain not in corsDomainWhitelist" }); + } + + const headersToSend = getHeadersToSend(req.url); + const expectedReceivedHeaders = geExpectedReceivedHeaders(req.url); + Log.log(`cors url: ${url}`); + + // Resolve DNS once and validate the IP. The validated IP is then pinned + // for the actual connection so fetch() cannot re-resolve to a different + // address. This prevents DNS rebinding / TOCTOU attacks (GHSA-xhvw-r95j-xm4v). + const { address, family } = await dns.promises.lookup(parsed.hostname); + if (ipaddr.process(address).range() !== "unicast") { + Log.warn(`SSRF blocked: ${url}`); + return res.status(403).json({ error: "Forbidden: private or reserved addresses are not allowed" }); + } + + // Pin the validated IP — fetch() reuses it instead of doing its own DNS lookup + const dispatcher = new undici.Agent({ + connect: { + lookup: (_h, _o, cb) => { + const addresses = [{ address: address, family: family }]; + process.nextTick(() => cb(null, addresses)); + } + } + }); + + const response = await undici.fetch(url, { dispatcher, headers: headersToSend }); + if (response.ok) { + for (const header of expectedReceivedHeaders) { + const headerValue = response.headers.get(header); + if (header) res.set(header, headerValue); + } + const arrayBuffer = await response.arrayBuffer(); + res.send(Buffer.from(arrayBuffer)); + } else { + throw new Error(`Response status: ${response.status}`); + } + } + } catch (error) { + 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); + + 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 global.config === "undefined") { + return defaultUserAgent; + } + + switch (typeof global.config.userAgent) { + case "function": + return global.config.userAgent(); + case "string": + return global.config.userAgent; + default: + return defaultUserAgent; + } +} + +/** + * Gets environment variables needed in the browser. + * @returns {object} environment variables key: values + */ +function getEnvVarsAsObj () { + const obj = { modulesDir: `${global.config.foreignModulesDir}`, defaultModulesDir: `${global.config.defaultModulesDir}`, customCss: `${global.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, getHtml, getVersion, getStartup, getEnvVars, getEnvVarsAsObj, getUserAgent, getConfigFilePath, replaceSecretPlaceholder }; diff --git a/js/socketclient.js b/js/socketclient.js new file mode 100644 index 0000000..9a2dfbf --- /dev/null +++ b/js/socketclient.js @@ -0,0 +1,49 @@ +/* global io */ + +export 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); + }; +}; + +// Legacy global bridge for third-party modules that reference MMSocket directly. +if (!globalThis.MMSocket) globalThis.MMSocket = MMSocket; diff --git a/js/suncalc-global.mjs b/js/suncalc-global.mjs new file mode 100644 index 0000000..ff49182 --- /dev/null +++ b/js/suncalc-global.mjs @@ -0,0 +1,8 @@ +/* + * Bridge for SunCalc v2 (ESM-only entry): + * default modules still expect a global `SunCalc` from getScripts()/vendor loading. + * Keep this as a tiny compatibility shim until the module loader is fully ESM-first. + */ +import * as SunCalc from "../node_modules/suncalc/index.js"; + +globalThis.SunCalc = SunCalc; diff --git a/js/systeminformation.js b/js/systeminformation.js new file mode 100644 index 0000000..085c582 --- /dev/null +++ b/js/systeminformation.js @@ -0,0 +1,49 @@ +const os = require("node:os"); +const { execFileSync } = require("node:child_process"); +const si = require("systeminformation"); +// needed with relative path because logSystemInformation is called in an own process in app.js: +const mmVersion = require("../package").version; +const Log = require("./logger"); + +let mmGitHash = ""; +let mmGitBranch = ""; +try { + mmGitHash = execFileSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).trim(); + mmGitBranch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" }).trim(); +} catch { + // not a git repo or git not available +} + +const logSystemInformation = async () => { + try { + const system = await si.system(); + const osInfo = await si.osInfo(); + const versions = await si.versions(); + + 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 ####", + `- MM: version: v${mmVersion}${mmGitHash ? `; git: ${mmGitHash}` : ""}${mmGitBranch ? `; branch: ${mmGitBranch}` : ""}`, + `- SYSTEM: manufacturer: ${system.manufacturer}; model: ${system.model}; virtual: ${system.virtual}`, + `- OS: platform: ${osInfo.platform}; distro: ${osInfo.distro}; release: ${osInfo.release}; arch: ${osInfo.arch}; kernel: ${versions.kernel}`, + `- VERSIONS: electron: ${process.env.ELECTRON_VERSION}; used node: ${process.env.USED_NODE_VERSION}; 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); + } +}; + +module.exports = logSystemInformation; +logSystemInformation(); diff --git a/js/translator.js b/js/translator.js new file mode 100644 index 0000000..b1b52ec --- /dev/null +++ b/js/translator.js @@ -0,0 +1,129 @@ +/* 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 { + 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; diff --git a/js/utils.js b/js/utils.js new file mode 100644 index 0000000..ed86886 --- /dev/null +++ b/js/utils.js @@ -0,0 +1,284 @@ +const fs = require("node:fs"); +const { loadEnvFile } = require("node:process"); + +const modulePositions = []; // will get list from index.html +const regionRegEx = /"region ([^"]*)/i; +const indexFileName = "index.html"; +const discoveredPositionsJSFilename = "js/positions.js"; + +const { styleText } = require("node:util"); +const Log = require("logger"); +const globals = require("globals"); +const { Linter } = require("eslint"); +const { getConfigFilePath } = require("#server_functions"); + +const linter = new Linter({ configType: "flat" }); + +class ConfigError extends Error { + constructor (message) { + super(message); + this.name = "ConfigError"; + } +} + +const requireFromString = (src) => { + const m = new module.constructor(); + m._compile(src, ""); + return m.exports; +}; + +// return all available module positions +const getAvailableModulePositions = () => { + return modulePositions; +}; + +// return if position is on modulePositions Array (true/false) +const moduleHasValidPosition = (position) => { + if (getAvailableModulePositions().indexOf(position) === -1) return false; + return true; +}; + +const 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 { + 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; +}; + +/** + * 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 + */ +const 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 the config file. Combines it with the defaults and returns the config + * @returns {object} an object holding full and redacted config + */ +const loadConfig = () => { + Log.log("Loading config ..."); + const defaults = require("./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); + Log.warn("config.js.template files are deprecated and not used anymore. You can use variables inside config.js so copy the template file content into config.js if needed."); + } catch { + // no action + } + + // check if config.env exists + const configEnvFile = `${configFilename.substr(0, configFilename.lastIndexOf("."))}.env`; + try { + if (fs.existsSync(configEnvFile)) { + // load content into process.env + loadEnvFile(configEnvFile); + } + } catch (error) { + Log.log(`${configEnvFile} does not exist. ${error.message}`); + } + + // Load config.js and catch errors if not accessible + try { + let configContent = fs.readFileSync(configFilename, "utf-8"); + const hideConfigSecrets = configContent.match(/^\s*hideConfigSecrets: true.*$/m); + let configContentFull = configContent; + let configContentRedacted = hideConfigSecrets ? configContent : undefined; + Object.keys(process.env).forEach((env) => { + configContentFull = configContentFull.replaceAll(`\${${env}}`, process.env[env]); + if (hideConfigSecrets) { + if (env.startsWith("SECRET_")) { + configContentRedacted = configContentRedacted.replaceAll(`"\${${env}}"`, `"**${env}**"`); + configContentRedacted = configContentRedacted.replaceAll(`\${${env}}`, `**${env}**`); + } else { + configContentRedacted = configContentRedacted.replaceAll(`\${${env}}`, process.env[env]); + } + } + }); + configContentRedacted = configContentRedacted ? configContentRedacted : configContentFull; + const configObj = { + configFilename: configFilename, + configContentFull: configContentFull, + configContentRedacted: configContentRedacted, + redactedConf: Object.assign({}, defaults, requireFromString(configContentRedacted)), + fullConf: Object.assign({}, defaults, requireFromString(configContentFull)) + }; + + if (Object.keys(configObj.fullConf).length === 0) { + Log.error("WARNING! Config file appears empty, maybe missing module.exports last line?"); + } + checkDeprecatedOptions(configObj.fullConf); + + try { + const cfg = `let config = { basePath: "${configObj.fullConf.basePath}"};`; + fs.writeFileSync(`${global.root_path}/config/basepath.js`, cfg, "utf-8"); + } catch (error) { + Log.error(`Could not write config/basepath.js file: ${error.message}`); + } + + return configObj; + + } catch (error) { + if (error.code === "ENOENT") { + Log.error(`Could not find config file: ${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}`); + } + throw new ConfigError(""); + } +}; + +/** + * Checks the config file using eslint. + * @param {object} configObject the configuration object + */ +const checkConfigFile = (configObject) => { + let configObj = configObject; + if (!configObj) configObj = loadConfig(); + const configFileName = configObj.configFilename; + + // Validate syntax of the configuration file. + Log.info(`Checking config file ${configFileName} ...`); + + // I'm not sure if all ever is utf-8 + const configFile = configObj.configContentFull; + + 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(configObj.fullConf); + } 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); + throw new ConfigError(""); + } +}; + +/** + * Validates the modules array in the config object. + * Checks that: + * - `modules` is an array + * - every entry has a `module` property of type string + * - every entry's `position` (if set) is a known region from index.html + * + * Unknown positions produce a warning; structural errors are fatal. + * @param {object} data - The full config object to validate. + */ +const validateModulePositions = (data) => { + Log.info("Checking modules structure configuration ..."); + + const positionList = getModulePositions(); + + // `modules` always exists (defaults.js provides a default array), but guard against it being overridden with a non-array value + if (data.modules !== undefined && !Array.isArray(data.modules)) { + Log.error("This module configuration contains errors:\nmodules must be an array"); + throw new ConfigError(""); + } + + // Validate each module entry + for (const [index, mod] of (data.modules ?? []).entries()) { + // Each module entry must be an object so we can safely inspect its fields + if (mod === null || typeof mod !== "object" || Array.isArray(mod)) { + Log.error(`This module configuration contains errors:\n${JSON.stringify(mod, null, 2)}\nmodule entry must be an object`); + throw new ConfigError(""); + } + + // `module` (the module name) is required and must be a string + if (typeof mod.module !== "string") { + Log.error(`This module configuration contains errors:\n${JSON.stringify(mod, null, 2)}\nmodule: must be a string`); + throw new ConfigError(""); + } + + // `position` is optional, but must be a string when provided + if (mod.position !== undefined && typeof mod.position !== "string") { + Log.error(`This module configuration contains errors:\n${JSON.stringify(mod, null, 2)}\nposition: must be a string`); + throw new ConfigError(""); + } + + // `position` is optional, but when set it must match a known region + if (mod.position && !positionList.includes(mod.position)) { + Log.warn(`Module ${index} ("${mod.module}") uses unknown position: "${mod.position}"`); + Log.warn(`Known positions are: ${positionList.join(", ")}`); + } + } + + Log.info(styleText("green", "Your modules structure configuration doesn't contain errors :)")); +}; + +module.exports = { loadConfig, getModulePositions, moduleHasValidPosition, getAvailableModulePositions, checkConfigFile, ConfigError }; diff --git a/js/vendor.js b/js/vendor.js new file mode 100644 index 0000000..bdd781f --- /dev/null +++ b/js/vendor.js @@ -0,0 +1,14 @@ +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": "js/suncalc-global.mjs", + "croner.js": "node_modules/croner/dist/croner.umd.js" +}; + +if (typeof module !== "undefined") { + module.exports = vendor; +} diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..6ae848f --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,10 @@ +{ + // 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"] +} diff --git a/module-types.ts b/module-types.ts new file mode 100644 index 0000000..440b2c4 --- /dev/null +++ b/module-types.ts @@ -0,0 +1,34 @@ +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; +}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7a74fef --- /dev/null +++ b/package-lock.json @@ -0,0 +1,9318 @@ +{ + "name": "magicmirror", + "version": "2.37.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "magicmirror", + "version": "2.37.0", + "license": "MIT", + "dependencies": { + "@fontsource/roboto": "^5.2.10", + "@fontsource/roboto-condensed": "^5.2.8", + "@fortawesome/fontawesome-free": "^7.3.0", + "animate.css": "^4.1.1", + "croner": "^10.0.1", + "eslint": "^10.6.0", + "express": "^5.2.1", + "feedme": "^2.0.2", + "globals": "^17.7.0", + "helmet": "^8.2.0", + "html-to-text": "^10.0.0", + "iconv-lite": "^0.7.2", + "ics-filter": "^1.0.3", + "ipaddr.js": "^2.4.0", + "moment": "^2.30.1", + "moment-timezone": "^0.6.2", + "node-ical": "^0.26.1", + "nunjucks": "^3.2.4", + "socket.io": "^4.8.3", + "suncalc": "^2.0.0", + "systeminformation": "^5.31.11", + "undici": "^8.5.0", + "weathericons": "^2.1.0" + }, + "devDependencies": { + "@eslint/css": "^1.3.0", + "@eslint/js": "^10.0.1", + "@eslint/markdown": "^8.0.2", + "@stylistic/eslint-plugin": "^5.10.0", + "@vitest/coverage-v8": "^4.1.9", + "@vitest/eslint-plugin": "^1.6.20", + "@vitest/ui": "^4.1.9", + "cspell": "^10.0.1", + "eslint-plugin-import-x": "^4.17.1", + "eslint-plugin-jsdoc": "^63.0.10", + "eslint-plugin-package-json": "^1.5.0", + "eslint-plugin-playwright": "^2.10.4", + "express-basic-auth": "^1.2.1", + "husky": "^9.1.7", + "jsdom": "^29.1.1", + "lint-staged": "^17.0.8", + "msw": "^2.14.6", + "playwright": "^1.61.1", + "prettier": "^3.9.4", + "prettier-plugin-jinja-template": "^2.2.0", + "vitest": "^4.1.9" + }, + "engines": { + "node": ">=22.21.1 <23 || >=24" + }, + "optionalDependencies": { + "electron": "^42.5.2" + } + }, + "node_modules/@altano/repository-tools": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@altano/repository-tools/-/repository-tools-2.0.3.tgz", + "integrity": "sha512-cSR/ZYDF6Wp9OeAJMyLYYN1GenAAhV17W+w38ELP+3c5Ltsy9jkkCymi33nz/qnXyef3n6Fbr1h2yt3dvUN5sQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@cspell/cspell-bundled-dicts": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@cspell/cspell-bundled-dicts/-/cspell-bundled-dicts-10.0.1.tgz", + "integrity": "sha512-WvkSDNX4Uyyj/ZgbPO6L38iFNMfK1EqsH1FteRiI2qLz6QZMXRFrIt12OqiWIplzZDDaVpBH9FCJOPJll0fjCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/dict-ada": "^4.1.1", + "@cspell/dict-al": "^1.1.1", + "@cspell/dict-aws": "^4.0.17", + "@cspell/dict-bash": "^4.2.2", + "@cspell/dict-companies": "^3.2.11", + "@cspell/dict-cpp": "^7.0.2", + "@cspell/dict-cryptocurrencies": "^5.0.5", + "@cspell/dict-csharp": "^4.0.8", + "@cspell/dict-css": "^4.1.1", + "@cspell/dict-dart": "^2.3.2", + "@cspell/dict-data-science": "^2.0.13", + "@cspell/dict-django": "^4.1.6", + "@cspell/dict-docker": "^1.1.17", + "@cspell/dict-dotnet": "^5.0.13", + "@cspell/dict-elixir": "^4.0.8", + "@cspell/dict-en_us": "^4.4.33", + "@cspell/dict-en-common-misspellings": "^2.1.12", + "@cspell/dict-en-gb-mit": "^3.1.22", + "@cspell/dict-filetypes": "^3.0.18", + "@cspell/dict-flutter": "^1.1.1", + "@cspell/dict-fonts": "^4.0.6", + "@cspell/dict-fsharp": "^1.1.1", + "@cspell/dict-fullstack": "^3.2.9", + "@cspell/dict-gaming-terms": "^1.1.2", + "@cspell/dict-git": "^3.1.0", + "@cspell/dict-golang": "^6.0.26", + "@cspell/dict-google": "^1.0.9", + "@cspell/dict-haskell": "^4.0.6", + "@cspell/dict-html": "^4.0.15", + "@cspell/dict-html-symbol-entities": "^4.0.5", + "@cspell/dict-java": "^5.0.12", + "@cspell/dict-julia": "^1.1.1", + "@cspell/dict-k8s": "^1.0.12", + "@cspell/dict-kotlin": "^1.1.1", + "@cspell/dict-latex": "^5.1.0", + "@cspell/dict-lorem-ipsum": "^4.0.5", + "@cspell/dict-lua": "^4.0.8", + "@cspell/dict-makefile": "^1.0.5", + "@cspell/dict-markdown": "^2.0.16", + "@cspell/dict-monkeyc": "^1.0.12", + "@cspell/dict-node": "^5.0.9", + "@cspell/dict-npm": "^5.2.38", + "@cspell/dict-php": "^4.1.1", + "@cspell/dict-powershell": "^5.0.15", + "@cspell/dict-public-licenses": "^2.0.16", + "@cspell/dict-python": "^4.2.26", + "@cspell/dict-r": "^2.1.1", + "@cspell/dict-ruby": "^5.1.1", + "@cspell/dict-rust": "^4.1.2", + "@cspell/dict-scala": "^5.0.9", + "@cspell/dict-shell": "^1.1.2", + "@cspell/dict-software-terms": "^5.2.2", + "@cspell/dict-sql": "^2.2.1", + "@cspell/dict-svelte": "^1.0.7", + "@cspell/dict-swift": "^2.0.6", + "@cspell/dict-terraform": "^1.1.3", + "@cspell/dict-typescript": "^3.2.3", + "@cspell/dict-vue": "^3.0.5", + "@cspell/dict-zig": "^1.0.0" + }, + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/@cspell/cspell-json-reporter": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@cspell/cspell-json-reporter/-/cspell-json-reporter-10.0.1.tgz", + "integrity": "sha512-/nes1RGILec3WCBcoMOd0byNTBtnJuPaVz/+ZzqYkLtY7x58VMcBG5kyP6hPyN8cIwjRADE/SR43gwdXuqk/FA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-types": "10.0.1" + }, + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/@cspell/cspell-performance-monitor": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@cspell/cspell-performance-monitor/-/cspell-performance-monitor-10.0.1.tgz", + "integrity": "sha512-9tVcHXwRnbazUv4WSG0h3MqV4+LgmLNgSALAQUflPPW0EMxTf7C4Dmv9cgxJyCEQrdnVKCr58nPPaahhz9LJUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/@cspell/cspell-pipe": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@cspell/cspell-pipe/-/cspell-pipe-10.0.1.tgz", + "integrity": "sha512-HPeXMD9AZ3V/qPkvQaPcak+C7cJ2z7JTHN8smd6J8L2aThLRky2cHc2OyeaHPSHB7WA47b4z2n5u5nawZhv5VQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/@cspell/cspell-resolver": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@cspell/cspell-resolver/-/cspell-resolver-10.0.1.tgz", + "integrity": "sha512-PIzkZHD1fGUQx1XteK2d1iQ0Mzq/maYcoB4jkvAiiR6WqP3MWYNKFdI9z+R5pOq5KgMfW+5Ig1q0oSR6h8irlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-directory": "^5.0.0" + }, + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/@cspell/cspell-service-bus": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@cspell/cspell-service-bus/-/cspell-service-bus-10.0.1.tgz", + "integrity": "sha512-y6NcIGP2IdXaBL4PVH8vxsr7K27wzz3Ech87UtUtrDSXAiVEOvXgAIknEOUVp59rTlUE8Rn4IRURC6f/hgMyfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/@cspell/cspell-types": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@cspell/cspell-types/-/cspell-types-10.0.1.tgz", + "integrity": "sha512-kLgLShnWADDVreKC63pBrWkcvxgZzFIfO34Jhx/SWfuOIA3cD8AXT+HjyuLfoGJ7mUb58hv2kUziKzEy4INb1w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/@cspell/cspell-worker": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@cspell/cspell-worker/-/cspell-worker-10.0.1.tgz", + "integrity": "sha512-L2bJerfuYOls2wEknm8FmynLtj/G7O4UqX9I/HznRggEW6i2yZIxagDetpVDNowpyavNHJ3SJtUFiyMiZc16Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cspell-lib": "10.0.1" + }, + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/@cspell/dict-ada": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-ada/-/dict-ada-4.1.1.tgz", + "integrity": "sha512-E+0YW9RhZod/9Qy2gxfNZiHJjCYFlCdI69br1eviQQWB8yOTJX0JHXLs79kOYhSW0kINPVUdvddEBe6Lu6CjGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-al": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-al/-/dict-al-1.1.1.tgz", + "integrity": "sha512-sD8GCaZetgQL4+MaJLXqbzWcRjfKVp8x+px3HuCaaiATAAtvjwUQ5/Iubiqwfd1boIh2Y1/3EgM3TLQ7Q8e0wQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-aws": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@cspell/dict-aws/-/dict-aws-4.0.17.tgz", + "integrity": "sha512-ORcblTWcdlGjIbWrgKF+8CNEBQiLVKdUOFoTn0KPNkAYnFcdPP0muT4892h7H4Xafh3j72wqB4/loQ6Nti9E/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-bash": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@cspell/dict-bash/-/dict-bash-4.2.3.tgz", + "integrity": "sha512-ljUZoKHbDqw5Sx0qpL2qTUlmkmr+vhZH/sCNrNaBZKTbdgiswErSnIF1jRbGmEitJNxHRHWsuZyVgnTGfVO1Yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/dict-shell": "1.2.0" + } + }, + "node_modules/@cspell/dict-companies": { + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/@cspell/dict-companies/-/dict-companies-3.2.11.tgz", + "integrity": "sha512-0cmafbcz2pTHXLd59eLR1gvDvN6aWAOM0+cIL4LLF9GX9yB2iKDNrKsvs4tJRqutoaTdwNFBbV0FYv+6iCtebQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-cpp": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-cpp/-/dict-cpp-7.0.2.tgz", + "integrity": "sha512-dfbeERiVNeqmo/npivdR6rDiBCqZi3QtjH2Z0HFcXwpdj6i97dX1xaKyK2GUsO/p4u1TOv63Dmj5Vm48haDpuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-cryptocurrencies": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-cryptocurrencies/-/dict-cryptocurrencies-5.0.5.tgz", + "integrity": "sha512-R68hYYF/rtlE6T/dsObStzN5QZw+0aQBinAXuWCVqwdS7YZo0X33vGMfChkHaiCo3Z2+bkegqHlqxZF4TD3rUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-csharp": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-csharp/-/dict-csharp-4.0.8.tgz", + "integrity": "sha512-qmk45pKFHSxckl5mSlbHxmDitSsGMlk/XzFgt7emeTJWLNSTUK//MbYAkBNRtfzB4uD7pAFiKgpKgtJrTMRnrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-css": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.1.2.tgz", + "integrity": "sha512-+ylGoKdwZ2sVOCOnU2Eq5wDZx+RaVX3HoKyNHGGsFvhSw6IidQ6tH/mAPKBDofViHJoWCPNlklE0lTr6MDG3QA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-dart": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-dart/-/dict-dart-2.3.2.tgz", + "integrity": "sha512-sUiLW56t9gfZcu8iR/5EUg+KYyRD83Cjl3yjDEA2ApVuJvK1HhX+vn4e4k4YfjpUQMag8XO2AaRhARE09+/rqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-data-science": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/@cspell/dict-data-science/-/dict-data-science-2.0.14.tgz", + "integrity": "sha512-jl6Ds4u5u5JT+yY30pWQpAbdCHfy3lCcNkLbpL/AZKoUaLEoXbaYsps9xQtvD7DyaiXxiLZkdH2yHHXtoFtZyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-django": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-django/-/dict-django-4.1.6.tgz", + "integrity": "sha512-SdbSFDGy9ulETqNz15oWv2+kpWLlk8DJYd573xhIkeRdcXOjskRuxjSZPKfW7O3NxN/KEf3gm3IevVOiNuFS+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-docker": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@cspell/dict-docker/-/dict-docker-1.1.17.tgz", + "integrity": "sha512-OcnVTIpHIYYKhztNTyK8ShAnXTfnqs43hVH6p0py0wlcwRIXe5uj4f12n7zPf2CeBI7JAlPjEsV0Rlf4hbz/xQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-dotnet": { + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/@cspell/dict-dotnet/-/dict-dotnet-5.0.13.tgz", + "integrity": "sha512-xPp7jMnFpOri7tzmqmm/dXMolXz1t2bhNqxYkOyMqXhvs08oc7BFs+EsbDY0X7hqiISgeFZGNqn0dOCr+ncPYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-elixir": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-elixir/-/dict-elixir-4.0.8.tgz", + "integrity": "sha512-CyfphrbMyl4Ms55Vzuj+mNmd693HjBFr9hvU+B2YbFEZprE5AG+EXLYTMRWrXbpds4AuZcvN3deM2XVB80BN/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-en_us": { + "version": "4.4.35", + "resolved": "https://registry.npmjs.org/@cspell/dict-en_us/-/dict-en_us-4.4.35.tgz", + "integrity": "sha512-xWpxBCc/FzzMMo/A+0qwARVaIIhR0Ql8yhhv4rvsvg+GfQF+LG9yzg2GwTM5N2rjvzmM3nKuR9zxFZq2I6fJSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-en-common-misspellings": { + "version": "2.1.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-en-common-misspellings/-/dict-en-common-misspellings-2.1.12.tgz", + "integrity": "sha512-14Eu6QGqyksqOd4fYPuRb58lK1Va7FQK9XxFsRKnZU8LhL3N+kj7YKDW+7aIaAN/0WGEqslGP6lGbQzNti8Akw==", + "dev": true, + "license": "CC BY-SA 4.0" + }, + "node_modules/@cspell/dict-en-gb-mit": { + "version": "3.1.24", + "resolved": "https://registry.npmjs.org/@cspell/dict-en-gb-mit/-/dict-en-gb-mit-3.1.24.tgz", + "integrity": "sha512-Oowb/Uzkh7OmDRdCcETzMc9imEb4IpLlHJXoYjX8A8DS2X/54gqSjI915JFB8hKtFjBko5OM0BLQ+6cZhFEMmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-filetypes": { + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/@cspell/dict-filetypes/-/dict-filetypes-3.0.18.tgz", + "integrity": "sha512-yU7RKD/x1IWmDLzWeiItMwgV+6bUcU/af23uS0+uGiFUbsY1qWV/D4rxlAAO6Z7no3J2z8aZOkYIOvUrJq0Rcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-flutter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-flutter/-/dict-flutter-1.1.1.tgz", + "integrity": "sha512-UlOzRcH2tNbFhZmHJN48Za/2/MEdRHl2BMkCWZBYs+30b91mWvBfzaN4IJQU7dUZtowKayVIF9FzvLZtZokc5A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-fonts": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-fonts/-/dict-fonts-4.0.6.tgz", + "integrity": "sha512-aR/0csY01dNb0A1tw/UmN9rKgHruUxsYsvXu6YlSBJFu60s26SKr/k1o4LavpHTQ+lznlYMqAvuxGkE4Flliqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-fsharp": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-fsharp/-/dict-fsharp-1.1.1.tgz", + "integrity": "sha512-imhs0u87wEA4/cYjgzS0tAyaJpwG7vwtC8UyMFbwpmtw+/bgss+osNfyqhYRyS/ehVCWL17Ewx2UPkexjKyaBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-fullstack": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-fullstack/-/dict-fullstack-3.2.9.tgz", + "integrity": "sha512-diZX+usW5aZ4/b2T0QM/H/Wl9aNMbdODa1Jq0ReBr/jazmNeWjd+PyqeVgzd1joEaHY+SAnjrf/i9CwKd2ZtWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-gaming-terms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-gaming-terms/-/dict-gaming-terms-1.1.2.tgz", + "integrity": "sha512-9XnOvaoTBscq0xuD6KTEIkk9hhdfBkkvJAIsvw3JMcnp1214OCGW8+kako5RqQ2vTZR3Tnf3pc57o7VgkM0q1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-git": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@cspell/dict-git/-/dict-git-3.1.0.tgz", + "integrity": "sha512-KEt9zGkxqGy2q1nwH4CbyqTSv5nadpn8BAlDnzlRcnL0Xb3LX9xTgSGShKvzb0bw35lHoYyLWN2ZKAqbC4pgGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-golang": { + "version": "6.0.26", + "resolved": "https://registry.npmjs.org/@cspell/dict-golang/-/dict-golang-6.0.26.tgz", + "integrity": "sha512-YKA7Xm5KeOd14v5SQ4ll6afe9VSy3a2DWM7L9uBq4u3lXToRBQ1W5PRa+/Q9udd+DTURyVVnQ+7b9cnOlNxaRg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-google": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-google/-/dict-google-1.0.9.tgz", + "integrity": "sha512-biL65POqialY0i4g6crj7pR6JnBkbsPovB2WDYkj3H4TuC/QXv7Pu5pdPxeUJA6TSCHI7T5twsO4VSVyRxD9CA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-haskell": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-haskell/-/dict-haskell-4.0.6.tgz", + "integrity": "sha512-ib8SA5qgftExpYNjWhpYIgvDsZ/0wvKKxSP+kuSkkak520iPvTJumEpIE+qPcmJQo4NzdKMN8nEfaeci4OcFAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-html": { + "version": "4.0.15", + "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.15.tgz", + "integrity": "sha512-GJYnYKoD9fmo2OI0aySEGZOjThnx3upSUvV7mmqUu8oG+mGgzqm82P/f7OqsuvTaInZZwZbo+PwJQd/yHcyFIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-html-symbol-entities": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-html-symbol-entities/-/dict-html-symbol-entities-4.0.5.tgz", + "integrity": "sha512-429alTD4cE0FIwpMucvSN35Ld87HCyuM8mF731KU5Rm4Je2SG6hmVx7nkBsLyrmH3sQukTcr1GaiZsiEg8svPA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-java": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-java/-/dict-java-5.0.12.tgz", + "integrity": "sha512-qPSNhTcl7LGJ5Qp6VN71H8zqvRQK04S08T67knMq9hTA8U7G1sTKzLmBaDOFhq17vNX/+rT+rbRYp+B5Nwza1A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-julia": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-julia/-/dict-julia-1.1.1.tgz", + "integrity": "sha512-WylJR9TQ2cgwd5BWEOfdO3zvDB+L7kYFm0I9u0s9jKHWQ6yKmfKeMjU9oXxTBxIufhCXm92SKwwVNAC7gjv+yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-k8s": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-k8s/-/dict-k8s-1.0.12.tgz", + "integrity": "sha512-2LcllTWgaTfYC7DmkMPOn9GsBWsA4DZdlun4po8s2ysTP7CPEnZc1ZfK6pZ2eI4TsZemlUQQ+NZxMe9/QutQxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-kotlin": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-kotlin/-/dict-kotlin-1.1.1.tgz", + "integrity": "sha512-J3NzzfgmxRvEeOe3qUXnSJQCd38i/dpF9/t3quuWh6gXM+krsAXP75dY1CzDmS8mrJAlBdVBeAW5eAZTD8g86Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-latex": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@cspell/dict-latex/-/dict-latex-5.1.0.tgz", + "integrity": "sha512-qxT4guhysyBt0gzoliXYEBYinkAdEtR2M7goRaUH0a7ltCsoqqAeEV8aXYRIdZGcV77gYSobvu3jJL038tlPAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-lorem-ipsum": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-lorem-ipsum/-/dict-lorem-ipsum-4.0.5.tgz", + "integrity": "sha512-9a4TJYRcPWPBKkQAJ/whCu4uCAEgv/O2xAaZEI0n4y1/l18Yyx8pBKoIX5QuVXjjmKEkK7hi5SxyIsH7pFEK9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-lua": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@cspell/dict-lua/-/dict-lua-4.0.8.tgz", + "integrity": "sha512-N4PkgNDMu9JVsRu7JBS/3E/dvfItRgk9w5ga2dKq+JupP2Y3lojNaAVFhXISh4Y0a6qXDn2clA6nvnavQ/jjLA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-makefile": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-makefile/-/dict-makefile-1.0.5.tgz", + "integrity": "sha512-4vrVt7bGiK8Rx98tfRbYo42Xo2IstJkAF4tLLDMNQLkQ86msDlYSKG1ZCk8Abg+EdNcFAjNhXIiNO+w4KflGAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-markdown": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@cspell/dict-markdown/-/dict-markdown-2.0.17.tgz", + "integrity": "sha512-H8bAxih6U8NOnSPL7R8My+tqjaB4tmnJTjERuz4zYqmf+cH+5xshX3UVgKlwWFcyjsYfv/zEDuRdMctQv1q6HQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@cspell/dict-css": "^4.1.2", + "@cspell/dict-html": "^4.0.15", + "@cspell/dict-html-symbol-entities": "^4.0.5", + "@cspell/dict-typescript": "^3.2.3" + } + }, + "node_modules/@cspell/dict-monkeyc": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cspell/dict-monkeyc/-/dict-monkeyc-1.0.12.tgz", + "integrity": "sha512-MN7Vs11TdP5mbdNFQP5x2Ac8zOBm97ARg6zM5Sb53YQt/eMvXOMvrep7+/+8NJXs0jkp70bBzjqU4APcqBFNAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-node": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-node/-/dict-node-5.0.9.tgz", + "integrity": "sha512-hO+ga+uYZ/WA4OtiMEyKt5rDUlUyu3nXMf8KVEeqq2msYvAPdldKBGH7lGONg6R/rPhv53Rb+0Y1SLdoK1+7wQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-npm": { + "version": "5.2.41", + "resolved": "https://registry.npmjs.org/@cspell/dict-npm/-/dict-npm-5.2.41.tgz", + "integrity": "sha512-To3xsfRmMBYVXtWVEdUgV35M9a/JZ54dSuoY6m6D3uHKKL3I326Wmy4xifZ3PU8MQaWhyEH7zbIcUEtKwTQMcA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-php": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-php/-/dict-php-4.1.1.tgz", + "integrity": "sha512-EXelI+4AftmdIGtA8HL8kr4WlUE11OqCSVlnIgZekmTkEGSZdYnkFdiJ5IANSALtlQ1mghKjz+OFqVs6yowgWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-powershell": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/@cspell/dict-powershell/-/dict-powershell-5.0.15.tgz", + "integrity": "sha512-l4S5PAcvCFcVDMJShrYD0X6Huv9dcsQPlsVsBGbH38wvuN7gS7+GxZFAjTNxDmTY1wrNi1cCatSg6Pu2BW4rgg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-public-licenses": { + "version": "2.0.16", + "resolved": "https://registry.npmjs.org/@cspell/dict-public-licenses/-/dict-public-licenses-2.0.16.tgz", + "integrity": "sha512-EQRrPvEOmwhwWezV+W7LjXbIBjiy6y/shrET6Qcpnk3XANTzfvWflf9PnJ5kId/oKWvihFy0za0AV1JHd03pSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-python": { + "version": "4.2.27", + "resolved": "https://registry.npmjs.org/@cspell/dict-python/-/dict-python-4.2.27.tgz", + "integrity": "sha512-Rj6xQgYS4X6ienjgAZF+njA0GRY4oSPouJWv0vfikCTn6EWlfk0V6Dy1HP3Migj1O+IC2NmespgVq+BZNSp8OA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/dict-data-science": "^2.0.14" + } + }, + "node_modules/@cspell/dict-r": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-r/-/dict-r-2.1.1.tgz", + "integrity": "sha512-71Ka+yKfG4ZHEMEmDxc6+blFkeTTvgKbKAbwiwQAuKl3zpqs1Y0vUtwW2N4b3LgmSPhV3ODVY0y4m5ofqDuKMw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-ruby": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-ruby/-/dict-ruby-5.1.1.tgz", + "integrity": "sha512-LHrp84oEV6q1ZxPPyj4z+FdKyq1XAKYPtmGptrd+uwHbrF/Ns5+fy6gtSi7pS+uc0zk3JdO9w/tPK+8N1/7WUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-rust": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-rust/-/dict-rust-4.1.2.tgz", + "integrity": "sha512-O1FHrumYcO+HZti3dHfBPUdnDFkI+nbYK3pxYmiM1sr+G0ebOd6qchmswS0Wsc6ZdEVNiPYJY/gZQR6jfW3uOg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-scala": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/@cspell/dict-scala/-/dict-scala-5.0.9.tgz", + "integrity": "sha512-AjVcVAELgllybr1zk93CJ5wSUNu/Zb5kIubymR/GAYkMyBdYFCZ3Zbwn4Zz8GJlFFAbazABGOu0JPVbeY59vGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-shell": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@cspell/dict-shell/-/dict-shell-1.2.0.tgz", + "integrity": "sha512-PVctvT22lJ49niMiakO8xieY7ELCAzjSqhejWR7bAMb5AZ9F4WDEs+XdGMnoVHWeXq7K5rcepLPmEJb+37zzIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-software-terms": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@cspell/dict-software-terms/-/dict-software-terms-5.2.2.tgz", + "integrity": "sha512-0CaYd6TAsKtEoA7tNswm1iptEblTzEe3UG8beG2cpSTHk7afWIVMtJLgXDv0f/Li67Lf3Z1Jf3JeXR7GsJ2TRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-sql": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@cspell/dict-sql/-/dict-sql-2.2.1.tgz", + "integrity": "sha512-qDHF8MpAYCf4pWU8NKbnVGzkoxMNrFqBHyG/dgrlic5EQiKANCLELYtGlX5auIMDLmTf1inA0eNtv74tyRJ/vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-svelte": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@cspell/dict-svelte/-/dict-svelte-1.0.7.tgz", + "integrity": "sha512-hGZsGqP0WdzKkdpeVLBivRuSNzOTvN036EBmpOwxH+FTY2DuUH7ecW+cSaMwOgmq5JFSdTcbTNFlNC8HN8lhaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-swift": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@cspell/dict-swift/-/dict-swift-2.0.6.tgz", + "integrity": "sha512-PnpNbrIbex2aqU1kMgwEKvCzgbkHtj3dlFLPMqW1vSniop7YxaDTtvTUO4zA++ugYAEL+UK8vYrBwDPTjjvSnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-terraform": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@cspell/dict-terraform/-/dict-terraform-1.1.3.tgz", + "integrity": "sha512-gr6wxCydwSFyyBKhBA2xkENXtVFToheqYYGFvlMZXWjviynXmh+NK/JTvTCk/VHk3+lzbO9EEQKee6VjrAUSbA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-typescript": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@cspell/dict-typescript/-/dict-typescript-3.2.3.tgz", + "integrity": "sha512-zXh1wYsNljQZfWWdSPYwQhpwiuW0KPW1dSd8idjMRvSD0aSvWWHoWlrMsmZeRl4qM4QCEAjua8+cjflm41cQBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-vue": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@cspell/dict-vue/-/dict-vue-3.0.5.tgz", + "integrity": "sha512-Mqutb8jbM+kIcywuPQCCaK5qQHTdaByoEO2J9LKFy3sqAdiBogNkrplqUK0HyyRFgCfbJUgjz3N85iCMcWH0JA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dict-zig": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@cspell/dict-zig/-/dict-zig-1.0.0.tgz", + "integrity": "sha512-XibBIxBlVosU06+M6uHWkFeT0/pW5WajDRYdXG2CgHnq85b0TI/Ks0FuBJykmsgi2CAD3Qtx8UHFEtl/DSFnAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@cspell/dynamic-import": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@cspell/dynamic-import/-/dynamic-import-10.0.1.tgz", + "integrity": "sha512-mP1gdq00aIcH8HxNMqnH11X6BKxLcneDtFgl/ecjIKnaGKwi44m8AndP5Kr4ODaYdl8UUw9O3dJh7KaQXnLHZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/url": "10.0.1", + "import-meta-resolve": "^4.2.0" + }, + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/@cspell/filetypes": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@cspell/filetypes/-/filetypes-10.0.1.tgz", + "integrity": "sha512-Z5S35giU5IW49fBBq6BksUbE8PC4IYPfaKuwl5Nl9jkf/OkAKiBmCowKX45NzRUQInwK/GSqqIUifrNeI6LdLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/@cspell/rpc": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@cspell/rpc/-/rpc-10.0.1.tgz", + "integrity": "sha512-axSRKv3zEAmBm66iD/FV/MPmE4/Yf7c3PZiwTW894Yd3iEhtn3KPKeTrqQ2/tDrhB1Z2qTsap/Hue0MK4o5WXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/@cspell/strong-weak-map": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@cspell/strong-weak-map/-/strong-weak-map-10.0.1.tgz", + "integrity": "sha512-lenN1DVyPi8nJLSMSJJ670ddTjyiruLueuSZO1qLcxBqUhgxDt/mALu9N/1m6WdOVcg6m/5cLiZVg2KOo2UzRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/@cspell/url": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@cspell/url/-/url-10.0.1.tgz", + "integrity": "sha512-abYYgI29wJhWIfWTYrYuzRYDcHQUQ1N5ylnhxYn1NJnIQMqUWGLbDmt12JABtZ+R6h6UNatQrS7rhP86etvJyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.4.tgz", + "integrity": "sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/get": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz", + "integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@electron/get/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@electron/get/node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@es-joy/jsdoccomment": { + "version": "0.87.0", + "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.87.0.tgz", + "integrity": "sha512-mFXZloZMzuJZXSHUmAFu/pXTk0ZJTJBluuAkrvbzidpTN8W6F2bpRFuedSH+85kbdlRLJqc+gfN+kD3JOLJK5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.9", + "@typescript-eslint/types": "^8.59.4", + "comment-parser": "1.4.7", + "esquery": "^1.7.0", + "jsdoc-type-pratt-parser": "~7.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@es-joy/resolve.exports": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@es-joy/resolve.exports/-/resolve.exports-1.2.0.tgz", + "integrity": "sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/css": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@eslint/css/-/css-1.3.0.tgz", + "integrity": "sha512-MwY657chvFQWtXmO86syZgD+JpWlzDq7VkKZyi65PwHDbhELQPMzPXh5s8rhrjptG6FCuls0puCmlXk66+14uA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "@eslint/css-tree": "^4.0.4", + "@eslint/plugin-kit": "^0.7.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/css-tree": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@eslint/css-tree/-/css-tree-4.0.4.tgz", + "integrity": "sha512-nxMparyhqVWQvadx9x8dIfubfIPOE+X2b2waua8fzdnM9vdp9rgVtwEZlG0TmCwEUz/d/f40fzvO/eqBwdxz0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.28.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/markdown": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@eslint/markdown/-/markdown-8.0.2.tgz", + "integrity": "sha512-W+/0qHp0WbvFEljUvvECNpSWrUHpBWIWwp7F3QqEwQKmaRCmfEWvk6VfUia9pTQ0th6HyBGBsPfg/kG3/aQxLA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "dependencies": { + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "github-slugger": "^2.0.0", + "mdast-util-from-markdown": "^2.0.2", + "mdast-util-frontmatter": "^2.0.1", + "mdast-util-gfm": "^3.1.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-frontmatter": "^2.0.0", + "micromark-extension-gfm": "^3.0.0", + "micromark-extension-math": "^3.1.0", + "micromark-util-normalize-identifier": "^2.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@fontsource/roboto": { + "version": "5.2.10", + "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.10.tgz", + "integrity": "sha512-8HlA5FtSfz//oFSr2eL7GFXAiE7eIkcGOtx7tjsLKq+as702x9+GU7K95iDeWFapHC4M2hv9RrpXKRTGGBI8Zg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/roboto-condensed": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/roboto-condensed/-/roboto-condensed-5.2.8.tgz", + "integrity": "sha512-M2OxXXGJQeif+LhxHjWiCEX3BHIYeZf64CcUTeh9AQFGp7DXo2UAXZiZUUOalVdOiuUtQSIi2CDHLDWFiyoECQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fortawesome/fontawesome-free": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.3.0.tgz", + "integrity": "sha512-Yw4Qa43P6e4Xwh0TwEUkHQNRJInWaqIBo73VJmns3j2AIlZPAvUcR4yGIxCPqPRCgyJ5KIVIalF/I1GxhZ/Kgw==", + "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)", + "engines": { + "node": ">=6" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@js-temporal/polyfill": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz", + "integrity": "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==", + "license": "ISC", + "dependencies": { + "jsbi": "^4.3.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz", + "integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==", + "license": "MIT", + "dependencies": { + "domelementtype": "~2.3.0", + "domhandler": "~5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + }, + "peerDependencies": { + "selderee": "~0.12.0" + } + }, + "node_modules/@sindresorhus/base62": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/base62/-/base62-1.0.0.tgz", + "integrity": "sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stylistic/eslint-plugin": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", + "integrity": "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/types": "^8.56.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^9.0.0 || ^10.0.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", + "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.62.1", + "@typescript-eslint/types": "^8.62.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", + "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", + "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", + "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", + "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.62.1", + "@typescript-eslint/tsconfig-utils": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", + "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", + "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/eslint-plugin": { + "version": "1.6.20", + "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.20.tgz", + "integrity": "sha512-xRwWHFG0Utp6hXtbGiWk4VdKXCGdExD8kbWrrmFEiG5dk8anOJ+vbWbeOa8EbkocKQRTsx7JAWETccZiBgFp/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "^8.58.0", + "@typescript-eslint/utils": "^8.58.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "*", + "eslint": ">=8.57.0", + "typescript": ">=5.0.0", + "vitest": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.9.tgz", + "integrity": "sha512-U/cRvtqfEPj27FI1n9cyUvi4vXXdcLhjJiI+InYKdk8hP4VrS6RXOjGL7rfFaeBc37iRKANsR6eEzIoC7lmgBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "fflate": "^0.8.2", + "flatted": "^3.4.2", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.9" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/a-sync-waterfall": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", + "integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==", + "license": "MIT" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/animate.css": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/animate.css/-/animate.css-4.1.1.tgz", + "integrity": "sha512-+mRmCTv6SbCmtYJCN4faJMNFVNN5EuCTTprDTAo7YzIGji2KADmakjVA3+8mVDkZ2Bf09vayB35lSQIex2+QaQ==", + "license": "MIT" + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/are-docs-informative": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/are-docs-informative/-/are-docs-informative-0.0.2.tgz", + "integrity": "sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk-template": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-1.1.2.tgz", + "integrity": "sha512-2bxTP2yUH7AJj/VAXfcA+4IcWGdQ87HwBANLt5XxGTeomo8yG0y95N1um9i5StvhT/Bl0/2cARA5v1PpPXUxUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.2.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/clarinet": { + "version": "0.12.6", + "resolved": "https://registry.npmjs.org/clarinet/-/clarinet-0.12.6.tgz", + "integrity": "sha512-0FR+TrvLbYHLjhzs9oeIbd3yfZmd4u2DzYQEjUTm2dNfh4Y/9RIRWPjsm3aBtrVEpjKI7+lWa4ouqEXoml84mQ==", + "license": "BSD-2-Clause", + "engines": { + "chrome": ">=16.0.912", + "firefox": ">=0.8.0", + "node": ">=0.3.6" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/comment-json": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-5.0.0.tgz", + "integrity": "sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/comment-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.7.tgz", + "integrity": "sha512-0h+uSNtQGW3D98eQt3jJ8L06Fves8hncB4V/PKdw/Qb8Hnk19VaKuTr55UNRYiSoVa7WwrFls+rh3ux9agmkeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/croner": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/croner/-/croner-10.0.1.tgz", + "integrity": "sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==", + "funding": [ + { + "type": "other", + "url": "https://paypal.me/hexagonpp" + }, + { + "type": "github", + "url": "https://github.com/sponsors/hexagon" + } + ], + "license": "MIT", + "engines": { + "node": ">=18.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cspell": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/cspell/-/cspell-10.0.1.tgz", + "integrity": "sha512-Gg6w/flT3fKfl3la62hfTnhtNnDQ+9mU7kUhVqw/axl/Ms4oENw0oJMkWFIoj4f6nL/SDPz7KcPXd2XbkKFNmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-json-reporter": "10.0.1", + "@cspell/cspell-performance-monitor": "10.0.1", + "@cspell/cspell-pipe": "10.0.1", + "@cspell/cspell-types": "10.0.1", + "@cspell/cspell-worker": "10.0.1", + "@cspell/dynamic-import": "10.0.1", + "@cspell/url": "10.0.1", + "ansi-regex": "^6.2.2", + "chalk": "^5.6.2", + "chalk-template": "^1.1.2", + "commander": "^14.0.3", + "cspell-config-lib": "10.0.1", + "cspell-dictionary": "10.0.1", + "cspell-gitignore": "10.0.1", + "cspell-glob": "10.0.1", + "cspell-io": "10.0.1", + "cspell-lib": "10.0.1", + "fast-json-stable-stringify": "^2.1.0", + "flatted": "^3.4.2", + "semver": "^7.8.1", + "tinyglobby": "^0.2.16" + }, + "bin": { + "cspell": "bin.mjs", + "cspell-esm": "bin.mjs" + }, + "engines": { + "node": ">=22.18.0" + }, + "funding": { + "url": "https://github.com/streetsidesoftware/cspell?sponsor=1" + } + }, + "node_modules/cspell-config-lib": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/cspell-config-lib/-/cspell-config-lib-10.0.1.tgz", + "integrity": "sha512-hMpo/0j6k7pbiqrLDOLJKD2IGP9XwhjKf2miiM6p84Xeo4nyuFZaxxDCQ68R851HSYFrrdltgpoipMbj1h2Tnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-types": "10.0.1", + "comment-json": "^5.0.0", + "smol-toml": "^1.6.1", + "yaml": "^2.9.0" + }, + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/cspell-dictionary": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/cspell-dictionary/-/cspell-dictionary-10.0.1.tgz", + "integrity": "sha512-3cZ659vgsZWkzGQJR/sNqGDVt/OnvTSieLKI76V++4t1bHJfochb9ZrrwsuMsb1VPGiyqClUP1/O6WrefF/FVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-performance-monitor": "10.0.1", + "@cspell/cspell-pipe": "10.0.1", + "@cspell/cspell-types": "10.0.1", + "cspell-trie-lib": "10.0.1", + "fast-equals": "^6.0.0" + }, + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/cspell-gitignore": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/cspell-gitignore/-/cspell-gitignore-10.0.1.tgz", + "integrity": "sha512-wN23U61Mx6qPJN3CesOmBU9vnbJ0jQm/ylK0iaVui3CcnO7Zzl5qLu5mPHUzGQGm8yso6qjyxqo16Ho7LpZGOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/url": "10.0.1", + "cspell-glob": "10.0.1", + "cspell-io": "10.0.1" + }, + "bin": { + "cspell-gitignore": "bin.mjs" + }, + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/cspell-glob": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/cspell-glob/-/cspell-glob-10.0.1.tgz", + "integrity": "sha512-7bII9J3aSSpZDwhx7w+zfQXbMxHZQ3be0ilUp5bHrsjz6o07v/NqOHMGcwKdPn1sw2dxDz9sv057xE5pqXnSdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/url": "10.0.1", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/cspell-grammar": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/cspell-grammar/-/cspell-grammar-10.0.1.tgz", + "integrity": "sha512-xC9AFYmaI9wsO//a7S5tdDGKGJVD5UEEsTg+Up2fi7lPfXIryisYmV6tePNL1SEg0idYss4ja8LUZ3Mib09BjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-pipe": "10.0.1", + "@cspell/cspell-types": "10.0.1" + }, + "bin": { + "cspell-grammar": "bin.mjs" + }, + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/cspell-io": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/cspell-io/-/cspell-io-10.0.1.tgz", + "integrity": "sha512-8C2ka07faxflnaqEBO3pektS21XViE/SEHT7F5ZD1ou7FyMR5u3xawTBJSczClfsxLt/WYeztBYrpmGAjmjksw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-service-bus": "10.0.1", + "@cspell/url": "10.0.1" + }, + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/cspell-lib": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/cspell-lib/-/cspell-lib-10.0.1.tgz", + "integrity": "sha512-RpsIPiLzc4/YMW8BMRKpyJ81x439qjYWcqgdKeXnMkbKM88J9PexzutfFf/4v97v96KzfNitEzMpbI0uj8OeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspell/cspell-bundled-dicts": "10.0.1", + "@cspell/cspell-performance-monitor": "10.0.1", + "@cspell/cspell-pipe": "10.0.1", + "@cspell/cspell-resolver": "10.0.1", + "@cspell/cspell-types": "10.0.1", + "@cspell/dynamic-import": "10.0.1", + "@cspell/filetypes": "10.0.1", + "@cspell/rpc": "10.0.1", + "@cspell/strong-weak-map": "10.0.1", + "@cspell/url": "10.0.1", + "cspell-config-lib": "10.0.1", + "cspell-dictionary": "10.0.1", + "cspell-glob": "10.0.1", + "cspell-grammar": "10.0.1", + "cspell-io": "10.0.1", + "cspell-trie-lib": "10.0.1", + "env-paths": "^4.0.0", + "gensequence": "^8.0.8", + "import-fresh": "^4.0.0", + "resolve-from": "^5.0.0", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-uri": "^3.1.0", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=22.18.0" + } + }, + "node_modules/cspell-trie-lib": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/cspell-trie-lib/-/cspell-trie-lib-10.0.1.tgz", + "integrity": "sha512-BFvhalSkRQFjKrZ//FKK7fRGrZFpifnxB5AwCkzsIsBZqicsfafcQ1xP21qpb0QqyV/IomjNgviG+tRJs+0rMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.18.0" + }, + "peerDependencies": { + "@cspell/cspell-types": "10.0.1" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-tree/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "license": "MIT" + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-indent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-7.0.2.tgz", + "integrity": "sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-newline": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-4.0.1.tgz", + "integrity": "sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron": { + "version": "42.5.2", + "resolved": "https://registry.npmjs.org/electron/-/electron-42.5.2.tgz", + "integrity": "sha512-nEoyciv2iC6gTvCbkQ3eP5tjAOo28wfm0adZaMYTns92MyODHeD1TlrGt4E35d4tJfDlmv+BHOuz0QIkJ63c6w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/engine.io": { + "version": "6.6.9", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz", + "integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-4.0.0.tgz", + "integrity": "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-safe-filename": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz", + "integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz", + "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==", + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-fix-utils": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/eslint-fix-utils/-/eslint-fix-utils-0.4.2.tgz", + "integrity": "sha512-n7ZTcwwkP5scedlhvWMcqxED+O1NzXcj5Rxn/0kJQMP88k02vRcBfQ1qsk/JHb6Aw8bajFoetFCCBiNIcNCsvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/estree": ">=1", + "eslint": ">=8" + }, + "peerDependenciesMeta": { + "@types/estree": { + "optional": true + } + } + }, + "node_modules/eslint-import-context": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", + "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } + } + }, + "node_modules/eslint-json-compat-utils": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/eslint-json-compat-utils/-/eslint-json-compat-utils-0.2.3.tgz", + "integrity": "sha512-RbBmDFyu7FqnjE8F0ZxPNzx5UaptdeS9Uu50r7A+D7s/+FCX+ybiyViYEgFUaFIFqSWJgZRTpL5d8Kanxxl2lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esquery": "^1.6.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "eslint": "*", + "jsonc-eslint-parser": "^2.4.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@eslint/json": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-import-x": { + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-import-x/-/eslint-plugin-import-x-4.17.1.tgz", + "integrity": "sha512-4cdstYkKCyjumM2Q9NSI03K8D2a9F4Ssz33K2lv2hQa4KmR9jPLwk3uWGtNvclfqBrPGfGuMBwsGMbe6dMRbfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "^8.56.0", + "comment-parser": "^1.4.1", + "debug": "^4.4.1", + "eslint-import-context": "^0.1.9", + "is-glob": "^4.0.3", + "minimatch": "^9.0.3 || ^10.1.2", + "semver": "^7.7.2", + "stable-hash-x": "^0.2.0", + "unrs-resolver": "^1.9.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-import-x" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "eslint-import-resolver-node": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/utils": { + "optional": true + }, + "eslint-import-resolver-node": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-jsdoc": { + "version": "63.0.10", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-63.0.10.tgz", + "integrity": "sha512-A9UIWsCquaKnit7rasXxYf12hhGIdDRjv65/RUE3AxbT6rdKBvr3MjH37g3gP+g4ipQMXuMn9slFKjO+vqNfkg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@es-joy/jsdoccomment": "~0.87.0", + "@es-joy/resolve.exports": "1.2.0", + "are-docs-informative": "^0.0.2", + "comment-parser": "1.4.7", + "debug": "^4.4.3", + "escape-string-regexp": "^4.0.0", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "html-entities": "^2.6.0", + "object-deep-merge": "^2.0.1", + "parse-imports-exports": "^0.2.4", + "semver": "^7.8.2", + "spdx-expression-parse": "^4.0.0", + "to-valid-identifier": "^1.0.0" + }, + "engines": { + "node": "^22.13.0 || >=24" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-jsdoc/node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-package-json": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-package-json/-/eslint-plugin-package-json-1.5.0.tgz", + "integrity": "sha512-KQkAdn+7I4vn1W4Va+V8M94RhcgZBvgrxMglDqgEhJgQDRUJ9RdtrEiHoe/84oVTIlp0jiPttLYwkExdvHYhZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@altano/repository-tools": "^2.0.1", + "change-case": "^5.4.4", + "detect-indent": "^7.0.2", + "detect-newline": "^4.0.1", + "eslint-fix-utils": "~0.4.1", + "eslint-json-compat-utils": "^0.2.3", + "jsonc-eslint-parser": "^3.1.0", + "package-json-validator": "^1.5.0", + "semver": "^7.7.3", + "sort-object-keys": "^2.0.0", + "sort-package-json": "^4.0.0" + }, + "engines": { + "node": "^22.22.2 || >=24.15.0" + }, + "peerDependencies": { + "@eslint/json": ">=1.0.0", + "eslint": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@eslint/json": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-playwright": { + "version": "2.10.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.10.4.tgz", + "integrity": "sha512-l0V/VxyqfFbtqCTxj5AdRn3Q6S/hIW4nKBnKZVleVbZ24N2My6Usj//ytX3dKKqAoSbvKck9YtSytfdZ5qjLuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "globals": "^17.3.0" + }, + "engines": { + "node": ">=16.9.0" + }, + "peerDependencies": { + "eslint": ">=8.40.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-basic-auth": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/express-basic-auth/-/express-basic-auth-1.2.1.tgz", + "integrity": "sha512-L6YQ1wQ/mNjVLAmK3AG1RK6VkokA1BIY6wmiH304Xtt/cLTps40EusZsU1Uop+v9lTDPxdtzbFmdXfFO3KEnwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-auth": "^2.0.1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-6.0.0.tgz", + "integrity": "sha512-PFhhIGgdM79r5Uztdj9Zb6Tt1zKafqVfdMGwVca1z5z6fbX7DmsySSuJd8HiP6I1j505DCS83cLxo5rmSNeVEA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fault": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz", + "integrity": "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/feedme": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/feedme/-/feedme-2.0.2.tgz", + "integrity": "sha512-0RNn0uLaSey8EThxgABR0T1Q47kSRatYnAXy1cfUc8/eypqXiAu38XGthuwwzb7mESCD9465k6Nym8D9AtB8HA==", + "license": "MIT", + "dependencies": { + "clarinet": "^0.12.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "license": "ISC" + }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "dev": true, + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensequence": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/gensequence/-/gensequence-8.0.8.tgz", + "integrity": "sha512-omMVniXEXpdx/vKxGnPRoO2394Otlze28TyxECbFVyoSpZ9H3EO7lemjcB12OpQJzRW4e5tt/dL1rOxry6aMHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/git-hooks-list": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/git-hooks-list/-/git-hooks-list-4.2.1.tgz", + "integrity": "sha512-WNvqJjOxxs/8ZP9+DWdwWJ7cDsd60NHf39XnD82pDVrKO5q7xfPqpkK6hwEAmBa/ZSEE4IOoR75EzbbIuwGlMw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/fisker/git-hooks-list?sponsor=1" + } + }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/global-directory": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-5.0.0.tgz", + "integrity": "sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "6.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globals": { + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC", + "optional": true + }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/headers-polyfill": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" + } + }, + "node_modules/helmet": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", + "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-to-text": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.0.tgz", + "integrity": "sha512-2OH59Gtprdczel+7Rxgpz9hGVJREaf8Lt1H4kZwWHpEn70VQKRuMNGsb2eDbwaTzrYzb0hheiOG1P7Dim0B4dQ==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "~0.12.0", + "deepmerge-ts": "^7.1.5", + "dom-serializer": "^2.0.0", + "htmlparser2": "^10.1.0", + "selderee": "~0.12.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ics-filter": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/ics-filter/-/ics-filter-1.0.3.tgz", + "integrity": "sha512-8v81GGkml7QZ5JZwH2DNbebyBdd96wHa4HaM8MnfzE7zY9PwP1veDnem8R/Qa6wHWMfbXv2V93RrXZt8L5v/WA==", + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-4.0.0.tgz", + "integrity": "sha512-Fpi660c7VPDM3fPKYovStd9IP1CPOikf6v/dGxJJMmHPcwYQIMJ4W7kO1avBYEpMqkCh+Dx3Ln6H7VYqgztLjw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.15" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-safe-filename": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-safe-filename/-/is-safe-filename-0.1.1.tgz", + "integrity": "sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsbi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.2.tgz", + "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==", + "license": "Apache-2.0" + }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.2.0.tgz", + "integrity": "sha512-dh140MMgjyg3JhJZY/+iEzW+NO5xR2gpbDFKHqotCmexElVntw7GjWjt511+C/Ef02RU5TKYrJo/Xlzk+OLaTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "license": "MIT" + }, + "node_modules/jsonc-eslint-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsonc-eslint-parser/-/jsonc-eslint-parser-3.1.0.tgz", + "integrity": "sha512-75EA7EWZExL/j+MDKQrRbdzcRI2HOkRlmUw8fZJc1ioqFEOvBsq7Rt+A6yCxOt9w/TYNpkt52gC6nm/g5tFIng==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.5.0", + "eslint-visitor-keys": "^5.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + } + }, + "node_modules/jsonc-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "dev": true, + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/leac": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz", + "integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lint-staged": { + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.8.tgz", + "integrity": "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "listr2": "^10.2.1", + "picomatch": "^4.0.4", + "string-argv": "^0.3.2", + "tinyexec": "^1.2.4" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=22.22.1" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + }, + "optionalDependencies": { + "yaml": "^2.9.0" + } + }, + "node_modules/listr2": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.2.tgz", + "integrity": "sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.2.0", + "eventemitter3": "^5.0.4", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^10.0.0" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-frontmatter": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-frontmatter/-/mdast-util-frontmatter-2.0.1.tgz", + "integrity": "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "escape-string-regexp": "^5.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-extension-frontmatter": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-frontmatter/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.28.1.tgz", + "integrity": "sha512-U9w+PzSZ00Z5m9rZ5ARVFL5xOfuCHdKYi/1RRwDCJsboFgJDNT3zT6PIPD7mZQYaQLhsZM3GfDRgSMRHhSmVng==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-frontmatter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-frontmatter/-/micromark-extension-frontmatter-2.0.0.tgz", + "integrity": "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fault": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.6.2.tgz", + "integrity": "sha512-lDsQv8FoGdBUdf0+TjGsq2orxKuXdwFlQ6Zw6TX3xIcTwTfEpCLyKqvEauvCHJ8iu3KBV8+uPhlv70YsNGdUBQ==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msw": { + "version": "2.14.6", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.14.6.tgz", + "integrity": "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.11", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-ical": { + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/node-ical/-/node-ical-0.26.1.tgz", + "integrity": "sha512-KoYLpsz7Ga9lPDpt9vy0iKcgcb/9Ix7ICRZd0csLXMl2lZOSONGj7HrcktJFR7Jid1l44Zu1H4k/1nB04rWPgQ==", + "license": "Apache-2.0", + "dependencies": { + "rrule-temporal": "^1.5.3", + "temporal-polyfill": "^0.3.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/npm-package-arg": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.2.tgz", + "integrity": "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA==", + "dev": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/nunjucks": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz", + "integrity": "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==", + "license": "BSD-2-Clause", + "dependencies": { + "a-sync-waterfall": "^1.0.0", + "asap": "^2.0.3", + "commander": "^5.1.0" + }, + "bin": { + "nunjucks-precompile": "bin/precompile" + }, + "engines": { + "node": ">= 6.9.0" + }, + "peerDependencies": { + "chokidar": "^3.3.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/nunjucks/node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-deep-merge": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/object-deep-merge/-/object-deep-merge-2.0.1.tgz", + "integrity": "sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-validator": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/package-json-validator/-/package-json-validator-1.5.2.tgz", + "integrity": "sha512-eHXskJQU4aCiSfjhRfTVtCJ+22/EzLHgYgZv5Gj3teb3NJrnTMzq5BnKAWKvR+PLpknCO1PmOCImDuO+dX4Vaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "npm-package-arg": "^13.0.2", + "semver": "^7.7.2", + "validate-npm-package-license": "^3.0.4", + "validate-npm-package-name": "^7.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/parse-imports-exports": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz", + "integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-statements": "1.0.11" + } + }, + "node_modules/parse-statements": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz", + "integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parseley": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz", + "integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==", + "license": "MIT", + "dependencies": { + "leac": "^0.7.0", + "peberminta": "^0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/peberminta": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz", + "integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", + "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-jinja-template": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-jinja-template/-/prettier-plugin-jinja-template-2.2.0.tgz", + "integrity": "sha512-FVZtWvSW8RKvHToWHgI9j1eau7t2mekYoVKg7GdDS16QVOYM8f08yzVptBUvSXKQR2xJAXCLi0rGGvJGFtCUrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": "^3.0.0" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reserved-identifiers": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz", + "integrity": "sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rettime": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.11.tgz", + "integrity": "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/rrule-temporal": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/rrule-temporal/-/rrule-temporal-1.5.3.tgz", + "integrity": "sha512-qALnXyu4MKNUeykkkO0r6Xxl5or3rM8Cf6ibKIe/29sgmq3tGm1oNq4G1Ddp8Ku3mnKmvC3+3yFAJ3OgOu6OJw==", + "license": "MIT", + "dependencies": { + "@js-temporal/polyfill": "^0.5.1" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/selderee": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz", + "integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==", + "license": "MIT", + "dependencies": { + "parseley": "~0.13.1" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.1.tgz", + "integrity": "sha512-vM9SUhjsUYs6UeJUmygc5Ofm5eQGe85riob5ju6XCgFGJI5PLV4nrDAQpQjd+LkFBpAkADn5BQQpZ9EUNkyLuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/smol-toml": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz", + "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.21.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/sort-object-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/sort-object-keys/-/sort-object-keys-2.1.0.tgz", + "integrity": "sha512-SOiEnthkJKPv2L6ec6HMwhUcN0/lppkeYuN1x63PbyPRrgSPIuBJCiYxYyvWRTtjMlOi14vQUCGUJqS6PLVm8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/sort-package-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/sort-package-json/-/sort-package-json-4.0.0.tgz", + "integrity": "sha512-6aYOlYI9AWioZ+rzu+4zKLmoFqJP0/fHDxrd7X04yqEibikY+5YVF0EYlyGn4v6X2PJY7yAUWV7oeP+i5rOm/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-indent": "^7.0.2", + "detect-newline": "^4.0.1", + "git-hooks-list": "^4.1.1", + "is-plain-obj": "^4.1.0", + "semver": "^7.7.3", + "sort-object-keys": "^2.0.1", + "tinyglobby": "^0.2.15" + }, + "bin": { + "sort-package-json": "cli.js" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-correct/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/stable-hash-x": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", + "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/suncalc": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/suncalc/-/suncalc-2.0.0.tgz", + "integrity": "sha512-RcO28GOMXNnB+sr+c7/+zv5UPMa9wxIsUVGE3HddjQykqCVEC2rc4t4IjBB7qaa7K7dx5Cxp8UZwexyXrX8JkA==" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/systeminformation": { + "version": "5.31.11", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.11.tgz", + "integrity": "sha512-I6O7iaUj23AXRgCPDDnvi3xHvdOLp4+1YMbF+X194lJwY1NeWojgHJPhslVKcmTtrLTguRk3QJK+xEdTiI3P0w==", + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/temporal-polyfill": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-0.3.2.tgz", + "integrity": "sha512-TzHthD/heRK947GNiSu3Y5gSPpeUDH34+LESnfsq8bqpFhsB79HFBX8+Z834IVX68P3EUyRPZK5bL/1fh437Eg==", + "license": "MIT", + "dependencies": { + "temporal-spec": "0.3.1" + } + }, + "node_modules/temporal-spec": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-0.3.1.tgz", + "integrity": "sha512-B4TUhezh9knfSIMwt7RVggApDRJZo73uZdj8AacL2mZ8RP5KtLianh2MXxL06GN9ESYiIsiuoLQhgVfwe55Yhw==", + "license": "ISC" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.5.tgz", + "integrity": "sha512-RfEzKWcq5fHUOFq7J3rl3Oz6ylKGtcHqUznzj4EcXsxLSIjJcvpbXAQtWGeJQ0xKnimR5e0Cn+cn9TssfMzm+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.5" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.5.tgz", + "integrity": "sha512-pGrwzZDvPwKe+7NNUqAunb6rqTfynr0VOUhCMdqbu5xlvNiszsAJygRzwvpVycdzejlbpY+SWJOn+s75Og7FEA==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-valid-identifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-valid-identifier/-/to-valid-identifier-1.0.0.tgz", + "integrity": "sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/base62": "^1.0.0", + "reserved-identifiers": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/validate-npm-package-license/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/validate-npm-package-name": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz", + "integrity": "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/weathericons": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/weathericons/-/weathericons-2.1.0.tgz", + "integrity": "sha512-V45viuyuQQOuoePTqzxvP/wBpYALWkD695fkFvqpn+BiMyo64fFlyDbP2A8umZyFyz1cXFPNw1pWqeaSaQqJlQ==", + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", + "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xdg-basedir": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", + "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/package.json b/package.json new file mode 100755 index 0000000..f329534 --- /dev/null +++ b/package.json @@ -0,0 +1,137 @@ +{ + "name": "magicmirror", + "version": "2.37.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": { + "#server_functions": { + "default": "./js/server_functions.js" + }, + "#http_fetcher": { + "default": "./js/http_fetcher.js" + } + }, + "main": "js/electron.js", + "exports": "./js/electron.js", + "files": [ + "clientonly", + "css", + "defaultmodules", + "js", + "serveronly", + "translations", + "favicon.svg", + "index.html" + ], + "sideEffects": true, + "scripts": { + "config:check": "node js/check_config.js", + "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:fix": "eslint --fix && 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:wayland", + "start:dev": "node --run start:wayland -- 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 ./defaultmodules/calendar/debug.js", + "test:coverage": "vitest run --coverage", + "test:e2e": "vitest run tests/e2e", + "test:electron": "vitest run tests/electron", + "test:lint": "eslint && prettier . --check", + "test:spelling": "cspell . --gitignore", + "test:ui": "vitest --ui", + "test:unit": "vitest run tests/unit", + "test:watch": "vitest" + }, + "lint-staged": { + "*": [ + "eslint --fix", + "prettier --write --ignore-unknown" + ] + }, + "dependencies": { + "@fontsource/roboto": "^5.2.10", + "@fontsource/roboto-condensed": "^5.2.8", + "@fortawesome/fontawesome-free": "^7.3.0", + "animate.css": "^4.1.1", + "croner": "^10.0.1", + "eslint": "^10.6.0", + "express": "^5.2.1", + "feedme": "^2.0.2", + "globals": "^17.7.0", + "helmet": "^8.2.0", + "html-to-text": "^10.0.0", + "iconv-lite": "^0.7.2", + "ics-filter": "^1.0.3", + "ipaddr.js": "^2.4.0", + "moment": "^2.30.1", + "moment-timezone": "^0.6.2", + "node-ical": "^0.26.1", + "nunjucks": "^3.2.4", + "socket.io": "^4.8.3", + "suncalc": "^2.0.0", + "systeminformation": "^5.31.11", + "undici": "^8.5.0", + "weathericons": "^2.1.0" + }, + "devDependencies": { + "@eslint/css": "^1.3.0", + "@eslint/js": "^10.0.1", + "@eslint/markdown": "^8.0.2", + "@stylistic/eslint-plugin": "^5.10.0", + "@vitest/coverage-v8": "^4.1.9", + "@vitest/eslint-plugin": "^1.6.20", + "@vitest/ui": "^4.1.9", + "cspell": "^10.0.1", + "eslint-plugin-import-x": "^4.17.1", + "eslint-plugin-jsdoc": "^63.0.10", + "eslint-plugin-package-json": "^1.5.0", + "eslint-plugin-playwright": "^2.10.4", + "express-basic-auth": "^1.2.1", + "husky": "^9.1.7", + "jsdom": "^29.1.1", + "lint-staged": "^17.0.8", + "msw": "^2.14.6", + "playwright": "^1.61.1", + "prettier": "^3.9.4", + "prettier-plugin-jinja-template": "^2.2.0", + "vitest": "^4.1.9" + }, + "optionalDependencies": { + "electron": "^42.5.2" + }, + "engines": { + "node": ">=22.21.1 <23 || >=24" + } +} diff --git a/prettier.config.mjs b/prettier.config.mjs new file mode 100644 index 0000000..717c123 --- /dev/null +++ b/prettier.config.mjs @@ -0,0 +1,14 @@ +const config = { + plugins: ["prettier-plugin-jinja-template"], + overrides: [ + { + files: ["*.njk"], + options: { + parser: "jinja-template" + } + } + ], + trailingComma: "none" +}; + +export default config; diff --git a/serveronly/index.js b/serveronly/index.js new file mode 100644 index 0000000..6156a0a --- /dev/null +++ b/serveronly/index.js @@ -0,0 +1,8 @@ +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} <<<`); +}); diff --git a/serveronly/watcher.js b/serveronly/watcher.js new file mode 100644 index 0000000..ab8aa0a --- /dev/null +++ b/serveronly/watcher.js @@ -0,0 +1,261 @@ +// Load lightweight internal alias resolver to enable require("logger") +require("../js/alias-resolver"); + +const { spawn } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); +const net = require("node:net"); +const http = require("node: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 { + 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 + */ +function restartServer (reason) { + if (restartTimer) clearTimeout(restartTimer); + + restartTimer = setTimeout(() => { + 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 { + // 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); +}); diff --git a/tests/configs/config_functions.js b/tests/configs/config_functions.js new file mode 100644 index 0000000..27e265f --- /dev/null +++ b/tests/configs/config_functions.js @@ -0,0 +1,35 @@ +/*eslint object-shorthand: ["error", "always", { "methodsIgnorePattern": "^roundToInt2$" }]*/ + +let config = require(`${process.cwd()}/tests/configs/default.js`).configFactory({ + modules: [ + { + module: "clock", + position: "middle_center", + config: { + moduleFunctions: { + roundToInt1: (value) => { + try { + return Math.round(parseFloat(value)); + } catch { + return value; + } + }, + roundToInt2: function (value) { + try { + return Math.round(parseFloat(value)); + } catch { + return value; + } + } + }, + stringWithArrow: "a => b is not a function", + stringWithFunction: "this function keyword is just text" + } + } + ] +}); + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/config_variables.env b/tests/configs/config_variables.env new file mode 100644 index 0000000..6f38944 --- /dev/null +++ b/tests/configs/config_variables.env @@ -0,0 +1,7 @@ +MM_LANGUAGE=de +MM_TIME_FORMAT=12 +MM_LOG_INFO=INFO +MM_LOG_ERROR=ERROR +SECRET_IP1="127.0.0.1" +SECRET_IP2="::ffff:127.0.0.1" +SECRET_IP3=1 diff --git a/tests/configs/config_variables.js b/tests/configs/config_variables.js new file mode 100644 index 0000000..2068a22 --- /dev/null +++ b/tests/configs/config_variables.js @@ -0,0 +1,12 @@ +let config = require(`${process.cwd()}/tests/configs/default.js`).configFactory({ + language: "${MM_LANGUAGE}", + logLevel: ["${MM_LOG_ERROR}", "LOG", "WARN", "${MM_LOG_INFO}"], + timeFormat: ${MM_TIME_FORMAT}, + hideConfigSecrets: true, + ipWhitelist: ["${SECRET_IP2}", "::${SECRET_IP3}", "${SECRET_IP1}"] +}); + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/customregions.js b/tests/configs/customregions.js new file mode 100644 index 0000000..5f1c643 --- /dev/null +++ b/tests/configs/customregions.js @@ -0,0 +1,25 @@ +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; +} diff --git a/tests/configs/default.js b/tests/configs/default.js new file mode 100644 index 0000000..71d320c --- /dev/null +++ b/tests/configs/default.js @@ -0,0 +1,19 @@ +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 + ); + }; +} diff --git a/tests/configs/empty_ipWhiteList.js b/tests/configs/empty_ipWhiteList.js new file mode 100644 index 0000000..8fdfc0e --- /dev/null +++ b/tests/configs/empty_ipWhiteList.js @@ -0,0 +1,9 @@ +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; +} diff --git a/tests/configs/modules/alert/welcome_false.js b/tests/configs/modules/alert/welcome_false.js new file mode 100644 index 0000000..1513904 --- /dev/null +++ b/tests/configs/modules/alert/welcome_false.js @@ -0,0 +1,18 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + modules: [ + { + module: "alert", + config: { + display_time: 1000000, + welcome_message: false + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/alert/welcome_string.js b/tests/configs/modules/alert/welcome_string.js new file mode 100644 index 0000000..6b7b690 --- /dev/null +++ b/tests/configs/modules/alert/welcome_string.js @@ -0,0 +1,18 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + modules: [ + { + module: "alert", + config: { + display_time: 1000000, + welcome_message: "Custom welcome message!" + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/alert/welcome_true.js b/tests/configs/modules/alert/welcome_true.js new file mode 100644 index 0000000..9e0238f --- /dev/null +++ b/tests/configs/modules/alert/welcome_true.js @@ -0,0 +1,18 @@ +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; +} diff --git a/tests/configs/modules/calendar/3_move_first_allday_repeating_event.js b/tests/configs/modules/calendar/3_move_first_allday_repeating_event.js new file mode 100644 index 0000000..30afae2 --- /dev/null +++ b/tests/configs/modules/calendar/3_move_first_allday_repeating_event.js @@ -0,0 +1,35 @@ +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; +} diff --git a/tests/configs/modules/calendar/auth-default.js b/tests/configs/modules/calendar/auth-default.js new file mode 100644 index 0000000..eb20683 --- /dev/null +++ b/tests/configs/modules/calendar/auth-default.js @@ -0,0 +1,29 @@ +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; +} diff --git a/tests/configs/modules/calendar/bad_rrule.js b/tests/configs/modules/calendar/bad_rrule.js new file mode 100644 index 0000000..f3e95e4 --- /dev/null +++ b/tests/configs/modules/calendar/bad_rrule.js @@ -0,0 +1,24 @@ +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; +} diff --git a/tests/configs/modules/calendar/basic-auth.js b/tests/configs/modules/calendar/basic-auth.js new file mode 100644 index 0000000..28c25ef --- /dev/null +++ b/tests/configs/modules/calendar/basic-auth.js @@ -0,0 +1,30 @@ +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; +} diff --git a/tests/configs/modules/calendar/berlin_end_of_day_repeating.js b/tests/configs/modules/calendar/berlin_end_of_day_repeating.js new file mode 100644 index 0000000..b706347 --- /dev/null +++ b/tests/configs/modules/calendar/berlin_end_of_day_repeating.js @@ -0,0 +1,33 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + dateFormat: "Do.MMM, HH:mm", + fullDayEventDateFormat: "Do.MMM", + timeFormat: "absolute", + getRelative: 0, + maximumNumberOfDays: 28, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/end_of_day_berlin_moved.ics" + } + ] + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/calendar/berlin_multi.js b/tests/configs/modules/calendar/berlin_multi.js new file mode 100644 index 0000000..5006937 --- /dev/null +++ b/tests/configs/modules/calendar/berlin_multi.js @@ -0,0 +1,33 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + dateFormat: "Do.MMM, HH:mm", + fullDayEventDateFormat: "Do.MMM", + timeFormat: "absolute", + getRelative: 0, + maximumNumberOfDays: 28, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/RepeatingEvent.Oct21.ics" + } + ] + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/calendar/berlin_whole_day_event_moved_over_dst_change.js b/tests/configs/modules/calendar/berlin_whole_day_event_moved_over_dst_change.js new file mode 100644 index 0000000..255bbd9 --- /dev/null +++ b/tests/configs/modules/calendar/berlin_whole_day_event_moved_over_dst_change.js @@ -0,0 +1,33 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + dateFormat: "Do.MMM, HH:mm", + fullDayEventDateFormat: "Do.MMM", + timeFormat: "absolute", + getRelative: 0, + maximumNumberOfDays: 28, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/whole_day_moved_over_dst_change_berlin.ics" + } + ] + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/calendar/calendarShowEndConfigs.js b/tests/configs/modules/calendar/calendarShowEndConfigs.js new file mode 100644 index 0000000..129ad97 --- /dev/null +++ b/tests/configs/modules/calendar/calendarShowEndConfigs.js @@ -0,0 +1,361 @@ +const calendarShowEndConfigs = { + event_with_time_over_multiple_days_non_repeating_display_end: { + 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" + } + ] + } + } + ] + }, + event_with_time_over_multiple_days_non_repeating_display_end_dateheaders: { + 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: "dateheaders", + getRelative: 0, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/event_with_time_over_multiple_days_yearly.ics" + } + ] + } + } + ] + }, + event_with_time_over_multiple_days_non_repeating_display_end_relative: { + 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: "relative", + getRelative: 0, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/event_with_time_over_multiple_days_yearly.ics" + } + ] + } + } + ] + }, + event_with_time_over_multiple_days_non_repeating_no_display_end: { + 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" + } + ] + } + } + ] + }, + event_with_time_same_day_yearly_display_end_absolute: { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + dateFormat: "Do.MMM", + 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_same_day_yearly.ics" + } + ] + } + } + ] + }, + event_with_time_same_day_yearly_display_end_absolute_dateformat_lll: { + address: "0.0.0.0", + ipWhitelist: [], + language: "en", + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + dateFormat: "LLL", + 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_same_day_yearly.ics" + } + ] + } + } + ] + }, + event_with_time_same_day_yearly_display_end_absolute_dateformat_with_time: { + 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_same_day_yearly.ics" + } + ] + } + } + ] + }, + event_with_time_same_day_yearly_display_end_dateheaders: { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + dateFormat: "Do.MMM", + dateEndFormat: "Do.MMM, HH:mm", + fullDayEventDateFormat: "Do.MMM", + timeFormat: "dateheaders", + getRelative: 0, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/event_with_time_same_day_yearly.ics" + } + ] + } + } + ] + }, + event_with_time_same_day_yearly_display_end_relative: { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + dateFormat: "Do.MMM", + dateEndFormat: "Do.MMM, HH:mm", + fullDayEventDateFormat: "Do.MMM", + timeFormat: "relative", + getRelative: 0, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/event_with_time_same_day_yearly.ics" + } + ] + } + } + ] + }, + event_with_time_same_day_yearly_display_end_relative_hide_time: { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + dateFormat: "Do.MMM", + dateEndFormat: "Do.MMM, HH:mm", + fullDayEventDateFormat: "Do.MMM", + timeFormat: "relative", + getRelative: 0, + hideTime: true, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/event_with_time_same_day_yearly.ics" + } + ] + } + } + ] + }, + fullday_multiday_showend_dateheaders: { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + fullDayEventDateFormat: "Do.MMM", + timeFormat: "dateheaders", + getRelative: 0, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/fullday_event_over_multiple_days_nonrepeating.ics" + } + ] + } + } + ] + }, + fullday_multiday_showend_nextdaysrelative: { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + fullDayEventDateFormat: "Do.MMM", + timeFormat: "absolute", + getRelative: 0, + showEnd: true, + nextDaysRelative: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/fullday_event_over_multiple_days_nonrepeating.ics" + } + ] + } + } + ] + }, + fullday_multiday_showend_relative: { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + fullDayEventDateFormat: "Do.MMM", + timeFormat: "relative", + getRelative: 0, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/fullday_event_over_multiple_days_nonrepeating.ics" + } + ] + } + } + ] + } +}; + +const defaultScenario = "event_with_time_over_multiple_days_non_repeating_display_end"; +const selectedScenario = process.env.MM_CALENDAR_SHOWEND_SCENARIO || defaultScenario; +const config = calendarShowEndConfigs[selectedScenario]; + +if (!config) { + throw new Error(`Unknown MM_CALENDAR_SHOWEND_SCENARIO: ${selectedScenario}`); +} + +module.exports = config; diff --git a/tests/configs/modules/calendar/changed-port.js b/tests/configs/modules/calendar/changed-port.js new file mode 100644 index 0000000..5cb5c63 --- /dev/null +++ b/tests/configs/modules/calendar/changed-port.js @@ -0,0 +1,29 @@ +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; +} diff --git a/tests/configs/modules/calendar/chicago-looking-at-ny-recurring.js b/tests/configs/modules/calendar/chicago-looking-at-ny-recurring.js new file mode 100644 index 0000000..1cab237 --- /dev/null +++ b/tests/configs/modules/calendar/chicago-looking-at-ny-recurring.js @@ -0,0 +1,33 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + dateFormat: "Do.MMM, HH:mm", + fullDayEventDateFormat: "Do.MMM", + timeFormat: "absolute", + getRelative: 0, + maximumNumberOfDays: 28, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/chicago-nyedge.ics" + } + ] + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/calendar/chicago_late_in_timezone.js b/tests/configs/modules/calendar/chicago_late_in_timezone.js new file mode 100644 index 0000000..3591b4c --- /dev/null +++ b/tests/configs/modules/calendar/chicago_late_in_timezone.js @@ -0,0 +1,33 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + dateFormat: "Do.MMM, HH:mm", + fullDayEventDateFormat: "Do.MMM", + timeFormat: "absolute", + getRelative: 0, + maximumNumberOfDays: 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; +} diff --git a/tests/configs/modules/calendar/countCalendarEvents.js b/tests/configs/modules/calendar/countCalendarEvents.js new file mode 100644 index 0000000..ea3244b --- /dev/null +++ b/tests/configs/modules/calendar/countCalendarEvents.js @@ -0,0 +1,39 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 12, + foreignModulesDir: "tests/mocks", + modules: [ + { + module: "calendar", + position: "bottom_bar", + + config: { + maximumEntries: 1, + calendars: [ + { + fetchInterval: 10000, //7 * 24 * 60 * 60 * 1000, + symbol: ["calendar-check", "google"], + url: "http://localhost:8080/tests/mocks/12_events.ics" + } + ] + } + }, + { + module: "testNotification", + position: "bottom_bar", + config: { + debug: true, + match: { + matchtype: "count", + notificationID: "CALENDAR_EVENTS" + } + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/calendar/custom.js b/tests/configs/modules/calendar/custom.js new file mode 100644 index 0000000..bc2dc3b --- /dev/null +++ b/tests/configs/modules/calendar/custom.js @@ -0,0 +1,33 @@ +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; +} diff --git a/tests/configs/modules/calendar/default.js b/tests/configs/modules/calendar/default.js new file mode 100644 index 0000000..0032d1c --- /dev/null +++ b/tests/configs/modules/calendar/default.js @@ -0,0 +1,25 @@ +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; +} diff --git a/tests/configs/modules/calendar/diff_tz_start_end.js b/tests/configs/modules/calendar/diff_tz_start_end.js new file mode 100644 index 0000000..829ef3a --- /dev/null +++ b/tests/configs/modules/calendar/diff_tz_start_end.js @@ -0,0 +1,34 @@ +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; +} diff --git a/tests/configs/modules/calendar/end_of_day_berlin_moved.js b/tests/configs/modules/calendar/end_of_day_berlin_moved.js new file mode 100644 index 0000000..b706347 --- /dev/null +++ b/tests/configs/modules/calendar/end_of_day_berlin_moved.js @@ -0,0 +1,33 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + dateFormat: "Do.MMM, HH:mm", + fullDayEventDateFormat: "Do.MMM", + timeFormat: "absolute", + getRelative: 0, + maximumNumberOfDays: 28, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/end_of_day_berlin_moved.ics" + } + ] + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/calendar/exdate_and_recurrence_together.js b/tests/configs/modules/calendar/exdate_and_recurrence_together.js new file mode 100644 index 0000000..b1dc063 --- /dev/null +++ b/tests/configs/modules/calendar/exdate_and_recurrence_together.js @@ -0,0 +1,33 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + + timeFormat: 24, + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + fade: false, + urgency: 0, + dateFormat: "Do.MMM, HH:mm", + fullDayEventDateFormat: "Do.MMM", + timeFormat: "absolute", + getRelative: 0, + maximumNumberOfDays: 28, + showEnd: true, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/exdate_and_recurrence_together.ics" + } + ] + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/calendar/exdate_la_at_midnight_dst.js b/tests/configs/modules/calendar/exdate_la_at_midnight_dst.js new file mode 100644 index 0000000..b584757 --- /dev/null +++ b/tests/configs/modules/calendar/exdate_la_at_midnight_dst.js @@ -0,0 +1,36 @@ +/* + * 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; +} diff --git a/tests/configs/modules/calendar/exdate_la_at_midnight_std.js b/tests/configs/modules/calendar/exdate_la_at_midnight_std.js new file mode 100644 index 0000000..e9cab5d --- /dev/null +++ b/tests/configs/modules/calendar/exdate_la_at_midnight_std.js @@ -0,0 +1,36 @@ +/* + * 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; +} diff --git a/tests/configs/modules/calendar/exdate_la_before_midnight.js b/tests/configs/modules/calendar/exdate_la_before_midnight.js new file mode 100644 index 0000000..bfb7218 --- /dev/null +++ b/tests/configs/modules/calendar/exdate_la_before_midnight.js @@ -0,0 +1,36 @@ +/* + * 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; +} diff --git a/tests/configs/modules/calendar/exdate_syd_at_midnight_dst.js b/tests/configs/modules/calendar/exdate_syd_at_midnight_dst.js new file mode 100644 index 0000000..7ce81cd --- /dev/null +++ b/tests/configs/modules/calendar/exdate_syd_at_midnight_dst.js @@ -0,0 +1,36 @@ +/* + * 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; +} diff --git a/tests/configs/modules/calendar/exdate_syd_at_midnight_std.js b/tests/configs/modules/calendar/exdate_syd_at_midnight_std.js new file mode 100644 index 0000000..06f974f --- /dev/null +++ b/tests/configs/modules/calendar/exdate_syd_at_midnight_std.js @@ -0,0 +1,36 @@ +/* + * 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; +} diff --git a/tests/configs/modules/calendar/exdate_syd_before_midnight.js b/tests/configs/modules/calendar/exdate_syd_before_midnight.js new file mode 100644 index 0000000..f83518e --- /dev/null +++ b/tests/configs/modules/calendar/exdate_syd_before_midnight.js @@ -0,0 +1,36 @@ +/* + * 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; +} diff --git a/tests/configs/modules/calendar/fail-basic-auth.js b/tests/configs/modules/calendar/fail-basic-auth.js new file mode 100644 index 0000000..c6eae35 --- /dev/null +++ b/tests/configs/modules/calendar/fail-basic-auth.js @@ -0,0 +1,30 @@ +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; +} diff --git a/tests/configs/modules/calendar/fullday_event_over_multiple_days_nonrepeating.js b/tests/configs/modules/calendar/fullday_event_over_multiple_days_nonrepeating.js new file mode 100644 index 0000000..7ec5885 --- /dev/null +++ b/tests/configs/modules/calendar/fullday_event_over_multiple_days_nonrepeating.js @@ -0,0 +1,32 @@ +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; +} diff --git a/tests/configs/modules/calendar/fullday_until.js b/tests/configs/modules/calendar/fullday_until.js new file mode 100644 index 0000000..6713d62 --- /dev/null +++ b/tests/configs/modules/calendar/fullday_until.js @@ -0,0 +1,27 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 12, + + modules: [ + { + module: "calendar", + position: "bottom_bar", + config: { + hideDuplicates: false, + maximumEntries: 100, + calendars: [ + { + maximumEntries: 100, + url: "http://localhost:8080/tests/mocks/fullday_until.ics" + } + ] + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/calendar/germany_at_end_of_day_repeating.js b/tests/configs/modules/calendar/germany_at_end_of_day_repeating.js new file mode 100644 index 0000000..e9d7e88 --- /dev/null +++ b/tests/configs/modules/calendar/germany_at_end_of_day_repeating.js @@ -0,0 +1,33 @@ +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; +} diff --git a/tests/configs/modules/calendar/long-fullday-event.js b/tests/configs/modules/calendar/long-fullday-event.js new file mode 100644 index 0000000..4606e05 --- /dev/null +++ b/tests/configs/modules/calendar/long-fullday-event.js @@ -0,0 +1,31 @@ +/* + * 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; +} diff --git a/tests/configs/modules/calendar/old-basic-auth.js b/tests/configs/modules/calendar/old-basic-auth.js new file mode 100644 index 0000000..18aae72 --- /dev/null +++ b/tests/configs/modules/calendar/old-basic-auth.js @@ -0,0 +1,27 @@ +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; +} diff --git a/tests/configs/modules/calendar/recurring.js b/tests/configs/modules/calendar/recurring.js new file mode 100644 index 0000000..f393385 --- /dev/null +++ b/tests/configs/modules/calendar/recurring.js @@ -0,0 +1,26 @@ +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; +} diff --git a/tests/configs/modules/calendar/rrule_until.js b/tests/configs/modules/calendar/rrule_until.js new file mode 100644 index 0000000..ace727f --- /dev/null +++ b/tests/configs/modules/calendar/rrule_until.js @@ -0,0 +1,28 @@ +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; +} diff --git a/tests/configs/modules/calendar/show-duplicates-in-calendar.js b/tests/configs/modules/calendar/show-duplicates-in-calendar.js new file mode 100644 index 0000000..7ad4209 --- /dev/null +++ b/tests/configs/modules/calendar/show-duplicates-in-calendar.js @@ -0,0 +1,33 @@ +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; +} diff --git a/tests/configs/modules/calendar/single-fullday-event.js b/tests/configs/modules/calendar/single-fullday-event.js new file mode 100644 index 0000000..ff0773c --- /dev/null +++ b/tests/configs/modules/calendar/single-fullday-event.js @@ -0,0 +1,31 @@ +/* + * 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; +} diff --git a/tests/configs/modules/calendar/sliceMultiDayEvents.js b/tests/configs/modules/calendar/sliceMultiDayEvents.js new file mode 100644 index 0000000..c46e316 --- /dev/null +++ b/tests/configs/modules/calendar/sliceMultiDayEvents.js @@ -0,0 +1,28 @@ +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; +} diff --git a/tests/configs/modules/calendar/symboltest.js b/tests/configs/modules/calendar/symboltest.js new file mode 100644 index 0000000..060bc82 --- /dev/null +++ b/tests/configs/modules/calendar/symboltest.js @@ -0,0 +1,26 @@ +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; +} diff --git a/tests/configs/modules/clock/clock_12hr.js b/tests/configs/modules/clock/clock_12hr.js new file mode 100644 index 0000000..46ac92f --- /dev/null +++ b/tests/configs/modules/clock/clock_12hr.js @@ -0,0 +1,17 @@ +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; +} diff --git a/tests/configs/modules/clock/clock_24hr.js b/tests/configs/modules/clock/clock_24hr.js new file mode 100644 index 0000000..b1aee73 --- /dev/null +++ b/tests/configs/modules/clock/clock_24hr.js @@ -0,0 +1,15 @@ +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; +} diff --git a/tests/configs/modules/clock/clock_analog.js b/tests/configs/modules/clock/clock_analog.js new file mode 100644 index 0000000..f3c3ffd --- /dev/null +++ b/tests/configs/modules/clock/clock_analog.js @@ -0,0 +1,20 @@ +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; +} diff --git a/tests/configs/modules/clock/clock_displaySeconds_false.js b/tests/configs/modules/clock/clock_displaySeconds_false.js new file mode 100644 index 0000000..db2c5d1 --- /dev/null +++ b/tests/configs/modules/clock/clock_displaySeconds_false.js @@ -0,0 +1,20 @@ +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; +} diff --git a/tests/configs/modules/clock/clock_showDateAnalog.js b/tests/configs/modules/clock/clock_showDateAnalog.js new file mode 100644 index 0000000..0ce9e28 --- /dev/null +++ b/tests/configs/modules/clock/clock_showDateAnalog.js @@ -0,0 +1,22 @@ +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; +} diff --git a/tests/configs/modules/clock/clock_showPeriodUpper.js b/tests/configs/modules/clock/clock_showPeriodUpper.js new file mode 100644 index 0000000..d6ce8cb --- /dev/null +++ b/tests/configs/modules/clock/clock_showPeriodUpper.js @@ -0,0 +1,20 @@ +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; +} diff --git a/tests/configs/modules/clock/clock_showSunMoon.js b/tests/configs/modules/clock/clock_showSunMoon.js new file mode 100644 index 0000000..4472f6d --- /dev/null +++ b/tests/configs/modules/clock/clock_showSunMoon.js @@ -0,0 +1,21 @@ +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; +} diff --git a/tests/configs/modules/clock/clock_showSunNoEvent.js b/tests/configs/modules/clock/clock_showSunNoEvent.js new file mode 100644 index 0000000..5b5d029 --- /dev/null +++ b/tests/configs/modules/clock/clock_showSunNoEvent.js @@ -0,0 +1,20 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 12, + + modules: [ + { + module: "clock", + position: "middle_center", + config: { + showSunTimes: "disableNextEvent" + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/clock/clock_showTime.js b/tests/configs/modules/clock/clock_showTime.js new file mode 100644 index 0000000..bcb112c --- /dev/null +++ b/tests/configs/modules/clock/clock_showTime.js @@ -0,0 +1,20 @@ +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; +} diff --git a/tests/configs/modules/clock/clock_showWeek.js b/tests/configs/modules/clock/clock_showWeek.js new file mode 100644 index 0000000..d47aa89 --- /dev/null +++ b/tests/configs/modules/clock/clock_showWeek.js @@ -0,0 +1,20 @@ +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; +} diff --git a/tests/configs/modules/clock/clock_showWeek_short.js b/tests/configs/modules/clock/clock_showWeek_short.js new file mode 100644 index 0000000..6728d01 --- /dev/null +++ b/tests/configs/modules/clock/clock_showWeek_short.js @@ -0,0 +1,20 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 12, + + modules: [ + { + module: "clock", + position: "middle_center", + config: { + showWeek: "short" + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/clock/de/clock_showWeek.js b/tests/configs/modules/clock/de/clock_showWeek.js new file mode 100644 index 0000000..3adf265 --- /dev/null +++ b/tests/configs/modules/clock/de/clock_showWeek.js @@ -0,0 +1,21 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + language: "de", + timeFormat: 12, + + modules: [ + { + module: "clock", + position: "middle_center", + config: { + showWeek: true + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/clock/de/clock_showWeek_short.js b/tests/configs/modules/clock/de/clock_showWeek_short.js new file mode 100644 index 0000000..bdbdca7 --- /dev/null +++ b/tests/configs/modules/clock/de/clock_showWeek_short.js @@ -0,0 +1,21 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + language: "de", + timeFormat: 12, + + modules: [ + { + module: "clock", + position: "middle_center", + config: { + showWeek: "short" + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/clock/es/clock_12hr.js b/tests/configs/modules/clock/es/clock_12hr.js new file mode 100644 index 0000000..8295bfa --- /dev/null +++ b/tests/configs/modules/clock/es/clock_12hr.js @@ -0,0 +1,18 @@ +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; +} diff --git a/tests/configs/modules/clock/es/clock_24hr.js b/tests/configs/modules/clock/es/clock_24hr.js new file mode 100644 index 0000000..f9334c4 --- /dev/null +++ b/tests/configs/modules/clock/es/clock_24hr.js @@ -0,0 +1,17 @@ +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; +} diff --git a/tests/configs/modules/clock/es/clock_showPeriodUpper.js b/tests/configs/modules/clock/es/clock_showPeriodUpper.js new file mode 100644 index 0000000..d314e4b --- /dev/null +++ b/tests/configs/modules/clock/es/clock_showPeriodUpper.js @@ -0,0 +1,21 @@ +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; +} diff --git a/tests/configs/modules/clock/es/clock_showWeek.js b/tests/configs/modules/clock/es/clock_showWeek.js new file mode 100644 index 0000000..0a3d326 --- /dev/null +++ b/tests/configs/modules/clock/es/clock_showWeek.js @@ -0,0 +1,21 @@ +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; +} diff --git a/tests/configs/modules/clock/es/clock_showWeek_short.js b/tests/configs/modules/clock/es/clock_showWeek_short.js new file mode 100644 index 0000000..2581a2d --- /dev/null +++ b/tests/configs/modules/clock/es/clock_showWeek_short.js @@ -0,0 +1,21 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + language: "es", + timeFormat: 12, + + modules: [ + { + module: "clock", + position: "middle_center", + config: { + showWeek: "short" + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/compliments/compliments_animateCSS.js b/tests/configs/modules/compliments/compliments_animateCSS.js new file mode 100644 index 0000000..505b7d4 --- /dev/null +++ b/tests/configs/modules/compliments/compliments_animateCSS.js @@ -0,0 +1,24 @@ +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; +} diff --git a/tests/configs/modules/compliments/compliments_animateCSS_fallbackToDefault.js b/tests/configs/modules/compliments/compliments_animateCSS_fallbackToDefault.js new file mode 100644 index 0000000..fa98a56 --- /dev/null +++ b/tests/configs/modules/compliments/compliments_animateCSS_fallbackToDefault.js @@ -0,0 +1,24 @@ +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; +} diff --git a/tests/configs/modules/compliments/compliments_animateCSS_invertedAnimationName.js b/tests/configs/modules/compliments/compliments_animateCSS_invertedAnimationName.js new file mode 100644 index 0000000..863b8c7 --- /dev/null +++ b/tests/configs/modules/compliments/compliments_animateCSS_invertedAnimationName.js @@ -0,0 +1,24 @@ +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; +} diff --git a/tests/configs/modules/compliments/compliments_anytime.js b/tests/configs/modules/compliments/compliments_anytime.js new file mode 100644 index 0000000..5491f6a --- /dev/null +++ b/tests/configs/modules/compliments/compliments_anytime.js @@ -0,0 +1,25 @@ +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; +} diff --git a/tests/configs/modules/compliments/compliments_cron_entry.js b/tests/configs/modules/compliments/compliments_cron_entry.js new file mode 100644 index 0000000..a2010d5 --- /dev/null +++ b/tests/configs/modules/compliments/compliments_cron_entry.js @@ -0,0 +1,20 @@ +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; } diff --git a/tests/configs/modules/compliments/compliments_date.js b/tests/configs/modules/compliments/compliments_date.js new file mode 100644 index 0000000..80c0730 --- /dev/null +++ b/tests/configs/modules/compliments/compliments_date.js @@ -0,0 +1,25 @@ +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; +} diff --git a/tests/configs/modules/compliments/compliments_e2e_cron_entry.js b/tests/configs/modules/compliments/compliments_e2e_cron_entry.js new file mode 100644 index 0000000..eaba541 --- /dev/null +++ b/tests/configs/modules/compliments/compliments_e2e_cron_entry.js @@ -0,0 +1,20 @@ +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; } diff --git a/tests/configs/modules/compliments/compliments_evening.js b/tests/configs/modules/compliments/compliments_evening.js new file mode 100644 index 0000000..d7bf224 --- /dev/null +++ b/tests/configs/modules/compliments/compliments_evening.js @@ -0,0 +1,22 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 12, + + modules: [ + { + module: "compliments", + position: "middle_center", + config: { + compliments: { + evening: ["Evening here"] + } + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/compliments/compliments_file.js b/tests/configs/modules/compliments/compliments_file.js new file mode 100644 index 0000000..d73e1b5 --- /dev/null +++ b/tests/configs/modules/compliments/compliments_file.js @@ -0,0 +1,17 @@ +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; } diff --git a/tests/configs/modules/compliments/compliments_file_change.js b/tests/configs/modules/compliments/compliments_file_change.js new file mode 100644 index 0000000..51fd4f6 --- /dev/null +++ b/tests/configs/modules/compliments/compliments_file_change.js @@ -0,0 +1,19 @@ +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; } diff --git a/tests/configs/modules/compliments/compliments_only_anytime.js b/tests/configs/modules/compliments/compliments_only_anytime.js new file mode 100644 index 0000000..09b3cf2 --- /dev/null +++ b/tests/configs/modules/compliments/compliments_only_anytime.js @@ -0,0 +1,22 @@ +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; +} diff --git a/tests/configs/modules/compliments/compliments_parts_day.js b/tests/configs/modules/compliments/compliments_parts_day.js new file mode 100644 index 0000000..a38e0e2 --- /dev/null +++ b/tests/configs/modules/compliments/compliments_parts_day.js @@ -0,0 +1,24 @@ +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; +} diff --git a/tests/configs/modules/compliments/compliments_remote.js b/tests/configs/modules/compliments/compliments_remote.js new file mode 100644 index 0000000..844f14f --- /dev/null +++ b/tests/configs/modules/compliments/compliments_remote.js @@ -0,0 +1,18 @@ +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; +} diff --git a/tests/configs/modules/compliments/compliments_specialDayUnique_false.js b/tests/configs/modules/compliments/compliments_specialDayUnique_false.js new file mode 100644 index 0000000..1effd09 --- /dev/null +++ b/tests/configs/modules/compliments/compliments_specialDayUnique_false.js @@ -0,0 +1,24 @@ +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; } diff --git a/tests/configs/modules/compliments/compliments_specialDayUnique_true.js b/tests/configs/modules/compliments/compliments_specialDayUnique_true.js new file mode 100644 index 0000000..6e10799 --- /dev/null +++ b/tests/configs/modules/compliments/compliments_specialDayUnique_true.js @@ -0,0 +1,24 @@ +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; } diff --git a/tests/configs/modules/display.js b/tests/configs/modules/display.js new file mode 100644 index 0000000..230b7bd --- /dev/null +++ b/tests/configs/modules/display.js @@ -0,0 +1,26 @@ +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; +} diff --git a/tests/configs/modules/helloworld/helloworld.js b/tests/configs/modules/helloworld/helloworld.js new file mode 100644 index 0000000..a6bf31c --- /dev/null +++ b/tests/configs/modules/helloworld/helloworld.js @@ -0,0 +1,18 @@ +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; +} diff --git a/tests/configs/modules/helloworld/helloworld_default.js b/tests/configs/modules/helloworld/helloworld_default.js new file mode 100644 index 0000000..25f8ffa --- /dev/null +++ b/tests/configs/modules/helloworld/helloworld_default.js @@ -0,0 +1,15 @@ +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; +} diff --git a/tests/configs/modules/newsfeed/basic_html.js b/tests/configs/modules/newsfeed/basic_html.js new file mode 100644 index 0000000..4c4b6bc --- /dev/null +++ b/tests/configs/modules/newsfeed/basic_html.js @@ -0,0 +1,28 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 12, + + modules: [ + { + module: "newsfeed", + position: "bottom_bar", + config: { + feeds: [ + { + title: "Formatting Feed", + url: "http://localhost:8080/tests/mocks/newsfeed_basic_html.xml" + } + ], + showDescription: true, + truncDescription: false, + allowedBasicHtmlTags: ["b", "strong", "i", "em", "u", "br", "code", "s", "sub", "sup"] + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/newsfeed/default.js b/tests/configs/modules/newsfeed/default.js new file mode 100644 index 0000000..10e70b2 --- /dev/null +++ b/tests/configs/modules/newsfeed/default.js @@ -0,0 +1,25 @@ +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; +} diff --git a/tests/configs/modules/newsfeed/ignore_items.js b/tests/configs/modules/newsfeed/ignore_items.js new file mode 100644 index 0000000..b7e5ffd --- /dev/null +++ b/tests/configs/modules/newsfeed/ignore_items.js @@ -0,0 +1,26 @@ +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; +} diff --git a/tests/configs/modules/newsfeed/incorrect_url.js b/tests/configs/modules/newsfeed/incorrect_url.js new file mode 100644 index 0000000..0866469 --- /dev/null +++ b/tests/configs/modules/newsfeed/incorrect_url.js @@ -0,0 +1,25 @@ +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; +} diff --git a/tests/configs/modules/newsfeed/notifications.js b/tests/configs/modules/newsfeed/notifications.js new file mode 100644 index 0000000..43bd0b1 --- /dev/null +++ b/tests/configs/modules/newsfeed/notifications.js @@ -0,0 +1,26 @@ +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" + } + ], + updateInterval: 3600 * 1000 // 1 hour - prevent auto-rotation during tests + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/newsfeed/prohibited_words.js b/tests/configs/modules/newsfeed/prohibited_words.js new file mode 100644 index 0000000..d4b0d74 --- /dev/null +++ b/tests/configs/modules/newsfeed/prohibited_words.js @@ -0,0 +1,27 @@ +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; +} diff --git a/tests/configs/modules/positions.js b/tests/configs/modules/positions.js new file mode 100644 index 0000000..3013be2 --- /dev/null +++ b/tests/configs/modules/positions.js @@ -0,0 +1,25 @@ +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; +} diff --git a/tests/configs/modules/weather/currentweather_compliments.js b/tests/configs/modules/weather/currentweather_compliments.js new file mode 100644 index 0000000..70bb1b8 --- /dev/null +++ b/tests/configs/modules/weather/currentweather_compliments.js @@ -0,0 +1,31 @@ +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: { + lat: 48.14, + lon: 11.58, + weatherProvider: "openweathermap", + apiKey: "test-api-key" + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/weather/currentweather_default.js b/tests/configs/modules/weather/currentweather_default.js new file mode 100644 index 0000000..0e6e9f1 --- /dev/null +++ b/tests/configs/modules/weather/currentweather_default.js @@ -0,0 +1,24 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 12, + + modules: [ + { + module: "weather", + position: "bottom_bar", + config: { + lat: 48.14, + lon: 11.58, + showHumidity: "feelslike", + weatherProvider: "openweathermap", + apiKey: "test-api-key" + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/weather/currentweather_options.js b/tests/configs/modules/weather/currentweather_options.js new file mode 100644 index 0000000..814fca5 --- /dev/null +++ b/tests/configs/modules/weather/currentweather_options.js @@ -0,0 +1,27 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + modules: [ + { + module: "weather", + position: "bottom_bar", + config: { + lat: 48.14, + lon: 11.58, + weatherProvider: "openweathermap", + apiKey: "test-api-key", + 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; +} diff --git a/tests/configs/modules/weather/currentweather_units.js b/tests/configs/modules/weather/currentweather_units.js new file mode 100644 index 0000000..33baecd --- /dev/null +++ b/tests/configs/modules/weather/currentweather_units.js @@ -0,0 +1,25 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + units: "imperial", + + modules: [ + { + module: "weather", + position: "bottom_bar", + config: { + lat: 48.14, + lon: 11.58, + weatherProvider: "openweathermap", + apiKey: "test-api-key", + decimalSymbol: ",", + showHumidity: "wind" + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/weather/forecastweather_absolute.js b/tests/configs/modules/weather/forecastweather_absolute.js new file mode 100644 index 0000000..01fc4f4 --- /dev/null +++ b/tests/configs/modules/weather/forecastweather_absolute.js @@ -0,0 +1,25 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 12, + + modules: [ + { + module: "weather", + position: "bottom_bar", + config: { + type: "forecast", + lat: 48.14, + lon: 11.58, + weatherProvider: "openweathermap", + apiKey: "test-api-key", + absoluteDates: true + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/weather/forecastweather_default.js b/tests/configs/modules/weather/forecastweather_default.js new file mode 100644 index 0000000..4cb2376 --- /dev/null +++ b/tests/configs/modules/weather/forecastweather_default.js @@ -0,0 +1,24 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 12, + + modules: [ + { + module: "weather", + position: "bottom_bar", + config: { + type: "forecast", + lat: 48.14, + lon: 11.58, + weatherProvider: "openweathermap", + apiKey: "test-api-key" + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/weather/forecastweather_options.js b/tests/configs/modules/weather/forecastweather_options.js new file mode 100644 index 0000000..25ff5bc --- /dev/null +++ b/tests/configs/modules/weather/forecastweather_options.js @@ -0,0 +1,27 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 12, + + modules: [ + { + module: "weather", + position: "bottom_bar", + config: { + type: "forecast", + lat: 48.14, + lon: 11.58, + weatherProvider: "openweathermap", + apiKey: "test-api-key", + showPrecipitationAmount: true, + colored: true, + tableClass: "myTableClass" + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/weather/forecastweather_units.js b/tests/configs/modules/weather/forecastweather_units.js new file mode 100644 index 0000000..a71afaa --- /dev/null +++ b/tests/configs/modules/weather/forecastweather_units.js @@ -0,0 +1,26 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + units: "imperial", + + modules: [ + { + module: "weather", + position: "bottom_bar", + config: { + type: "forecast", + lat: 48.14, + lon: 11.58, + weatherProvider: "openweathermap", + apiKey: "test-api-key", + decimalSymbol: "_", + showPrecipitationAmount: true + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/weather/hourlyweather_default.js b/tests/configs/modules/weather/hourlyweather_default.js new file mode 100644 index 0000000..e7437b0 --- /dev/null +++ b/tests/configs/modules/weather/hourlyweather_default.js @@ -0,0 +1,24 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 12, + + modules: [ + { + module: "weather", + position: "bottom_bar", + config: { + lat: 48.14, + lon: 11.58, + type: "hourly", + weatherProvider: "openweathermap", + apiKey: "test-api-key" + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/weather/hourlyweather_options.js b/tests/configs/modules/weather/hourlyweather_options.js new file mode 100644 index 0000000..0e323a9 --- /dev/null +++ b/tests/configs/modules/weather/hourlyweather_options.js @@ -0,0 +1,25 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 12, + + modules: [ + { + module: "weather", + position: "bottom_bar", + config: { + lat: 48.14, + lon: 11.58, + type: "hourly", + weatherProvider: "openweathermap", + apiKey: "test-api-key", + hourlyForecastIncrements: 2 + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/modules/weather/hourlyweather_showPrecipitation.js b/tests/configs/modules/weather/hourlyweather_showPrecipitation.js new file mode 100644 index 0000000..bc04a99 --- /dev/null +++ b/tests/configs/modules/weather/hourlyweather_showPrecipitation.js @@ -0,0 +1,26 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [], + timeFormat: 12, + + modules: [ + { + module: "weather", + position: "bottom_bar", + config: { + lat: 48.14, + lon: 11.58, + type: "hourly", + weatherProvider: "openweathermap", + apiKey: "test-api-key", + showPrecipitationAmount: true, + showPrecipitationProbability: true + } + } + ] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/configs/noIpWhiteList.js b/tests/configs/noIpWhiteList.js new file mode 100644 index 0000000..57eaa91 --- /dev/null +++ b/tests/configs/noIpWhiteList.js @@ -0,0 +1,9 @@ +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; +} diff --git a/tests/configs/port_8090.js b/tests/configs/port_8090.js new file mode 100644 index 0000000..4019be8 --- /dev/null +++ b/tests/configs/port_8090.js @@ -0,0 +1,8 @@ +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; +} diff --git a/tests/configs/without_modules.js b/tests/configs/without_modules.js new file mode 100644 index 0000000..988c338 --- /dev/null +++ b/tests/configs/without_modules.js @@ -0,0 +1,9 @@ +let config = { + address: "0.0.0.0", + ipWhitelist: [] +}; + +/*************** DO NOT EDIT THE LINE BELOW ***************/ +if (typeof module !== "undefined") { + module.exports = config; +} diff --git a/tests/e2e/animateCSS_spec.js b/tests/e2e/animateCSS_spec.js new file mode 100644 index 0000000..eed01f4 --- /dev/null +++ b/tests/e2e/animateCSS_spec.js @@ -0,0 +1,107 @@ +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(); + }); + }); +}); diff --git a/tests/e2e/clientonly_spec.js b/tests/e2e/clientonly_spec.js new file mode 100644 index 0000000..e19d4ee --- /dev/null +++ b/tests/e2e/clientonly_spec.js @@ -0,0 +1,179 @@ +const { spawnSync, spawn } = require("node:child_process"); + +const delay = (time) => { + return new Promise((resolve) => setTimeout(resolve, time)); +}; + +/** + * Run clientonly with given arguments and return result + * @param {string[]} args command line arguments + * @param {object} env environment variables to merge (replaces process.env) + * @returns {object} result with status and stderr + */ +const runClientOnly = (args = [], env = {}) => { + // Start with minimal env and merge provided env + const testEnv = { + PATH: process.env.PATH, + NODE_PATH: process.env.NODE_PATH, + ...env + }; + const result = spawnSync("node", ["clientonly/index.js", ...args], { + env: testEnv, + encoding: "utf-8", + timeout: 5000 + }); + return result; +}; + +describe("Clientonly parameter handling", () => { + + describe("Missing parameters", () => { + it("should fail without any parameters", () => { + const result = runClientOnly(); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Usage:"); + }); + + it("should fail with only address parameter", () => { + const result = runClientOnly(["--address", "192.168.1.10"]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Usage:"); + }); + + it("should fail with only port parameter", () => { + const result = runClientOnly(["--port", "8080"]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Usage:"); + }); + }); + + describe("Local address rejection", () => { + it("should fail with localhost address", () => { + const result = runClientOnly(["--address", "localhost", "--port", "8080"]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Usage:"); + }); + + it("should fail with 127.0.0.1 address", () => { + const result = runClientOnly(["--address", "127.0.0.1", "--port", "8080"]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Usage:"); + }); + + it("should fail with ::1 address", () => { + const result = runClientOnly(["--address", "::1", "--port", "8080"]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Usage:"); + }); + + it("should fail with ::ffff:127.0.0.1 address", () => { + const result = runClientOnly(["--address", "::ffff:127.0.0.1", "--port", "8080"]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Usage:"); + }); + }); + + describe("Port validation", () => { + it("should fail with port 0", () => { + const result = runClientOnly(["--address", "192.168.1.10", "--port", "0"]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Invalid port number"); + }); + + it("should fail with negative port", () => { + const result = runClientOnly(["--address", "192.168.1.10", "--port", "-1"]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Invalid port number"); + }); + + it("should fail with port above 65535", () => { + const result = runClientOnly(["--address", "192.168.1.10", "--port", "65536"]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Invalid port number"); + }); + + it("should fail with non-numeric port", () => { + const result = runClientOnly(["--address", "192.168.1.10", "--port", "abc"]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Invalid port number"); + }); + + it("should accept valid port 8080", () => { + const result = runClientOnly(["--address", "192.168.1.10", "--port", "8080"]); + // Should not fail on port validation (will fail on connection or display) + expect(result.stderr).not.toContain("Invalid port number"); + }); + + it("should accept valid port 1", () => { + const result = runClientOnly(["--address", "192.168.1.10", "--port", "1"]); + expect(result.stderr).not.toContain("Invalid port number"); + }); + + it("should accept valid port 65535", () => { + const result = runClientOnly(["--address", "192.168.1.10", "--port", "65535"]); + expect(result.stderr).not.toContain("Invalid port number"); + }); + }); + + describe("TLS flag parsing", () => { + // Note: These tests verify the flag is parsed, not the actual connection behavior + // Connection tests would timeout as they try to reach unreachable addresses + + it("should not fail on port validation when using --use-tls", () => { + // Verify --use-tls doesn't interfere with other parameter parsing + const result = runClientOnly(["--address", "192.168.1.10", "--port", "443", "--use-tls"]); + expect(result.stderr).not.toContain("Invalid port number"); + }); + + it("should accept --use-tls flag with valid parameters", () => { + const result = runClientOnly(["--address", "192.168.1.10", "--port", "443", "--use-tls"]); + // Should not fail on parameter parsing (will fail on connection or display) + expect(result.stderr).not.toContain("Usage:"); + }); + }); + + describe("Display environment check", () => { + it("should fail without DISPLAY or WAYLAND_DISPLAY when connecting to valid server", () => { + // This test needs a running server to get past the connection phase + // Without DISPLAY, it should fail with display error + // For now, we just verify it fails (connection error comes first without server) + const result = runClientOnly(["--address", "192.168.1.10", "--port", "1"]); + // Either exits with code 1 or times out (null status means killed/timeout) + expect(result.status === 1 || result.status === null).toBe(true); + }); + }); +}); + +describe("Clientonly with running server", () => { + let serverProcess; + const testPort = 8081; + + beforeAll(async () => { + process.env.MM_CONFIG_FILE = "tests/configs/default.js"; + process.env.MM_PORT = testPort.toString(); + serverProcess = spawn("node", ["--run", "server"], { + env: process.env, + detached: true + }); + // Wait for server to start + await delay(2000); + }); + + afterAll(() => { + if (serverProcess && serverProcess.pid) { + try { + process.kill(-serverProcess.pid); + } catch { + // Process may already be dead + } + } + }); + + it("should be able to fetch config from server", async () => { + const res = await fetch(`http://localhost:${testPort}/config/`); + expect(res.status).toBe(200); + const config = await res.json(); + expect(config).toBeDefined(); + expect(typeof config).toBe("object"); + }); +}); diff --git a/tests/e2e/config_functions_spec.js b/tests/e2e/config_functions_spec.js new file mode 100644 index 0000000..b40361c --- /dev/null +++ b/tests/e2e/config_functions_spec.js @@ -0,0 +1,21 @@ +const helpers = require("./helpers/global-setup"); + +describe("config with module function", () => { + beforeAll(async () => { + await helpers.startApplication("tests/configs/config_functions.js"); + }); + + afterAll(async () => { + await helpers.stopApplication(); + }); + + it("config should resolve module functions", () => { + expect(config.modules[0].config.moduleFunctions.roundToInt1(13.3)).toBe(13); + expect(config.modules[0].config.moduleFunctions.roundToInt2(13.3)).toBe(13); + }); + + it("config should not revive plain strings containing arrow or function keywords", () => { + expect(config.modules[0].config.stringWithArrow).toBe("a => b is not a function"); + expect(config.modules[0].config.stringWithFunction).toBe("this function keyword is just text"); + }); +}); diff --git a/tests/e2e/config_variables_spec.js b/tests/e2e/config_variables_spec.js new file mode 100644 index 0000000..4fb9ced --- /dev/null +++ b/tests/e2e/config_variables_spec.js @@ -0,0 +1,34 @@ +const helpers = require("./helpers/global-setup"); + +describe("config with variables and secrets", () => { + beforeAll(async () => { + await helpers.startApplication("tests/configs/config_variables.js"); + }); + + afterAll(async () => { + await helpers.stopApplication(); + }); + + it("config.language should be \"de\"", () => { + expect(config.language).toBe("de"); + }); + + it("config.loglevel should be [\"ERROR\", \"LOG\", \"WARN\", \"INFO\"]", () => { + expect(config.logLevel).toStrictEqual(["ERROR", "LOG", "WARN", "INFO"]); + }); + + it("config.ipWhitelist should be [\"::ffff:127.0.0.1\", \"::1\", \"127.0.0.1\"]", () => { + expect(config.ipWhitelist).toStrictEqual(["::ffff:127.0.0.1", "::1", "127.0.0.1"]); + }); + + it("config.timeFormat should be 12", () => { + expect(config.timeFormat).toBe(12); // default is 24 + }); + + it("/config endpoint should show redacted secrets", async () => { + const res = await fetch(`http://localhost:${config.port}/config`); + expect(res.status).toBe(200); + const cfg = await res.json(); + expect(cfg.ipWhitelist).toStrictEqual(["**SECRET_IP2**", "::**SECRET_IP3**", "**SECRET_IP1**"]); + }); +}); diff --git a/tests/e2e/custom_module_regions_spec.js b/tests/e2e/custom_module_regions_spec.js new file mode 100644 index 0000000..d52e2cd --- /dev/null +++ b/tests/e2e/custom_module_regions_spec.js @@ -0,0 +1,32 @@ +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); + }); +}); diff --git a/tests/e2e/env_spec.js b/tests/e2e/env_spec.js new file mode 100644 index 0000000..14383e2 --- /dev/null +++ b/tests/e2e/env_spec.js @@ -0,0 +1,30 @@ +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²"); + }); +}); diff --git a/tests/e2e/fonts_spec.js b/tests/e2e/fonts_spec.js new file mode 100644 index 0000000..524a4e0 --- /dev/null +++ b/tests/e2e/fonts_spec.js @@ -0,0 +1,29 @@ +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); + }); +}); diff --git a/tests/e2e/helpers/basic-auth.js b/tests/e2e/helpers/basic-auth.js new file mode 100644 index 0000000..865a69f --- /dev/null +++ b/tests/e2e/helpers/basic-auth.js @@ -0,0 +1,29 @@ +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(); +}; diff --git a/tests/e2e/helpers/global-setup.js b/tests/e2e/helpers/global-setup.js new file mode 100644 index 0000000..01e20ff --- /dev/null +++ b/tests/e2e/helpers/global-setup.js @@ -0,0 +1,188 @@ +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}/config/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) => { + 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 MM_PORT if preset by a test, otherwise default to 8080. + const port = Number(process.env.MM_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; + + // Ensure MM_PORT is set before app loads + process.env.MM_PORT = port.toString(); + + process.env.mmTestMode = "true"; + process.setMaxListeners(0); + 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; + delete process.env.MM_PORT; + + // 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 }); + } +}; diff --git a/tests/e2e/helpers/weather-functions.js b/tests/e2e/helpers/weather-functions.js new file mode 100644 index 0000000..8eb0c06 --- /dev/null +++ b/tests/e2e/helpers/weather-functions.js @@ -0,0 +1,108 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const weatherUtils = require("../../../defaultmodules/weather/provider-utils"); +const helpers = require("./global-setup"); + +/** + * Inject mock weather data directly via socket communication + * This bypasses the weather provider and tests only client-side rendering + * @param {object} page - Playwright page + * @param {string} mockDataFile - Filename of mock data + */ +async function injectMockWeatherData (page, mockDataFile) { + const rawData = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../../mocks", mockDataFile)).toString()); + + // Validate that the fixture has at least one expected weather data type + if (!rawData.current && !rawData.daily && !rawData.hourly) { + throw new Error( + "Invalid weather fixture: missing current, daily, and hourly data. " + + `Available keys: ${Object.keys(rawData).join(", ")}` + ); + } + + // Determine weather type from the mock data structure + let type = "current"; + let data = null; + + const timezoneOffset = rawData.timezone_offset ? rawData.timezone_offset / 60 : 0; + + if (rawData.current) { + type = "current"; + // Mock what the provider would send for current weather + data = { + date: weatherUtils.applyTimezoneOffset(new Date(rawData.current.dt * 1000), timezoneOffset), + windSpeed: rawData.current.wind_speed, + windFromDirection: rawData.current.wind_deg, + sunrise: weatherUtils.applyTimezoneOffset(new Date(rawData.current.sunrise * 1000), timezoneOffset), + sunset: weatherUtils.applyTimezoneOffset(new Date(rawData.current.sunset * 1000), timezoneOffset), + temperature: rawData.current.temp, + weatherType: weatherUtils.convertWeatherType(rawData.current.weather[0].icon), + humidity: rawData.current.humidity, + feelsLikeTemp: rawData.current.feels_like + }; + } else if (rawData.daily) { + type = "forecast"; + data = rawData.daily.map((day) => ({ + date: weatherUtils.applyTimezoneOffset(new Date(day.dt * 1000), timezoneOffset), + minTemperature: day.temp.min, + maxTemperature: day.temp.max, + weatherType: weatherUtils.convertWeatherType(day.weather[0].icon), + rain: day.rain || 0, + snow: day.snow || 0, + precipitationAmount: (day.rain || 0) + (day.snow || 0) + })); + } else if (rawData.hourly) { + type = "hourly"; + data = rawData.hourly.map((hour) => ({ + date: weatherUtils.applyTimezoneOffset(new Date(hour.dt * 1000), timezoneOffset), + temperature: hour.temp, + feelsLikeTemp: hour.feels_like, + humidity: hour.humidity, + windSpeed: hour.wind_speed, + windFromDirection: hour.wind_deg, + weatherType: weatherUtils.convertWeatherType(hour.weather[0].icon), + precipitationProbability: hour.pop != null ? hour.pop * 100 : undefined, + precipitationAmount: (hour.rain?.["1h"] || 0) + (hour.snow?.["1h"] || 0) + })); + } + + // Inject weather data by evaluating code in the browser context + await page.evaluate(({ type, data }) => { + // Find the weather module instance + const weatherModule = MM.getModules().find((m) => m.name === "weather"); + if (weatherModule) { + // Send INITIALIZED first + weatherModule.socketNotificationReceived("WEATHER_INITIALIZED", { + instanceId: weatherModule.instanceId, + locationName: "Munich" + }); + // Then send the actual data + weatherModule.socketNotificationReceived("WEATHER_DATA", { + instanceId: weatherModule.instanceId, + type: type, + data: data + }); + } + }, { type, data }); +} + +exports.startApplication = async (configFileName, mockDataFile) => { + await helpers.startApplication(configFileName); + await helpers.getDocument(); + + // If mock data file is provided, inject it + if (mockDataFile) { + const page = helpers.getPage(); + // Wait for weather module to initialize + // eslint-disable-next-line playwright/no-wait-for-selector + await page.waitForSelector(".weather", { timeout: 5000 }); + await injectMockWeatherData(page, mockDataFile); + // Wait for data to be rendered + // eslint-disable-next-line playwright/no-wait-for-selector + await page.waitForSelector(".weather .weathericon", { timeout: 2000 }); + } +}; + +exports.stopApplication = async () => { + await helpers.stopApplication(); +}; diff --git a/tests/e2e/ipWhitelist_spec.js b/tests/e2e/ipWhitelist_spec.js new file mode 100644 index 0000000..3a7c9e4 --- /dev/null +++ b/tests/e2e/ipWhitelist_spec.js @@ -0,0 +1,47 @@ +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); + }); + + it("should also reject Socket.IO handshake with 403 (Forbidden) — not just HTTP routes", async () => { + const port = global.testPort || 8080; + const res = await fetch(`http://localhost:${port}/socket.io/?EIO=4&transport=polling`); + 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); + }); + + it("should also allow Socket.IO handshake with 200 (OK) — not just HTTP routes", async () => { + const port = global.testPort || 8080; + const res = await fetch(`http://localhost:${port}/socket.io/?EIO=4&transport=polling`); + expect(res.status).toBe(200); + }); + }); +}); diff --git a/tests/e2e/modules/alert_spec.js b/tests/e2e/modules/alert_spec.js new file mode 100644 index 0000000..367f2eb --- /dev/null +++ b/tests/e2e/modules/alert_spec.js @@ -0,0 +1,53 @@ +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!"); + }); + }); +}); diff --git a/tests/e2e/modules/calendar_spec.js b/tests/e2e/modules/calendar_spec.js new file mode 100644 index 0000000..d24bae1 --- /dev/null +++ b/tests/e2e/modules/calendar_spec.js @@ -0,0 +1,194 @@ +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"); + }); + }); +}); diff --git a/tests/e2e/modules/clock_de_spec.js b/tests/e2e/modules/clock_de_spec.js new file mode 100644 index 0000000..79d3bb4 --- /dev/null +++ b/tests/e2e/modules/clock_de_spec.js @@ -0,0 +1,36 @@ +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); + }); + }); +}); diff --git a/tests/e2e/modules/clock_es_spec.js b/tests/e2e/modules/clock_es_spec.js new file mode 100644 index 0000000..6749bda --- /dev/null +++ b/tests/e2e/modules/clock_es_spec.js @@ -0,0 +1,85 @@ +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); + }); + }); +}); diff --git a/tests/e2e/modules/clock_spec.js b/tests/e2e/modules/clock_spec.js new file mode 100644 index 0000000..6fb72fa --- /dev/null +++ b/tests/e2e/modules/clock_spec.js @@ -0,0 +1,183 @@ +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(); + }); + }); +}); diff --git a/tests/e2e/modules/compliments_spec.js b/tests/e2e/modules/compliments_spec.js new file mode 100644 index 0000000..ef35c8a --- /dev/null +++ b/tests/e2e/modules/compliments_spec.js @@ -0,0 +1,136 @@ +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() + // }); + }); + }); + +}); diff --git a/tests/e2e/modules/helloworld_spec.js b/tests/e2e/modules/helloworld_spec.js new file mode 100644 index 0000000..2db8fac --- /dev/null +++ b/tests/e2e/modules/helloworld_spec.js @@ -0,0 +1,34 @@ +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!"); + }); + }); +}); diff --git a/tests/e2e/modules/newsfeed_spec.js b/tests/e2e/modules/newsfeed_spec.js new file mode 100644 index 0000000..88a6205 --- /dev/null +++ b/tests/e2e/modules/newsfeed_spec.js @@ -0,0 +1,260 @@ +const fs = require("node:fs"); +const { expect } = require("playwright/test"); +const helpers = require("../helpers/global-setup"); + +const runTests = () => { + 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("Basic HTML tags", () => { + beforeAll(async () => { + await helpers.startApplication("tests/configs/modules/newsfeed/basic_html.js"); + await helpers.getDocument(); + page = helpers.getPage(); + }); + + it("should render allowlisted formatting tags in title and description", async () => { + await expect(page.locator(".newsfeed .newsfeed-desc")).toBeVisible(); + const descHtml = await page.locator(".newsfeed .newsfeed-desc").innerHTML(); + expect(descHtml).toContain(""); + expect(descHtml).toContain(""); + expect(descHtml).toContain(""); + const titleHtml = await page.locator(".newsfeed .newsfeed-title").innerHTML(); + expect(titleHtml).toContain(""); + }); + + it("should strip disallowed HTML and not execute injected scripts", async () => { + const descHtml = await page.locator(".newsfeed .newsfeed-desc").innerHTML(); + expect(descHtml).not.toContain(" window.__newsfeedXss); + expect(xss).toBeUndefined(); + }); + }); + + 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 > Notifications", () => { + let page; + + afterAll(async () => { + await helpers.stopApplication(); + }); + + /** + * Helper: call notificationReceived on the newsfeed module directly. + * @param {object} p - playwright page + * @param {string} notification - notification name + * @param {object} payload - notification payload + * @returns {Promise} resolves when the notification has been dispatched + */ + const notify = (p, notification, payload = {}) => p.evaluate( + ({ n, pl }) => { + const nf = MM.getModules().find((m) => m.name === "newsfeed"); + nf.notificationReceived(n, pl, nf); + }, + { n: notification, pl: payload } + ); + + beforeAll(async () => { + await helpers.startApplication("tests/configs/modules/newsfeed/notifications.js"); + await helpers.getDocument(); + page = helpers.getPage(); + await expect(page.locator(".newsfeed .newsfeed-title")).toBeVisible(); + }); + + it("ARTICLE_NEXT should show the next article", async () => { + const title1 = await page.locator(".newsfeed .newsfeed-title").textContent(); + await notify(page, "ARTICLE_NEXT"); + await expect(page.locator(".newsfeed .newsfeed-title")).not.toContainText(title1.trim()); + }); + + it("ARTICLE_PREVIOUS should return to the previous article", async () => { + // Start at article 0, go to article 1, then back + await page.evaluate(() => { + const nf = MM.getModules().find((m) => m.name === "newsfeed"); + nf.activeItem = 0; + nf.resetDescrOrFullArticleAndTimer(); + nf.updateDom(0); + }); + await expect(page.locator(".newsfeed .newsfeed-title")).toContainText("QPanel"); + const title0 = await page.locator(".newsfeed .newsfeed-title").textContent(); + + await notify(page, "ARTICLE_NEXT"); + await expect(page.locator(".newsfeed .newsfeed-title")).not.toContainText(title0.trim()); + + await notify(page, "ARTICLE_PREVIOUS"); + await expect(page.locator(".newsfeed .newsfeed-title")).toContainText(title0.trim()); + }); + + it("ARTICLE_NEXT should wrap around from the last article to the first", async () => { + // Jump to the last article + await page.evaluate(() => { + const nf = MM.getModules().find((m) => m.name === "newsfeed"); + nf.activeItem = nf.newsItems.length - 1; + nf.resetDescrOrFullArticleAndTimer(); + nf.updateDom(0); + }); + await expect(page.locator(".newsfeed .newsfeed-title")).toBeVisible(); + const titleLast = await page.locator(".newsfeed .newsfeed-title").textContent(); + + await notify(page, "ARTICLE_NEXT"); + await expect(page.locator(".newsfeed .newsfeed-title")).not.toContainText(titleLast.trim()); + + // activeItem should now be 0 + const activeItem = await page.evaluate(() => MM.getModules().find((m) => m.name === "newsfeed").activeItem); + expect(activeItem).toBe(0); + }); + + it("ARTICLE_PREVIOUS should wrap around from the first article to the last", async () => { + await page.evaluate(() => { + const nf = MM.getModules().find((m) => m.name === "newsfeed"); + nf.activeItem = 0; + nf.resetDescrOrFullArticleAndTimer(); + }); + await notify(page, "ARTICLE_PREVIOUS"); + + const activeItem = await page.evaluate(() => { + const nf = MM.getModules().find((m) => m.name === "newsfeed"); + return { activeItem: nf.activeItem, total: nf.newsItems.length }; + }); + expect(activeItem.activeItem).toBe(activeItem.total - 1); + }); + + it("ARTICLE_INFO_REQUEST should respond with title, source, date, desc and raw url", async () => { + await page.evaluate(() => { + const nf = MM.getModules().find((m) => m.name === "newsfeed"); + nf.activeItem = 0; + nf.resetDescrOrFullArticleAndTimer(); + }); + + const info = await page.evaluate(() => new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("ARTICLE_INFO_RESPONSE timeout")), 3000); + const origSend = MM.sendNotification.bind(MM); + MM.sendNotification = function (n, p, s) { + if (n === "ARTICLE_INFO_RESPONSE") { + clearTimeout(timer); + MM.sendNotification = origSend; + resolve(p); + } + return origSend(n, p, s); + }; + const nf = MM.getModules().find((m) => m.name === "newsfeed"); + nf.notificationReceived("ARTICLE_INFO_REQUEST", {}, nf); + })); + + expect(info).toHaveProperty("title"); + expect(info).toHaveProperty("source"); + expect(info).toHaveProperty("date"); + expect(info).toHaveProperty("desc"); + expect(info).toHaveProperty("url"); + expect(info.title).toBe("QPanel 0.13.0"); + expect(info.source).toBe("Rodrigo Ramirez Blog"); + // URL must be the raw article URL, not a CORS proxy URL + expect(info.url).toMatch(/^https?:\/\//); + expect(info.url).not.toContain("localhost"); + }); + + it("ARTICLE_LESS_DETAILS should reset the full article view", async () => { + // Simulate full article view being active + await page.evaluate(() => { + const nf = MM.getModules().find((m) => m.name === "newsfeed"); + nf.config.showFullArticle = true; + nf.articleFrameCheckPending = false; + nf.articleUnavailable = false; + }); + + await notify(page, "ARTICLE_LESS_DETAILS"); + + const state = await page.evaluate(() => { + const nf = MM.getModules().find((m) => m.name === "newsfeed"); + return { showFullArticle: nf.config.showFullArticle }; + }); + expect(state.showFullArticle).toBe(false); + // Normal newsfeed title should be visible again + await expect(page.locator(".newsfeed .newsfeed-title")).toBeVisible(); + }); +}); + +describe("Newsfeed module", () => { + afterAll(async () => { + await helpers.stopApplication(); + }); + + runTests(); +}); + +describe("Newsfeed module located in config directory", () => { + beforeAll(() => { + fs.cpSync(`${global.root_path}/${global.defaultModulesDir}/newsfeed`, `${global.root_path}/config/newsfeed`, { recursive: true }); + process.env.MM_MODULES_DIR = "config"; + }); + + afterAll(async () => { + await helpers.stopApplication(); + }); + + runTests(); +}); diff --git a/tests/e2e/modules/weather_current_spec.js b/tests/e2e/modules/weather_current_spec.js new file mode 100644 index 0000000..bb8b63e --- /dev/null +++ b/tests/e2e/modules/weather_current_spec.js @@ -0,0 +1,98 @@ +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", "weather_onecall_current.json"); + 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", "weather_onecall_current.json"); + 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", "weather_onecall_current.json"); + 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", "weather_onecall_current.json"); + 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°"); + }); + }); +}); diff --git a/tests/e2e/modules/weather_forecast_spec.js b/tests/e2e/modules/weather_forecast_spec.js new file mode 100644 index 0000000..435cc98 --- /dev/null +++ b/tests/e2e/modules/weather_forecast_spec.js @@ -0,0 +1,128 @@ +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", "weather_onecall_forecast.json"); + 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", "weather_onecall_forecast.json"); + 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", "weather_onecall_forecast.json"); + 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", "weather_onecall_forecast.json"); + 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); + }); + } + } + }); + }); +}); diff --git a/tests/e2e/modules/weather_hourly_spec.js b/tests/e2e/modules/weather_hourly_spec.js new file mode 100644 index 0000000..d84bd69 --- /dev/null +++ b/tests/e2e/modules/weather_hourly_spec.js @@ -0,0 +1,74 @@ +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", "weather_onecall_hourly.json"); + 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", "weather_onecall_hourly.json"); + 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", "weather_onecall_hourly.json"); + 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); + }); + } + } + }); + }); +}); diff --git a/tests/e2e/modules_display_spec.js b/tests/e2e/modules_display_spec.js new file mode 100644 index 0000000..fc4c992 --- /dev/null +++ b/tests/e2e/modules_display_spec.js @@ -0,0 +1,25 @@ +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"); + }); +}); diff --git a/tests/e2e/modules_empty_spec.js b/tests/e2e/modules_empty_spec.js new file mode 100644 index 0000000..18c5d30 --- /dev/null +++ b/tests/e2e/modules_empty_spec.js @@ -0,0 +1,24 @@ +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/"); + }); +}); diff --git a/tests/e2e/modules_position_spec.js b/tests/e2e/modules_position_spec.js new file mode 100644 index 0000000..ee4f14e --- /dev/null +++ b/tests/e2e/modules_position_spec.js @@ -0,0 +1,27 @@ +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}`); + }); + } +}); diff --git a/tests/e2e/port_spec.js b/tests/e2e/port_spec.js new file mode 100644 index 0000000..8638d0e --- /dev/null +++ b/tests/e2e/port_spec.js @@ -0,0 +1,36 @@ +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 res = await fetch(`http://localhost:${global.testPort}`); + expect(res.status).toBe(200); + }); + }); + + describe("Set port 8100 on environment variable MM_PORT", () => { + beforeAll(async () => { + process.env.MM_PORT = "8100"; + await helpers.startApplication("tests/configs/port_8090.js"); + }); + + afterAll(async () => { + await helpers.stopApplication(); + delete process.env.MM_PORT; + }); + + it("should return 200", async () => { + expect(global.testPort).toBe(8100); + const res = await fetch(`http://localhost:${global.testPort}`); + expect(res.status).toBe(200); + }); + }); +}); diff --git a/tests/e2e/serveronly_spec.js b/tests/e2e/serveronly_spec.js new file mode 100644 index 0000000..b793543 --- /dev/null +++ b/tests/e2e/serveronly_spec.js @@ -0,0 +1,51 @@ +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); + }); +}); diff --git a/tests/e2e/translations_spec.js b/tests/e2e/translations_spec.js new file mode 100644 index 0000000..ee275ae --- /dev/null +++ b/tests/e2e/translations_spec.js @@ -0,0 +1,242 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const { pathToFileURL } = require("node:url"); +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(async () => { + // Create a new translation test environment for each test + const env = createTranslationTestEnvironment(); + const window = env.window; + + // Bridge JSDOM globals to Node.js so module.js (ES module) can access them + global.Log = window.Log; + global.Translator = window.Translator; + global.config = { language: "de" }; + global.window = { name: "", mmVersion: "2.0.0" }; + global.MM = { hideModule: () => {}, showModule: () => {}, sendNotification: () => {}, updateDom: () => {} }; + global.nunjucks = { + Environment () { + this.addFilter = () => {}; + this.renderString = () => ""; + this.render = (_t, _d, cb) => cb(null, ""); + }, + WebLoader () {}, + runtime: { markSafe: (str) => str } + }; + + // Import Module directly — eval can't handle ES module syntax + const modulePath = pathToFileURL(path.join(__dirname, "..", "..", "js", "module.js")).href; + const { Module } = await import(`${modulePath}?test=${Date.now()}`); + window.Module = Module; + + // Expose config on window so tests can modify dom.window.config + window.config = global.config; + + dom = { window }; + }); + + afterEach(() => { + delete global.Log; + delete global.Translator; + delete global.config; + delete global.window; + delete global.MM; + delete global.nunjucks; + }); + + it("should load translation file", async () => { + const { Translator, Module, config } = dom.window; + config.language = "en"; + Translator.load = vi.fn().mockImplementation(() => 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(() => 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(() => 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}'`); + }); + }); + }); + } + }); +}); diff --git a/tests/e2e/vendor_spec.js b/tests/e2e/vendor_spec.js new file mode 100644 index 0000000..aca6f3f --- /dev/null +++ b/tests/e2e/vendor_spec.js @@ -0,0 +1,23 @@ +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); + }); + }); + }); +}); diff --git a/tests/electron/env_spec.js b/tests/electron/env_spec.js new file mode 100644 index 0000000..3351e1a --- /dev/null +++ b/tests/electron/env_spec.js @@ -0,0 +1,38 @@ +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()); + } + }); +}); diff --git a/tests/electron/helpers/global-setup.js b/tests/electron/helpers/global-setup.js new file mode 100644 index 0000000..8df47c4 --- /dev/null +++ b/tests/electron/helpers/global-setup.js @@ -0,0 +1,94 @@ +// 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", "--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 { + // 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 { + 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; +}; diff --git a/tests/electron/helpers/weather-setup.js b/tests/electron/helpers/weather-setup.js new file mode 100644 index 0000000..3bddaef --- /dev/null +++ b/tests/electron/helpers/weather-setup.js @@ -0,0 +1,102 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const weatherUtils = require("../../../defaultmodules/weather/provider-utils"); +const helpers = require("./global-setup"); + +/** + * Inject mock weather data directly via socket communication + * This bypasses the weather provider and tests only client-side rendering + * @param {string} mockDataFile - Filename of mock data in tests/mocks + */ +async function injectMockWeatherData (mockDataFile) { + const rawData = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../../mocks", mockDataFile)).toString()); + + const timezoneOffset = rawData.timezone_offset ? rawData.timezone_offset / 60 : 0; + + let type = "current"; + let data = null; + + if (rawData.current) { + type = "current"; + data = { + date: weatherUtils.applyTimezoneOffset(new Date(rawData.current.dt * 1000), timezoneOffset), + windSpeed: rawData.current.wind_speed, + windFromDirection: rawData.current.wind_deg, + sunrise: weatherUtils.applyTimezoneOffset(new Date(rawData.current.sunrise * 1000), timezoneOffset), + sunset: weatherUtils.applyTimezoneOffset(new Date(rawData.current.sunset * 1000), timezoneOffset), + temperature: rawData.current.temp, + weatherType: weatherUtils.convertWeatherType(rawData.current.weather[0].icon), + humidity: rawData.current.humidity, + feelsLikeTemp: rawData.current.feels_like + }; + } else if (rawData.daily) { + type = "forecast"; + data = rawData.daily.map((day) => ({ + date: weatherUtils.applyTimezoneOffset(new Date(day.dt * 1000), timezoneOffset), + minTemperature: day.temp.min, + maxTemperature: day.temp.max, + weatherType: weatherUtils.convertWeatherType(day.weather[0].icon), + rain: day.rain || 0, + snow: day.snow || 0, + precipitationAmount: (day.rain || 0) + (day.snow || 0) + })); + } else if (rawData.hourly) { + type = "hourly"; + data = rawData.hourly.map((hour) => ({ + date: weatherUtils.applyTimezoneOffset(new Date(hour.dt * 1000), timezoneOffset), + temperature: hour.temp, + feelsLikeTemp: hour.feels_like, + humidity: hour.humidity, + windSpeed: hour.wind_speed, + windFromDirection: hour.wind_deg, + weatherType: weatherUtils.convertWeatherType(hour.weather[0].icon), + precipitationProbability: hour.pop != null ? hour.pop * 100 : undefined, + precipitationAmount: (hour.rain?.["1h"] || 0) + (hour.snow?.["1h"] || 0) + })); + } + + // Inject weather data by evaluating code in the browser context + await global.page.evaluate(({ type, data }) => { + const weatherModule = MM.getModules().find((m) => m.name === "weather"); + if (weatherModule) { + weatherModule.socketNotificationReceived("WEATHER_INITIALIZED", { + instanceId: weatherModule.instanceId, + locationName: "Munich" + }); + weatherModule.socketNotificationReceived("WEATHER_DATA", { + instanceId: weatherModule.instanceId, + type: type, + data: data + }); + } + }, { type, data }); +} + +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, mockDataFile = "weather_onecall_current.json") => { + await helpers.startApplication(configFileName, systemDate); + + // Wait for weather module to be present in DOM + await global.page.waitForSelector(".weather", { timeout: 5000 }); + + // Inject mock weather data + await injectMockWeatherData(mockDataFile); + + // Wait for weather content to be rendered + await global.page.waitForFunction(() => { + const weatherRoot = document.querySelector(".weather"); + return !!(weatherRoot && weatherRoot.textContent && weatherRoot.textContent.trim().length > 0); + }, { timeout: 5000 }); +}; diff --git a/tests/electron/modules/calendar_spec.js b/tests/electron/modules/calendar_spec.js new file mode 100644 index 0000000..655e7e5 --- /dev/null +++ b/tests/electron/modules/calendar_spec.js @@ -0,0 +1,416 @@ +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(); + }; + + 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; + }; + + const defaultCalendarNow = "08 Oct 2024 12:30:00 GMT-07:00"; + const defaultCalendarTimeZone = "America/Chicago"; + const showEndConfigPath = "tests/configs/modules/calendar/calendarShowEndConfigs.js"; + + const startCalendarShowEndScenario = async (scenario, now = defaultCalendarNow, timeZone = defaultCalendarTimeZone) => { + process.env.MM_CALENDAR_SHOWEND_SCENARIO = scenario; + await helpers.startApplication(showEndConfigPath, now, [], timeZone); + }; + + const expectFirstEventTimeCell = async ({ scenario, expectedTime, now = defaultCalendarNow, timeZone = defaultCalendarTimeZone }) => { + await startCalendarShowEndScenario(scenario, now, timeZone); + await expect(doTestTableContent(".calendar .event", ".time", expectedTime, first)).resolves.toBe(true); + }; + + const getFirstEventTimeText = async () => { + const timeCell = global.page.locator(".calendar .event .time").locator(`nth=${first}`); + await timeCell.waitFor({ state: "visible" }); + return (await timeCell.textContent()) || ""; + }; + + afterEach(async () => { + await helpers.stopApplication(); + delete process.env.MM_CALENDAR_SHOWEND_SCENARIO; + }); + + 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 startCalendarShowEndScenario("event_with_time_over_multiple_days_non_repeating_no_display_end"); + // 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 startCalendarShowEndScenario("event_with_time_over_multiple_days_non_repeating_display_end"); + // just + await expect(doTestTableContent(".calendar .event", ".time", "25th.Oct, 20:00-26th.Oct, 06:00", first)).resolves.toBe(true); + }); + }); + + describe("showEnd for timed multi-day events", () => { + const timedMultiDayCases = [ + { + name: "relative timeFormat shows start and end for timed multi-day events", + scenario: "event_with_time_over_multiple_days_non_repeating_display_end_relative", + expectedTime: "25th.Oct, 20:00-26th.Oct, 06:00" + }, + { + name: "dateheaders timeFormat shows end for timed multi-day events", + scenario: "event_with_time_over_multiple_days_non_repeating_display_end_dateheaders", + expectedTime: "20:00-06:00" + } + ]; + + it.each(timedMultiDayCases)("$name", async (testCase) => { + expect.hasAssertions(); + await expectFirstEventTimeCell(testCase); + }); + }); + + describe("showEnd for timed same-day events", () => { + const timedSameDaySimpleCases = [ + { + name: "absolute timeFormat shows start and end time without repeating date", + scenario: "event_with_time_same_day_yearly_display_end_absolute", + expectedTime: "25th.Oct, 20:00-22:00" + }, + { + name: "absolute timeFormat with time in dateFormat does not duplicate start time", + scenario: "event_with_time_same_day_yearly_display_end_absolute_dateformat_with_time", + expectedTime: "25th.Oct, 20:00-22:00" + }, + { + name: "relative timeFormat shows start and end time without repeating date", + scenario: "event_with_time_same_day_yearly_display_end_relative", + expectedTime: "25th.Oct, 20:00-22:00" + }, + { + name: "dateheaders timeFormat shows start and end time only", + scenario: "event_with_time_same_day_yearly_display_end_dateheaders", + expectedTime: "20:00-22:00" + } + ]; + + it.each(timedSameDaySimpleCases)("$name", async (testCase) => { + expect.hasAssertions(); + await expectFirstEventTimeCell(testCase); + }); + + it("absolute timeFormat with dateFormat LLL does not duplicate start time", async () => { + await startCalendarShowEndScenario("event_with_time_same_day_yearly_display_end_absolute_dateformat_lll"); + const timeText = await getFirstEventTimeText(); + const timeTokens = timeText.match(/\d{1,2}:\d{2}(?:\s?[AP]M)?/gi) || []; + expect(timeTokens).toHaveLength(2); + expect(timeText).toContain("-"); + }); + + it("relative timeFormat with hideTime does not show start or end times", async () => { + await startCalendarShowEndScenario("event_with_time_same_day_yearly_display_end_relative_hide_time"); + const timeText = await getFirstEventTimeText(); + expect(timeText).toContain("25th.Oct"); + expect(timeText.match(/\d{1,2}:\d{2}(?:\s?[AP]M)?/gi) || []).toHaveLength(0); + }); + }); + + 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); + }); + }); + + describe("showEnd for multi-day full-day events", () => { + const fullDayShowEndCases = [ + { + name: "relative timeFormat shows start and end date", + scenario: "fullday_multiday_showend_relative", + expectedTime: "25th.Oct-30th.Oct" + }, + { + name: "dateheaders timeFormat shows end date in time cell", + scenario: "fullday_multiday_showend_dateheaders", + expectedTime: "-30th.Oct" + }, + { + name: "absolute timeFormat with nextDaysRelative shows relative label and end date", + scenario: "fullday_multiday_showend_nextdaysrelative", + expectedTime: "Tomorrow-30th.Oct", + now: "24 Oct 2024 12:30:00 GMT-07:00" + } + ]; + + it.each(fullDayShowEndCases)("$name", async (testCase) => { + expect.hasAssertions(); + await expectFirstEventTimeCell(testCase); + }); + }); +}); diff --git a/tests/electron/modules/compliments_spec.js b/tests/electron/modules/compliments_spec.js new file mode 100644 index 0000000..8745b25 --- /dev/null +++ b/tests/electron/modules/compliments_spec.js @@ -0,0 +1,105 @@ +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); + }); + }); + }); +}); diff --git a/tests/electron/modules/weather_spec.js b/tests/electron/modules/weather_spec.js new file mode 100644 index 0000000..e300da9 --- /dev/null +++ b/tests/electron/modules/weather_spec.js @@ -0,0 +1,37 @@ +const helpers = require("../helpers/global-setup"); +const weatherHelper = require("../helpers/weather-setup"); + +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(); + }); + + 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); + }); + }); +}); diff --git a/tests/mocks/12_events.ics b/tests/mocks/12_events.ics new file mode 100644 index 0000000..f89c938 --- /dev/null +++ b/tests/mocks/12_events.ics @@ -0,0 +1,164 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Calendar Labs//Calendar 1.0//EN +CALSCALE:GREGORIAN +METHOD:PUBLISH +X-WR-CALNAME:US Holidays +X-WR-TIMEZONE:Etc/GMT +BEGIN:VEVENT +SUMMARY:Start of Month 1 +DTSTART:20190101 +DTEND:20190101 +RRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1 +LOCATION:United States +DESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \n\n Like us on Facebook: http://fb.com/calendarlabs to get updates +UID:5e52949sada28d231582470298@calendarlabs.com +DTSTAMP:20200223T150458Z +STATUS:CONFIRMED +TRANSP:TRANSPARENT +SEQUENCE:0 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Start of Month 2 +DTSTART:20190201 +DTEND:20190201 +RRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1 +LOCATION:United States +DESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \n\n Like us on Facebook: http://fb.com/calendarlabs to get updates +UID:5e52949a2wds8d231582470298@calendarlabs.com +DTSTAMP:20200223T150458Z +STATUS:CONFIRMED +TRANSP:TRANSPARENT +SEQUENCE:0 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Start of Month 3 +DTSTART:20190301 +DTEND:20190301 +RRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1 +LOCATION:United States +DESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \n\n Like us on Facebook: http://fb.com/calendarlabs to get updates +UID:5e52949a2SDD8d231582470298@calendarlabs.com +DTSTAMP:20200223T150458Z +STATUS:CONFIRMED +TRANSP:TRANSPARENT +SEQUENCE:0 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Start of Month 4 +DTSTART:20190401 +DTEND:20190401 +RRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1 +LOCATION:United States +DESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \n\n Like us on Facebook: http://fb.com/calendarlabs to get updates +UID:5e52949a2SDD8d231582FDSFD470298@calendarlabs.com +DTSTAMP:20200223T150458Z +STATUS:CONFIRMED +TRANSP:TRANSPARENT +SEQUENCE:0 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Start of Month 5 +DTSTART:20190501 +DTEND:20190501 +RRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1 +LOCATION:United States +DESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \n\n Like us on Facebook: http://fb.com/calendarlabs to get updates +UID:5e52949a2SDD8d2DD315824702598@calendarlabs.com +DTSTAMP:20200223T150458Z +STATUS:CONFIRMED +TRANSP:TRANSPARENT +SEQUENCE:0 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Start of Month 6 +DTSTART:20190601 +DTEND:20190601 +RRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1 +LOCATION:United States +DESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \n\n Like us on Facebook: http://fb.com/calendarlabs to get updates +UID:5e52949a2SDD8d2DD31582470298@calendarlabs.com +DTSTAMP:20200223T150458Z +STATUS:CONFIRMED +TRANSP:TRANSPARENT +SEQUENCE:0 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Start of Month 7 +DTSTART:20190701 +DTEND:20190701 +RRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1 +LOCATION:United States +DESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \n\n Like us on Facebook: http://fb.com/calendarlabs to get updates +UID:5e52942SDD8d2DD31582470298@calendarlabs.com +DTSTAMP:20200223T150458Z +STATUS:CONFIRMED +TRANSP:TRANSPARENT +SEQUENCE:0 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Start of Month 8 +DTSTART:20190801 +DTEND:20190801 +RRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1 +LOCATION:United States +DESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \n\n Like us on Facebook: http://fb.com/calendarlabs to get updates +UID:5e52949a2SDD8d2DDt31582470298@calendarlabs.com +DTSTAMP:20200223T150458Z +STATUS:CONFIRMED +TRANSP:TRANSPARENT +SEQUENCE:0 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Start of Month 9 +DTSTART:20190901 +DTEND:20190901 +RRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1 +LOCATION:United States +DESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \n\n Like us on Facebook: http://fb.com/calendarlabs to get updates +UID:5e529449a2SDD8d2DDt315824702798@calendarlabs.com +DTSTAMP:20200223T150458Z +STATUS:CONFIRMED +TRANSP:TRANSPARENT +SEQUENCE:0 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Start of Month 10 +DTSTART:20191001 +DTEND:20191001 +RRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1 +LOCATION:United States +DESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \n\n Like us on Facebook: http://fb.com/calendarlabs to get updates +UID:5e529449a2SDD8d2DDt31582470298@calendarlabs.com +DTSTAMP:20200223T150458Z +STATUS:CONFIRMED +TRANSP:TRANSPARENT +SEQUENCE:0 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Start of Month 11 +DTSTART:20191101 +DTEND:20191101 +RRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1 +LOCATION:United States +DESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \n\n Like us on Facebook: http://fb.com/calendarlabs to get updates +UID:5e5294449a2SDD8d2DDt31582470298@calendarlabs.com +DTSTAMP:20200223T150458Z +STATUS:CONFIRMED +TRANSP:TRANSPARENT +SEQUENCE:0 +END:VEVENT +BEGIN:VEVENT +SUMMARY:Start of Month 12 +DTSTART:20191201 +DTEND:20191201 +RRULE:FREQ=YEARLY;WKST=SU;INTERVAL=1 +LOCATION:United States +DESCRIPTION:Visit https://calendarlabs.com/holidays/us/new-years-day.php to know more about New Year's Day. \n\n Like us on Facebook: http://fb.com/calendarlabs to get updates +UID:5e5294a2SDD8d2DDt31582470298@calendarlabs.com +DTSTAMP:20200223T150458Z +STATUS:CONFIRMED +TRANSP:TRANSPARENT +SEQUENCE:0 +END:VEVENT +END:VCALENDAR diff --git a/tests/mocks/3_move_first_allday_repeating_event.ics b/tests/mocks/3_move_first_allday_repeating_event.ics new file mode 100644 index 0000000..d0587e3 --- /dev/null +++ b/tests/mocks/3_move_first_allday_repeating_event.ics @@ -0,0 +1,35 @@ +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 diff --git a/tests/mocks/RepeatingEvent.Oct21.ics b/tests/mocks/RepeatingEvent.Oct21.ics new file mode 100644 index 0000000..9eb6130 --- /dev/null +++ b/tests/mocks/RepeatingEvent.Oct21.ics @@ -0,0 +1,28 @@ +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 diff --git a/tests/mocks/bad_rrule.ics b/tests/mocks/bad_rrule.ics new file mode 100644 index 0000000..2515fde --- /dev/null +++ b/tests/mocks/bad_rrule.ics @@ -0,0 +1,20 @@ +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 \ No newline at end of file diff --git a/tests/mocks/calendar_duplicates_1.ics b/tests/mocks/calendar_duplicates_1.ics new file mode 100644 index 0000000..5d1bfa5 --- /dev/null +++ b/tests/mocks/calendar_duplicates_1.ics @@ -0,0 +1,87 @@ +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 diff --git a/tests/mocks/calendar_duplicates_2.ics b/tests/mocks/calendar_duplicates_2.ics new file mode 100644 index 0000000..241196b --- /dev/null +++ b/tests/mocks/calendar_duplicates_2.ics @@ -0,0 +1,87 @@ +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 diff --git a/tests/mocks/calendar_test.ics b/tests/mocks/calendar_test.ics new file mode 100644 index 0000000..1b52771 --- /dev/null +++ b/tests/mocks/calendar_test.ics @@ -0,0 +1,190 @@ +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 diff --git a/tests/mocks/calendar_test_clone.ics b/tests/mocks/calendar_test_clone.ics new file mode 100644 index 0000000..1b52771 --- /dev/null +++ b/tests/mocks/calendar_test_clone.ics @@ -0,0 +1,190 @@ +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 diff --git a/tests/mocks/calendar_test_full_day_events.ics b/tests/mocks/calendar_test_full_day_events.ics new file mode 100644 index 0000000..a3c4871 --- /dev/null +++ b/tests/mocks/calendar_test_full_day_events.ics @@ -0,0 +1,33 @@ +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 diff --git a/tests/mocks/calendar_test_icons.ics b/tests/mocks/calendar_test_icons.ics new file mode 100644 index 0000000..abfcb78 --- /dev/null +++ b/tests/mocks/calendar_test_icons.ics @@ -0,0 +1,63 @@ +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 diff --git a/tests/mocks/calendar_test_multi_day_starting_today.ics b/tests/mocks/calendar_test_multi_day_starting_today.ics new file mode 100644 index 0000000..7a91511 --- /dev/null +++ b/tests/mocks/calendar_test_multi_day_starting_today.ics @@ -0,0 +1,33 @@ +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 diff --git a/tests/mocks/calendar_test_recurring.ics b/tests/mocks/calendar_test_recurring.ics new file mode 100644 index 0000000..d749a73 --- /dev/null +++ b/tests/mocks/calendar_test_recurring.ics @@ -0,0 +1,37 @@ +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 diff --git a/tests/mocks/chicago-nyedge.ics b/tests/mocks/chicago-nyedge.ics new file mode 100644 index 0000000..641ff02 --- /dev/null +++ b/tests/mocks/chicago-nyedge.ics @@ -0,0 +1,15 @@ +BEGIN:VEVENT +DTSTART;TZID=America/New_York:20240918T183000 +DTEND;TZID=America/New_York:20240918T203000 +RRULE:FREQ=WEEKLY;BYDAY=WE +EXDATE;TZID=America/New_York:20241127T183000 +EXDATE;TZID=America/New_York:20241225T183000 +DTSTAMP:20250122T045443Z +UID:_@google.com +CREATED:20240916T131843Z +LAST-MODIFIED:20241222T235014Z +SEQUENCE:0 +STATUS:CONFIRMED +SUMMARY:Derby +TRANSP:OPAQUE +END:VEVENT \ No newline at end of file diff --git a/tests/mocks/chicago_late_in_timezone.ics b/tests/mocks/chicago_late_in_timezone.ics new file mode 100644 index 0000000..2c94477 --- /dev/null +++ b/tests/mocks/chicago_late_in_timezone.ics @@ -0,0 +1,15 @@ +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 \ No newline at end of file diff --git a/tests/mocks/compliments_file.json b/tests/mocks/compliments_file.json new file mode 100644 index 0000000..03e2e8b --- /dev/null +++ b/tests/mocks/compliments_file.json @@ -0,0 +1,3 @@ +{ + "anytime": ["test in morning"] +} diff --git a/tests/mocks/compliments_test.json b/tests/mocks/compliments_test.json new file mode 100644 index 0000000..fa1d0b9 --- /dev/null +++ b/tests/mocks/compliments_test.json @@ -0,0 +1,3 @@ +{ + "anytime": ["Remote compliment file works!"] +} diff --git a/tests/mocks/diff_tz_start_end.ics b/tests/mocks/diff_tz_start_end.ics new file mode 100644 index 0000000..b59ba41 --- /dev/null +++ b/tests/mocks/diff_tz_start_end.ics @@ -0,0 +1,14 @@ +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 diff --git a/tests/mocks/end_of_day_berlin_moved.ics b/tests/mocks/end_of_day_berlin_moved.ics new file mode 100644 index 0000000..c5a02d0 --- /dev/null +++ b/tests/mocks/end_of_day_berlin_moved.ics @@ -0,0 +1,54 @@ +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 + diff --git a/tests/mocks/event_with_time_over_multiple_days_non_repeating.ics b/tests/mocks/event_with_time_over_multiple_days_non_repeating.ics new file mode 100644 index 0000000..a12d58d --- /dev/null +++ b/tests/mocks/event_with_time_over_multiple_days_non_repeating.ics @@ -0,0 +1,14 @@ +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 diff --git a/tests/mocks/event_with_time_over_multiple_days_yearly.ics b/tests/mocks/event_with_time_over_multiple_days_yearly.ics new file mode 100644 index 0000000..00f65b2 --- /dev/null +++ b/tests/mocks/event_with_time_over_multiple_days_yearly.ics @@ -0,0 +1,18 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//MagicMirror Test//timed-multiday-yearly//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTART:20241026T010000Z +DTEND:20241026T110000Z +DTSTAMP:20241024T153358Z +UID:4maud6s79m41a99pj2g7j5km0a@google.com +CREATED:20241024T153313Z +LAST-MODIFIED:20241024T153330Z +SEQUENCE:0 +STATUS:CONFIRMED +RRULE:FREQ=YEARLY +SUMMARY:Sleep over at Bobs +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR diff --git a/tests/mocks/event_with_time_same_day_yearly.ics b/tests/mocks/event_with_time_same_day_yearly.ics new file mode 100644 index 0000000..4e0bb85 --- /dev/null +++ b/tests/mocks/event_with_time_same_day_yearly.ics @@ -0,0 +1,18 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//MagicMirror Test//timed-same-day-yearly//EN +CALSCALE:GREGORIAN +BEGIN:VEVENT +DTSTART:20241025T200000 +DTEND:20241025T220000 +DTSTAMP:20241024T153358Z +UID:timed-same-day-yearly@magicmirror +CREATED:20241024T153313Z +LAST-MODIFIED:20241024T153330Z +SEQUENCE:0 +STATUS:CONFIRMED +RRULE:FREQ=YEARLY +SUMMARY:Same day timed event +TRANSP:OPAQUE +END:VEVENT +END:VCALENDAR diff --git a/tests/mocks/exdate_and_recurrence_together.ics b/tests/mocks/exdate_and_recurrence_together.ics new file mode 100644 index 0000000..d366cc4 --- /dev/null +++ b/tests/mocks/exdate_and_recurrence_together.ics @@ -0,0 +1,48 @@ +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 diff --git a/tests/mocks/exdate_la_at_midnight_dst.ics b/tests/mocks/exdate_la_at_midnight_dst.ics new file mode 100644 index 0000000..6f8575f --- /dev/null +++ b/tests/mocks/exdate_la_at_midnight_dst.ics @@ -0,0 +1,15 @@ +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 \ No newline at end of file diff --git a/tests/mocks/exdate_la_at_midnight_std.ics b/tests/mocks/exdate_la_at_midnight_std.ics new file mode 100644 index 0000000..553ca10 --- /dev/null +++ b/tests/mocks/exdate_la_at_midnight_std.ics @@ -0,0 +1,15 @@ +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 \ No newline at end of file diff --git a/tests/mocks/exdate_la_before_midnight.ics b/tests/mocks/exdate_la_before_midnight.ics new file mode 100644 index 0000000..5cdb069 --- /dev/null +++ b/tests/mocks/exdate_la_before_midnight.ics @@ -0,0 +1,15 @@ +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 \ No newline at end of file diff --git a/tests/mocks/exdate_syd_at_midnight_dst.ics b/tests/mocks/exdate_syd_at_midnight_dst.ics new file mode 100644 index 0000000..9a88a10 --- /dev/null +++ b/tests/mocks/exdate_syd_at_midnight_dst.ics @@ -0,0 +1,15 @@ +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 \ No newline at end of file diff --git a/tests/mocks/exdate_syd_at_midnight_std.ics b/tests/mocks/exdate_syd_at_midnight_std.ics new file mode 100644 index 0000000..af444e8 --- /dev/null +++ b/tests/mocks/exdate_syd_at_midnight_std.ics @@ -0,0 +1,15 @@ +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 \ No newline at end of file diff --git a/tests/mocks/exdate_syd_before_midnight.ics b/tests/mocks/exdate_syd_before_midnight.ics new file mode 100644 index 0000000..5b5e53d --- /dev/null +++ b/tests/mocks/exdate_syd_before_midnight.ics @@ -0,0 +1,15 @@ +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 \ No newline at end of file diff --git a/tests/mocks/fullday_event_over_multiple_days_nonrepeating.ics b/tests/mocks/fullday_event_over_multiple_days_nonrepeating.ics new file mode 100644 index 0000000..8d506e5 --- /dev/null +++ b/tests/mocks/fullday_event_over_multiple_days_nonrepeating.ics @@ -0,0 +1,15 @@ +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 diff --git a/tests/mocks/fullday_until.ics b/tests/mocks/fullday_until.ics new file mode 100644 index 0000000..f0edcc1 --- /dev/null +++ b/tests/mocks/fullday_until.ics @@ -0,0 +1,28 @@ +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 diff --git a/tests/mocks/germany_at_end_of_day_repeating.ics b/tests/mocks/germany_at_end_of_day_repeating.ics new file mode 100644 index 0000000..9a72df0 --- /dev/null +++ b/tests/mocks/germany_at_end_of_day_repeating.ics @@ -0,0 +1,15 @@ +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 \ No newline at end of file diff --git a/tests/mocks/newsfeed_basic_html.xml b/tests/mocks/newsfeed_basic_html.xml new file mode 100644 index 0000000..3425648 --- /dev/null +++ b/tests/mocks/newsfeed_basic_html.xml @@ -0,0 +1,15 @@ + + + + Formatting Feed + http://localhost:8080 + Feed used to test the allowBasicHtmlTags option. + + News <em>Flash</em> + http://localhost:8080/article + Tue, 20 Sep 2016 11:16:08 +0000 + http://localhost:8080/?p=1 + Italic and Bold and Underlined text.

]]>
+
+
+
diff --git a/tests/mocks/newsfeed_test.xml b/tests/mocks/newsfeed_test.xml new file mode 100644 index 0000000..b781a8a --- /dev/null +++ b/tests/mocks/newsfeed_test.xml @@ -0,0 +1,410 @@ + + + + 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 +
+
+
diff --git a/tests/mocks/rrule_until.ics b/tests/mocks/rrule_until.ics new file mode 100644 index 0000000..cdb34e2 --- /dev/null +++ b/tests/mocks/rrule_until.ics @@ -0,0 +1,24 @@ +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 \ No newline at end of file diff --git a/tests/mocks/sliceMultiDayEvents.ics b/tests/mocks/sliceMultiDayEvents.ics new file mode 100644 index 0000000..d477246 --- /dev/null +++ b/tests/mocks/sliceMultiDayEvents.ics @@ -0,0 +1,58 @@ +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 \ No newline at end of file diff --git a/tests/mocks/testNotification/testNotification.js b/tests/mocks/testNotification/testNotification.js new file mode 100644 index 0000000..a18cc02 --- /dev/null +++ b/tests/mocks/testNotification/testNotification.js @@ -0,0 +1,59 @@ +Module.register("testNotification", { + defaults: { + debug: false, + match: { + notificationID: "", + matchtype: "count" + //or + // type: 'contents' // look for item in field of content + } + }, + count: 0, + table: null, + notificationReceived (notification, payload) { + if (notification === this.config.match.notificationID) { + if (this.config.match.matchtype === "count") { + this.count = payload.length; + if (this.count) { + this.table = document.createElement("table"); + this.addTableRow(this.table, null, `${this.count}:elementCount`); + if (this.config.debug) { + payload.forEach((e, i) => { + this.addTableRow(this.table, i, e.title); + }); + } + } + + this.updateDom(); + } + } + }, + maketd (row, info) { + let td = document.createElement("td"); + row.appendChild(td); + if (info !== null) { + let colinfo = info.toString().split(":"); + if (colinfo.length === 2) td.className = colinfo[1]; + td.innerText = colinfo[0]; + } + return td; + }, + addTableRow (table, col1 = null, col2 = null, col3 = null) { + let tableRow = document.createElement("tr"); + table.appendChild(tableRow); + + this.maketd(tableRow, col1); + this.maketd(tableRow, col2); + this.maketd(tableRow, col3); + + return tableRow; + }, + getDom () { + let wrapper = document.createElement("div"); + if (this.table) { + wrapper.appendChild(this.table); + } + return wrapper; + } + +}); diff --git a/tests/mocks/translation_test.json b/tests/mocks/translation_test.json new file mode 100644 index 0000000..dd7b02f --- /dev/null +++ b/tests/mocks/translation_test.json @@ -0,0 +1,33 @@ +{ + "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." +} diff --git a/tests/mocks/weather_current.json b/tests/mocks/weather_current.json new file mode 100644 index 0000000..8d46c97 --- /dev/null +++ b/tests/mocks/weather_current.json @@ -0,0 +1,48 @@ +{ + "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 +} diff --git a/tests/mocks/weather_envcanada.xml b/tests/mocks/weather_envcanada.xml new file mode 100644 index 0000000..ac8d6fc --- /dev/null +++ b/tests/mocks/weather_envcanada.xml @@ -0,0 +1,871 @@ + + + https://dd.weather.gc.ca/doc/LICENCE_GENERAL.txt + + 2026 + 02 + 07 + 12 + 04 + 20260207120421 + Saturday February 07, 2026 at 12:04 UTC + + + 2026 + 02 + 07 + 07 + 04 + 20260207070421 + Saturday February 07, 2026 at 07:04 EST + + + North America + Canada + Ontario + Toronto + City of Toronto + + + + + 2026 + 02 + 07 + 09 + 06 + 20260207090653 + Saturday February 07, 2026 at 09:06 UTC + + + 2026 + 02 + 07 + 04 + 06 + 20260207040653 + Saturday February 07, 2026 at 04:06 EST + + + + + Toronto Pearson Int'l Airport + + 2026 + 02 + 07 + 12 + 00 + 20260207120000 + Saturday February 07, 2026 at 12:00 UTC + + + 2026 + 02 + 07 + 07 + 00 + 20260207070000 + Saturday February 07, 2026 at 07:00 EST + + Blowing Snow + 40 + -20.3 + -24.9 + -31 + 102.1 + 9.7 + 67 + + 19 + 33 + NNW + 346.0 + + + + + 2026 + 02 + 07 + 10 + 00 + 20260207100000 + Saturday February 07, 2026 at 10:00 UTC + + + 2026 + 02 + 07 + 05 + 00 + 20260207050000 + Saturday February 07, 2026 at 05:00 EST + + + Low minus 9. High minus 2. + -2 + -9 + + + Saturday + A mix of sun and cloud. 40 percent chance of flurries early this morning. Wind northwest 30 km/h gusting to 50. High minus 13. Wind chill minus 33 this morning and minus 22 this afternoon. Risk of frostbite. UV index 1 or low. + + A mix of sun and cloud. 40 percent chance of flurries early this morning. + + + 08 + 40 + Chance of flurries + + + High minus 13. + -13 + + + Wind northwest 30 km/h gusting to 50. + + 30 + 50 + NW + 32 + + + + + snow + + + Wind chill minus 33 this morning and minus 22 this afternoon. Risk of frostbite. + -33 + -22 + Risk of frostbite + + + 1 + UV index 1 or low. + + 40 + + + + Saturday night + Partly cloudy. Clearing late this evening. Wind northwest 20 km/h becoming light late this evening. Low minus 21. Wind chill minus 22 this evening and minus 28 overnight. Risk of frostbite. + + Partly cloudy. Clearing late this evening. + + + 35 + + Clearing + + + Low minus 21. + -21 + + + Wind northwest 20 km/h becoming light late this evening. + + 20 + 00 + NW + 32 + + + 10 + 00 + NW + 32 + + + + + + + + Wind chill minus 22 this evening and minus 28 overnight. Risk of frostbite. + -22 + -28 + Risk of frostbite + + 65 + + + + Sunday + Sunny. Wind up to 15 km/h. High minus 12. Wind chill minus 28 in the morning and minus 19 in the afternoon. Risk of frostbite. UV index 2 or low. + + Sunny. + + + 00 + + Sunny + + + High minus 12. + -12 + + + Wind up to 15 km/h. + + 10 + 00 + N + 36 + + + 15 + 00 + NW + 32 + + + + + + + + Wind chill minus 28 in the morning and minus 19 in the afternoon. Risk of frostbite. + -28 + -19 + Risk of frostbite + + + 2 + UV index 2 or low. + + 55 + + + + Sunday night + Cloudy periods. Low minus 14. + + Cloudy periods. + + + 32 + + Cloudy periods + + + Low minus 14. + -14 + + + + + + + + 60 + + + + Monday + Cloudy with 40 percent chance of flurries. High minus 6. + + Cloudy with 40 percent chance of flurries. + + + 16 + 40 + Chance of flurries + + + High minus 6. + -6 + + + + + snow + + + 65 + + + + Monday night + Cloudy with 30 percent chance of flurries. Low minus 8. + + Cloudy with 30 percent chance of flurries. + + + 16 + 30 + Chance of flurries + + + Low minus 8. + -8 + + + + + snow + + + 65 + + + + Tuesday + Cloudy with 30 percent chance of flurries. High minus 2. + + Cloudy with 30 percent chance of flurries. + + + 16 + 30 + Chance of flurries + + + High minus 2. + -2 + + + + + snow + + + 75 + + + + Tuesday night + Cloudy with 40 percent chance of flurries. Low minus 3. + + Cloudy with 40 percent chance of flurries. + + + 16 + 40 + Chance of flurries + + + Low minus 3. + -3 + + + + + snow + + + 80 + + + + Wednesday + Cloudy with 30 percent chance of flurries. High zero. + + Cloudy with 30 percent chance of flurries. + + + 16 + 30 + Chance of flurries + + + High zero. + 0 + + + + + snow + + + 80 + + + + Wednesday night + Cloudy periods. Low minus 6. + + Cloudy periods. + + + 32 + + Cloudy periods + + + Low minus 6. + -6 + + + + + + + + 90 + + + + Thursday + A mix of sun and cloud. High minus 2. + + A mix of sun and cloud. + + + 02 + + A mix of sun and cloud + + + High minus 2. + -2 + + + + + + + + 75 + + + + Thursday night + Cloudy periods. Low minus 7. + + Cloudy periods. + + + 32 + + Cloudy periods + + + Low minus 7. + -7 + + + + + + + + 75 + + + + Friday + A mix of sun and cloud. High minus 1. + + A mix of sun and cloud. + + + 02 + + A mix of sun and cloud + + + High minus 1. + -1 + + + + + + + + 75 + + + + + + 2026 + 02 + 07 + 10 + 00 + 20260207100000 + Saturday February 07, 2026 at 10:00 UTC + + + 2026 + 02 + 07 + 05 + 00 + 20260207050000 + Saturday February 07, 2026 at 05:00 EST + + + A mix of sun and cloud + 02 + -20 + 10 + -32 + + + 30 + NW + 50 + + + + A mix of sun and cloud + 02 + -19 + 10 + -32 + + + 30 + NW + 50 + + + 1 + + + + A mix of sun and cloud + 02 + -19 + 10 + -31 + + + 30 + NW + 50 + + + 1 + + + + A mix of sun and cloud + 02 + -18 + 10 + -30 + + + 30 + NW + 50 + + + 1 + + + + Mainly sunny + 01 + -16 + 10 + -28 + + + 30 + NW + 50 + + + 1 + + + + A mix of sun and cloud + 02 + -15 + 10 + -26 + + + 30 + NW + 50 + + + 1 + + + + A mix of sun and cloud + 02 + -14 + 10 + -25 + + + 30 + NW + 50 + + + 1 + + + + A mix of sun and cloud + 02 + -14 + 10 + -24 + + + 30 + NW + 50 + + + + A mix of sun and cloud + 02 + -13 + 10 + -23 + + + 30 + NW + 50 + + + + A mix of sun and cloud + 02 + -13 + 10 + -24 + + + 30 + NW + 50 + + + + Partly cloudy + 32 + -14 + 10 + -22 + + + 20 + NW + + + + + Partly cloudy + 32 + -14 + 10 + -23 + + + 20 + NW + + + + + Partly cloudy + 32 + -15 + 10 + -24 + + + 20 + NW + + + + + Partly cloudy + 32 + -16 + 0 + -25 + + + 20 + NW + + + + + Partly cloudy + 32 + -17 + 0 + -24 + + + 10 + NW + + + + + A few clouds + 31 + -18 + 0 + -24 + + + 10 + NW + + + + + A few clouds + 31 + -18 + 0 + -25 + + + 10 + NW + + + + + A few clouds + 31 + -19 + 0 + -26 + + + 10 + NW + + + + + Clear + 30 + -20 + 0 + -27 + + + 10 + NW + + + + + Clear + 30 + -20 + 0 + -28 + + + 10 + NW + + + + + Clear + 30 + -21 + 0 + -28 + + + 10 + NW + + + + + Clear + 30 + -21 + 0 + -28 + + + 10 + NW + + + + + Clear + 30 + -21 + 0 + -28 + + + 10 + N + + + + + Sunny + 00 + -21 + 0 + -28 + + + 10 + N + + + + + + The information provided here, for the times of the rise and set of the sun, is an estimate included as a convenience to our clients. Values shown here may differ from the official sunrise/sunset data available from (http://hia-iha.nrc-cnrc.gc.ca/sunrise_e.html) + + 2026 + 02 + 07 + 12 + 27 + 20260207122700 + Saturday February 07, 2026 at 12:27 UTC + + + 2026 + 02 + 07 + 07 + 27 + 20260207072700 + Saturday February 07, 2026 at 07:27 EST + + + 2026 + 02 + 07 + 22 + 37 + 20260207223700 + Saturday February 07, 2026 at 22:37 UTC + + + 2026 + 02 + 07 + 17 + 37 + 20260207173700 + Saturday February 07, 2026 at 17:37 EST + + + \ No newline at end of file diff --git a/tests/mocks/weather_envcanada_index.html b/tests/mocks/weather_envcanada_index.html new file mode 100644 index 0000000..93d5a5d --- /dev/null +++ b/tests/mocks/weather_envcanada_index.html @@ -0,0 +1,427 @@ + + + + Index of /today/citypage_weather/ON/12 + + +

Index of /today/citypage_weather/ON/12

+
Icon  Name                                                     Last modified      Size  Description
[PARENTDIR] Parent Directory - +[   ] 20260207T120044.778Z_MSC_CitypageWeather_s0000024_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120044.827Z_MSC_CitypageWeather_s0000024_fr.xml 2026-02-07 12:00 36K +[   ] 20260207T120044.875Z_MSC_CitypageWeather_s0000022_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120044.919Z_MSC_CitypageWeather_s0000022_fr.xml 2026-02-07 12:00 37K +[   ] 20260207T120045.458Z_MSC_CitypageWeather_s0000588_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120045.502Z_MSC_CitypageWeather_s0000588_fr.xml 2026-02-07 12:00 36K +[   ] 20260207T120047.636Z_MSC_CitypageWeather_s0000546_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120047.636Z_MSC_CitypageWeather_s0000819_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120047.715Z_MSC_CitypageWeather_s0000546_fr.xml 2026-02-07 12:00 37K +[   ] 20260207T120047.715Z_MSC_CitypageWeather_s0000819_fr.xml 2026-02-07 12:00 37K +[   ] 20260207T120047.911Z_MSC_CitypageWeather_s0000646_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120047.960Z_MSC_CitypageWeather_s0000646_fr.xml 2026-02-07 12:00 37K +[   ] 20260207T120048.388Z_MSC_CitypageWeather_s0000512_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120048.388Z_MSC_CitypageWeather_s0000513_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120048.388Z_MSC_CitypageWeather_s0000790_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120048.526Z_MSC_CitypageWeather_s0000512_fr.xml 2026-02-07 12:00 36K +[   ] 20260207T120048.526Z_MSC_CitypageWeather_s0000513_fr.xml 2026-02-07 12:00 37K +[   ] 20260207T120048.526Z_MSC_CitypageWeather_s0000790_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120048.664Z_MSC_CitypageWeather_s0000765_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120048.664Z_MSC_CitypageWeather_s0000766_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120048.752Z_MSC_CitypageWeather_s0000765_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120048.752Z_MSC_CitypageWeather_s0000766_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120048.945Z_MSC_CitypageWeather_s0000782_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120048.994Z_MSC_CitypageWeather_s0000782_fr.xml 2026-02-07 12:00 37K +[   ] 20260207T120049.038Z_MSC_CitypageWeather_s0000585_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120049.038Z_MSC_CitypageWeather_s0000785_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120049.127Z_MSC_CitypageWeather_s0000585_fr.xml 2026-02-07 12:00 37K +[   ] 20260207T120049.127Z_MSC_CitypageWeather_s0000785_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120049.536Z_MSC_CitypageWeather_s0000411_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120049.536Z_MSC_CitypageWeather_s0000659_en.xml 2026-02-07 12:00 36K +[   ] 20260207T120049.536Z_MSC_CitypageWeather_s0000660_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120049.653Z_MSC_CitypageWeather_s0000411_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120049.653Z_MSC_CitypageWeather_s0000659_fr.xml 2026-02-07 12:00 37K +[   ] 20260207T120049.653Z_MSC_CitypageWeather_s0000660_fr.xml 2026-02-07 12:00 38K +[   ] 20260207T120052.583Z_MSC_CitypageWeather_s0000080_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120052.583Z_MSC_CitypageWeather_s0000454_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120052.665Z_MSC_CitypageWeather_s0000080_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120052.665Z_MSC_CitypageWeather_s0000454_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120055.400Z_MSC_CitypageWeather_s0000596_en.xml 2026-02-07 12:01 35K +[   ] 20260207T120055.400Z_MSC_CitypageWeather_s0000597_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120055.471Z_MSC_CitypageWeather_s0000596_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120055.471Z_MSC_CitypageWeather_s0000597_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120117.907Z_MSC_CitypageWeather_s0000744_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120117.907Z_MSC_CitypageWeather_s0000796_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120118.045Z_MSC_CitypageWeather_s0000744_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120118.045Z_MSC_CitypageWeather_s0000796_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120118.904Z_MSC_CitypageWeather_s0000572_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120118.904Z_MSC_CitypageWeather_s0000573_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120119.006Z_MSC_CitypageWeather_s0000572_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120119.006Z_MSC_CitypageWeather_s0000573_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120119.181Z_MSC_CitypageWeather_s0000765_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120119.181Z_MSC_CitypageWeather_s0000766_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120119.228Z_MSC_CitypageWeather_s0000765_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120119.228Z_MSC_CitypageWeather_s0000766_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120119.276Z_MSC_CitypageWeather_s0000395_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120119.276Z_MSC_CitypageWeather_s0000520_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120119.351Z_MSC_CitypageWeather_s0000395_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120119.351Z_MSC_CitypageWeather_s0000520_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120119.429Z_MSC_CitypageWeather_s0000724_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120119.429Z_MSC_CitypageWeather_s0000725_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120119.429Z_MSC_CitypageWeather_s0000726_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120119.429Z_MSC_CitypageWeather_s0000727_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120119.429Z_MSC_CitypageWeather_s0000728_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120119.665Z_MSC_CitypageWeather_s0000724_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120119.665Z_MSC_CitypageWeather_s0000725_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120119.665Z_MSC_CitypageWeather_s0000726_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120119.665Z_MSC_CitypageWeather_s0000727_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120119.665Z_MSC_CitypageWeather_s0000728_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120120.713Z_MSC_CitypageWeather_s0000752_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120120.713Z_MSC_CitypageWeather_s0000753_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120120.815Z_MSC_CitypageWeather_s0000752_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120120.815Z_MSC_CitypageWeather_s0000753_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120121.465Z_MSC_CitypageWeather_s0000538_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120121.465Z_MSC_CitypageWeather_s0000539_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120121.544Z_MSC_CitypageWeather_s0000538_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120121.544Z_MSC_CitypageWeather_s0000539_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120121.909Z_MSC_CitypageWeather_s0000637_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120121.909Z_MSC_CitypageWeather_s0000638_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120121.909Z_MSC_CitypageWeather_s0000639_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120121.909Z_MSC_CitypageWeather_s0000640_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120121.909Z_MSC_CitypageWeather_s0000641_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120121.909Z_MSC_CitypageWeather_s0000642_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120122.236Z_MSC_CitypageWeather_s0000637_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120122.236Z_MSC_CitypageWeather_s0000638_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120122.236Z_MSC_CitypageWeather_s0000639_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120122.236Z_MSC_CitypageWeather_s0000640_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120122.236Z_MSC_CitypageWeather_s0000641_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120122.236Z_MSC_CitypageWeather_s0000642_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120123.442Z_MSC_CitypageWeather_s0000707_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120123.442Z_MSC_CitypageWeather_s0000708_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120123.442Z_MSC_CitypageWeather_s0000710_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120123.575Z_MSC_CitypageWeather_s0000707_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120123.575Z_MSC_CitypageWeather_s0000708_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120123.575Z_MSC_CitypageWeather_s0000710_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120124.159Z_MSC_CitypageWeather_s0000628_en.xml 2026-02-07 12:01 35K +[   ] 20260207T120124.217Z_MSC_CitypageWeather_s0000628_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120125.748Z_MSC_CitypageWeather_s0000629_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120125.748Z_MSC_CitypageWeather_s0000631_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120125.748Z_MSC_CitypageWeather_s0000632_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120125.902Z_MSC_CitypageWeather_s0000629_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120125.902Z_MSC_CitypageWeather_s0000631_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120125.902Z_MSC_CitypageWeather_s0000632_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120126.136Z_MSC_CitypageWeather_s0000650_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120126.136Z_MSC_CitypageWeather_s0000651_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120126.234Z_MSC_CitypageWeather_s0000650_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120126.234Z_MSC_CitypageWeather_s0000651_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120126.402Z_MSC_CitypageWeather_s0000691_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120126.402Z_MSC_CitypageWeather_s0000692_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120126.591Z_MSC_CitypageWeather_s0000691_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120126.591Z_MSC_CitypageWeather_s0000692_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120127.205Z_MSC_CitypageWeather_s0000696_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120127.205Z_MSC_CitypageWeather_s0000697_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120127.205Z_MSC_CitypageWeather_s0000698_en.xml 2026-02-07 12:01 35K +[   ] 20260207T120127.458Z_MSC_CitypageWeather_s0000696_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120127.458Z_MSC_CitypageWeather_s0000697_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120127.458Z_MSC_CitypageWeather_s0000698_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120127.975Z_MSC_CitypageWeather_s0000374_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120128.092Z_MSC_CitypageWeather_s0000374_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120128.201Z_MSC_CitypageWeather_s0000761_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120128.201Z_MSC_CitypageWeather_s0000762_en.xml 2026-02-07 12:01 35K +[   ] 20260207T120128.201Z_MSC_CitypageWeather_s0000764_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120128.320Z_MSC_CitypageWeather_s0000761_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120128.320Z_MSC_CitypageWeather_s0000762_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120128.320Z_MSC_CitypageWeather_s0000764_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120129.744Z_MSC_CitypageWeather_s0000676_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120129.744Z_MSC_CitypageWeather_s0000677_en.xml 2026-02-07 12:01 35K +[   ] 20260207T120129.871Z_MSC_CitypageWeather_s0000676_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120129.871Z_MSC_CitypageWeather_s0000677_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120130.576Z_MSC_CitypageWeather_s0000069_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120131.204Z_MSC_CitypageWeather_s0000069_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120131.735Z_MSC_CitypageWeather_s0000691_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120131.735Z_MSC_CitypageWeather_s0000692_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120131.790Z_MSC_CitypageWeather_s0000691_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120131.790Z_MSC_CitypageWeather_s0000692_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120132.173Z_MSC_CitypageWeather_s0000232_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120132.173Z_MSC_CitypageWeather_s0000233_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120132.291Z_MSC_CitypageWeather_s0000232_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120132.291Z_MSC_CitypageWeather_s0000233_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120132.667Z_MSC_CitypageWeather_s0000325_en.xml 2026-02-07 12:01 39K +[   ] 20260207T120132.667Z_MSC_CitypageWeather_s0000326_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120132.667Z_MSC_CitypageWeather_s0000327_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120132.667Z_MSC_CitypageWeather_s0000328_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120132.667Z_MSC_CitypageWeather_s0000329_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120132.904Z_MSC_CitypageWeather_s0000325_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120132.904Z_MSC_CitypageWeather_s0000326_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120132.904Z_MSC_CitypageWeather_s0000327_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120132.904Z_MSC_CitypageWeather_s0000328_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120132.904Z_MSC_CitypageWeather_s0000329_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120133.465Z_MSC_CitypageWeather_s0000676_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120133.465Z_MSC_CitypageWeather_s0000677_en.xml 2026-02-07 12:01 35K +[   ] 20260207T120133.514Z_MSC_CitypageWeather_s0000676_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120133.514Z_MSC_CitypageWeather_s0000677_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120133.562Z_MSC_CitypageWeather_s0000761_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120133.562Z_MSC_CitypageWeather_s0000762_en.xml 2026-02-07 12:01 35K +[   ] 20260207T120133.562Z_MSC_CitypageWeather_s0000764_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120133.639Z_MSC_CitypageWeather_s0000761_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120133.639Z_MSC_CitypageWeather_s0000762_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120133.639Z_MSC_CitypageWeather_s0000764_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120136.411Z_MSC_CitypageWeather_s0000424_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120136.411Z_MSC_CitypageWeather_s0000425_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120136.411Z_MSC_CitypageWeather_s0000426_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120136.517Z_MSC_CitypageWeather_s0000424_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120136.517Z_MSC_CitypageWeather_s0000425_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120136.517Z_MSC_CitypageWeather_s0000426_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120139.492Z_MSC_CitypageWeather_s0000724_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120139.492Z_MSC_CitypageWeather_s0000725_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120139.492Z_MSC_CitypageWeather_s0000726_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120139.492Z_MSC_CitypageWeather_s0000727_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120139.492Z_MSC_CitypageWeather_s0000728_en.xml 2026-02-07 12:01 38K +[   ] 20260207T120139.618Z_MSC_CitypageWeather_s0000724_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120139.618Z_MSC_CitypageWeather_s0000725_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120139.618Z_MSC_CitypageWeather_s0000726_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120139.618Z_MSC_CitypageWeather_s0000727_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120139.618Z_MSC_CitypageWeather_s0000728_fr.xml 2026-02-07 12:01 39K +[   ] 20260207T120139.808Z_MSC_CitypageWeather_s0000826_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120139.851Z_MSC_CitypageWeather_s0000826_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120147.835Z_MSC_CitypageWeather_s0000548_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120147.835Z_MSC_CitypageWeather_s0000549_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120147.928Z_MSC_CitypageWeather_s0000548_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120147.928Z_MSC_CitypageWeather_s0000549_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120148.018Z_MSC_CitypageWeather_s0000747_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120148.018Z_MSC_CitypageWeather_s0000748_en.xml 2026-02-07 12:01 37K +[   ] 20260207T120148.089Z_MSC_CitypageWeather_s0000747_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120148.089Z_MSC_CitypageWeather_s0000748_fr.xml 2026-02-07 12:01 38K +[   ] 20260207T120148.161Z_MSC_CitypageWeather_s0000231_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120148.194Z_MSC_CitypageWeather_s0000231_fr.xml 2026-02-07 12:01 36K +[   ] 20260207T120150.484Z_MSC_CitypageWeather_s0000071_en.xml 2026-02-07 12:01 36K +[   ] 20260207T120150.649Z_MSC_CitypageWeather_s0000071_fr.xml 2026-02-07 12:01 37K +[   ] 20260207T120153.235Z_MSC_CitypageWeather_s0000023_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120153.276Z_MSC_CitypageWeather_s0000023_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120153.480Z_MSC_CitypageWeather_s0000072_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120153.480Z_MSC_CitypageWeather_s0000077_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120153.480Z_MSC_CitypageWeather_s0000434_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120153.480Z_MSC_CitypageWeather_s0000435_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120153.480Z_MSC_CitypageWeather_s0000436_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120153.708Z_MSC_CitypageWeather_s0000072_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120153.708Z_MSC_CitypageWeather_s0000077_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120153.708Z_MSC_CitypageWeather_s0000434_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120153.708Z_MSC_CitypageWeather_s0000435_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120153.708Z_MSC_CitypageWeather_s0000436_fr.xml 2026-02-07 12:02 38K +[   ] 20260207T120153.926Z_MSC_CitypageWeather_s0000437_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120153.960Z_MSC_CitypageWeather_s0000437_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120154.081Z_MSC_CitypageWeather_s0000073_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120154.081Z_MSC_CitypageWeather_s0000074_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120154.081Z_MSC_CitypageWeather_s0000075_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120154.216Z_MSC_CitypageWeather_s0000073_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120154.216Z_MSC_CitypageWeather_s0000074_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120154.216Z_MSC_CitypageWeather_s0000075_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120205.167Z_MSC_CitypageWeather_s0000428_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120205.205Z_MSC_CitypageWeather_s0000428_fr.xml 2026-02-07 12:02 36K +[   ] 20260207T120207.885Z_MSC_CitypageWeather_s0000680_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120207.885Z_MSC_CitypageWeather_s0000843_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120207.961Z_MSC_CitypageWeather_s0000680_fr.xml 2026-02-07 12:02 38K +[   ] 20260207T120207.961Z_MSC_CitypageWeather_s0000843_fr.xml 2026-02-07 12:02 38K +[   ] 20260207T120210.405Z_MSC_CitypageWeather_s0000455_en.xml 2026-02-07 12:02 38K +[   ] 20260207T120210.451Z_MSC_CitypageWeather_s0000455_fr.xml 2026-02-07 12:02 39K +[   ] 20260207T120210.675Z_MSC_CitypageWeather_s0000367_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120210.675Z_MSC_CitypageWeather_s0000368_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120210.758Z_MSC_CitypageWeather_s0000367_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120210.758Z_MSC_CitypageWeather_s0000368_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120211.358Z_MSC_CitypageWeather_s0000251_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120211.408Z_MSC_CitypageWeather_s0000251_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120221.954Z_MSC_CitypageWeather_s0000418_en.xml 2026-02-07 12:02 35K +[   ] 20260207T120221.954Z_MSC_CitypageWeather_s0000419_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120222.043Z_MSC_CitypageWeather_s0000418_fr.xml 2026-02-07 12:02 36K +[   ] 20260207T120222.043Z_MSC_CitypageWeather_s0000419_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120222.347Z_MSC_CitypageWeather_s0000451_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120222.347Z_MSC_CitypageWeather_s0000452_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120222.448Z_MSC_CitypageWeather_s0000451_fr.xml 2026-02-07 12:02 38K +[   ] 20260207T120222.448Z_MSC_CitypageWeather_s0000452_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120223.004Z_MSC_CitypageWeather_s0000550_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120223.050Z_MSC_CitypageWeather_s0000550_fr.xml 2026-02-07 12:02 38K +[   ] 20260207T120223.502Z_MSC_CitypageWeather_s0000763_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120223.539Z_MSC_CitypageWeather_s0000763_fr.xml 2026-02-07 12:02 36K +[   ] 20260207T120223.577Z_MSC_CitypageWeather_s0000469_en.xml 2026-02-07 12:02 38K +[   ] 20260207T120223.577Z_MSC_CitypageWeather_s0000470_en.xml 2026-02-07 12:02 38K +[   ] 20260207T120223.660Z_MSC_CitypageWeather_s0000469_fr.xml 2026-02-07 12:02 39K +[   ] 20260207T120223.660Z_MSC_CitypageWeather_s0000470_fr.xml 2026-02-07 12:02 39K +[   ] 20260207T120223.753Z_MSC_CitypageWeather_s0000103_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120223.753Z_MSC_CitypageWeather_s0000105_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120223.839Z_MSC_CitypageWeather_s0000103_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120223.839Z_MSC_CitypageWeather_s0000105_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120225.159Z_MSC_CitypageWeather_s0000528_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120225.159Z_MSC_CitypageWeather_s0000529_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120225.159Z_MSC_CitypageWeather_s0000530_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120225.159Z_MSC_CitypageWeather_s0000531_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120225.356Z_MSC_CitypageWeather_s0000528_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120225.356Z_MSC_CitypageWeather_s0000529_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120225.356Z_MSC_CitypageWeather_s0000530_fr.xml 2026-02-07 12:02 38K +[   ] 20260207T120225.356Z_MSC_CitypageWeather_s0000531_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120226.518Z_MSC_CitypageWeather_s0000582_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120226.518Z_MSC_CitypageWeather_s0000584_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120226.518Z_MSC_CitypageWeather_s0000773_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120226.630Z_MSC_CitypageWeather_s0000582_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120226.630Z_MSC_CitypageWeather_s0000584_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120226.630Z_MSC_CitypageWeather_s0000773_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120228.362Z_MSC_CitypageWeather_s0000235_en.xml 2026-02-07 12:02 38K +[   ] 20260207T120228.362Z_MSC_CitypageWeather_s0000236_en.xml 2026-02-07 12:02 38K +[   ] 20260207T120228.362Z_MSC_CitypageWeather_s0000237_en.xml 2026-02-07 12:02 38K +[   ] 20260207T120228.362Z_MSC_CitypageWeather_s0000238_en.xml 2026-02-07 12:02 38K +[   ] 20260207T120228.362Z_MSC_CitypageWeather_s0000240_en.xml 2026-02-07 12:02 38K +[   ] 20260207T120228.597Z_MSC_CitypageWeather_s0000235_fr.xml 2026-02-07 12:02 39K +[   ] 20260207T120228.597Z_MSC_CitypageWeather_s0000236_fr.xml 2026-02-07 12:02 39K +[   ] 20260207T120228.597Z_MSC_CitypageWeather_s0000237_fr.xml 2026-02-07 12:02 39K +[   ] 20260207T120228.597Z_MSC_CitypageWeather_s0000238_fr.xml 2026-02-07 12:02 39K +[   ] 20260207T120228.597Z_MSC_CitypageWeather_s0000240_fr.xml 2026-02-07 12:02 39K +[   ] 20260207T120228.829Z_MSC_CitypageWeather_s0000127_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120228.874Z_MSC_CitypageWeather_s0000127_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120238.436Z_MSC_CitypageWeather_s0000700_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120238.436Z_MSC_CitypageWeather_s0000701_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120238.436Z_MSC_CitypageWeather_s0000702_en.xml 2026-02-07 12:03 37K +[   ] 20260207T120238.436Z_MSC_CitypageWeather_s0000703_en.xml 2026-02-07 12:02 37K +[   ] 20260207T120238.436Z_MSC_CitypageWeather_s0000704_en.xml 2026-02-07 12:03 37K +[   ] 20260207T120238.621Z_MSC_CitypageWeather_s0000700_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120238.621Z_MSC_CitypageWeather_s0000701_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120238.621Z_MSC_CitypageWeather_s0000702_fr.xml 2026-02-07 12:02 38K +[   ] 20260207T120238.621Z_MSC_CitypageWeather_s0000703_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120238.621Z_MSC_CitypageWeather_s0000704_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120240.742Z_MSC_CitypageWeather_s0000104_en.xml 2026-02-07 12:02 36K +[   ] 20260207T120240.780Z_MSC_CitypageWeather_s0000104_fr.xml 2026-02-07 12:02 37K +[   ] 20260207T120252.049Z_MSC_CitypageWeather_s0000431_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120252.088Z_MSC_CitypageWeather_s0000431_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120253.001Z_MSC_CitypageWeather_s0000169_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120253.050Z_MSC_CitypageWeather_s0000169_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120255.032Z_MSC_CitypageWeather_s0000108_en.xml 2026-02-07 12:03 38K +[   ] 20260207T120255.032Z_MSC_CitypageWeather_s0000429_en.xml 2026-02-07 12:03 38K +[   ] 20260207T120255.032Z_MSC_CitypageWeather_s0000489_en.xml 2026-02-07 12:03 38K +[   ] 20260207T120255.158Z_MSC_CitypageWeather_s0000108_fr.xml 2026-02-07 12:03 39K +[   ] 20260207T120255.158Z_MSC_CitypageWeather_s0000429_fr.xml 2026-02-07 12:03 39K +[   ] 20260207T120255.158Z_MSC_CitypageWeather_s0000489_fr.xml 2026-02-07 12:03 39K +[   ] 20260207T120305.479Z_MSC_CitypageWeather_s0000705_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120305.479Z_MSC_CitypageWeather_s0000706_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120305.555Z_MSC_CitypageWeather_s0000705_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120305.555Z_MSC_CitypageWeather_s0000706_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120307.995Z_MSC_CitypageWeather_s0000517_en.xml 2026-02-07 12:03 35K +[   ] 20260207T120308.045Z_MSC_CitypageWeather_s0000517_fr.xml 2026-02-07 12:03 36K +[   ] 20260207T120312.670Z_MSC_CitypageWeather_s0000076_en.xml 2026-02-07 12:03 38K +[   ] 20260207T120312.703Z_MSC_CitypageWeather_s0000076_fr.xml 2026-02-07 12:03 39K +[   ] 20260207T120312.743Z_MSC_CitypageWeather_s0000281_en.xml 2026-02-07 12:03 37K +[   ] 20260207T120312.743Z_MSC_CitypageWeather_s0000414_en.xml 2026-02-07 12:03 37K +[   ] 20260207T120312.743Z_MSC_CitypageWeather_s0000415_en.xml 2026-02-07 12:03 38K +[   ] 20260207T120312.872Z_MSC_CitypageWeather_s0000281_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120312.872Z_MSC_CitypageWeather_s0000414_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120312.872Z_MSC_CitypageWeather_s0000415_fr.xml 2026-02-07 12:03 39K +[   ] 20260207T120323.523Z_MSC_CitypageWeather_s0000301_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120323.523Z_MSC_CitypageWeather_s0000302_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120323.523Z_MSC_CitypageWeather_s0000303_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120323.523Z_MSC_CitypageWeather_s0000304_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120323.523Z_MSC_CitypageWeather_s0000305_en.xml 2026-02-07 12:03 37K +[   ] 20260207T120323.760Z_MSC_CitypageWeather_s0000301_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120323.760Z_MSC_CitypageWeather_s0000302_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120323.760Z_MSC_CitypageWeather_s0000303_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120323.760Z_MSC_CitypageWeather_s0000304_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120323.760Z_MSC_CitypageWeather_s0000305_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120324.150Z_MSC_CitypageWeather_s0000168_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120324.203Z_MSC_CitypageWeather_s0000168_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120324.794Z_MSC_CitypageWeather_s0000165_en.xml 2026-02-07 12:03 38K +[   ] 20260207T120324.794Z_MSC_CitypageWeather_s0000166_en.xml 2026-02-07 12:03 38K +[   ] 20260207T120324.879Z_MSC_CitypageWeather_s0000165_fr.xml 2026-02-07 12:03 39K +[   ] 20260207T120324.879Z_MSC_CitypageWeather_s0000166_fr.xml 2026-02-07 12:03 39K +[   ] 20260207T120326.004Z_MSC_CitypageWeather_s0000266_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120326.004Z_MSC_CitypageWeather_s0000267_en.xml 2026-02-07 12:03 36K +[   ] 20260207T120326.077Z_MSC_CitypageWeather_s0000266_fr.xml 2026-02-07 12:03 37K +[   ] 20260207T120326.077Z_MSC_CitypageWeather_s0000267_fr.xml 2026-02-07 12:03 36K +[   ] 20260207T120334.963Z_MSC_CitypageWeather_s0000571_en.xml 2026-02-07 12:04 38K +[   ] 20260207T120335.008Z_MSC_CitypageWeather_s0000571_fr.xml 2026-02-07 12:04 39K +[   ] 20260207T120407.043Z_MSC_CitypageWeather_s0000422_en.xml 2026-02-07 12:04 38K +[   ] 20260207T120407.081Z_MSC_CitypageWeather_s0000422_fr.xml 2026-02-07 12:04 39K +[   ] 20260207T120410.902Z_MSC_CitypageWeather_s0000070_en.xml 2026-02-07 12:04 36K +[   ] 20260207T120410.951Z_MSC_CitypageWeather_s0000070_fr.xml 2026-02-07 12:04 37K +[   ] 20260207T120421.782Z_MSC_CitypageWeather_s0000458_en.xml 2026-02-07 12:04 37K +[   ] 20260207T120421.782Z_MSC_CitypageWeather_s0000658_en.xml 2026-02-07 12:05 37K +[   ] 20260207T120421.782Z_MSC_CitypageWeather_s0000786_en.xml 2026-02-07 12:04 37K +[   ] 20260207T120421.782Z_MSC_CitypageWeather_s0000787_en.xml 2026-02-07 12:05 38K +[   ] 20260207T120421.782Z_MSC_CitypageWeather_s0000789_en.xml 2026-02-07 12:04 37K +[   ] 20260207T120421.980Z_MSC_CitypageWeather_s0000458_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120421.980Z_MSC_CitypageWeather_s0000658_fr.xml 2026-02-07 12:04 37K +[   ] 20260207T120421.980Z_MSC_CitypageWeather_s0000786_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120421.980Z_MSC_CitypageWeather_s0000787_fr.xml 2026-02-07 12:04 39K +[   ] 20260207T120421.980Z_MSC_CitypageWeather_s0000789_fr.xml 2026-02-07 12:04 37K +[   ] 20260207T120503.727Z_MSC_CitypageWeather_s0000729_en.xml 2026-02-07 12:05 36K +[   ] 20260207T120503.727Z_MSC_CitypageWeather_s0000730_en.xml 2026-02-07 12:05 36K +[   ] 20260207T120503.727Z_MSC_CitypageWeather_s0000731_en.xml 2026-02-07 12:05 37K +[   ] 20260207T120503.727Z_MSC_CitypageWeather_s0000732_en.xml 2026-02-07 12:05 36K +[   ] 20260207T120503.926Z_MSC_CitypageWeather_s0000729_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120503.926Z_MSC_CitypageWeather_s0000730_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120503.926Z_MSC_CitypageWeather_s0000731_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120503.926Z_MSC_CitypageWeather_s0000732_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.127Z_MSC_CitypageWeather_s0000430_en.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.127Z_MSC_CitypageWeather_s0000623_en.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.217Z_MSC_CitypageWeather_s0000430_fr.xml 2026-02-07 12:05 38K +[   ] 20260207T120504.217Z_MSC_CitypageWeather_s0000623_fr.xml 2026-02-07 12:05 38K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000025_en.xml 2026-02-07 12:05 34K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000025_fr.xml 2026-02-07 12:05 35K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000088_en.xml 2026-02-07 12:05 34K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000088_fr.xml 2026-02-07 12:05 35K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000239_en.xml 2026-02-07 12:05 36K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000239_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000282_en.xml 2026-02-07 12:05 36K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000282_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000283_en.xml 2026-02-07 12:05 36K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000283_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000479_en.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000479_fr.xml 2026-02-07 12:05 38K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000630_en.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000630_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000815_en.xml 2026-02-07 12:05 36K +[   ] 20260207T120504.532Z_MSC_CitypageWeather_s0000815_fr.xml 2026-02-07 12:05 37K +[   ] 20260207T120611.878Z_MSC_CitypageWeather_s0000528_en.xml 2026-02-07 12:07 37K +[   ] 20260207T120611.878Z_MSC_CitypageWeather_s0000529_en.xml 2026-02-07 12:07 37K +[   ] 20260207T120611.878Z_MSC_CitypageWeather_s0000530_en.xml 2026-02-07 12:07 37K +[   ] 20260207T120611.878Z_MSC_CitypageWeather_s0000531_en.xml 2026-02-07 12:07 37K +[   ] 20260207T120611.965Z_MSC_CitypageWeather_s0000528_fr.xml 2026-02-07 12:06 38K +[   ] 20260207T120611.965Z_MSC_CitypageWeather_s0000529_fr.xml 2026-02-07 12:07 37K +[   ] 20260207T120611.965Z_MSC_CitypageWeather_s0000530_fr.xml 2026-02-07 12:07 38K +[   ] 20260207T120611.965Z_MSC_CitypageWeather_s0000531_fr.xml 2026-02-07 12:07 38K +[   ] 20260207T120612.072Z_MSC_CitypageWeather_s0000479_en.xml 2026-02-07 12:07 37K +[   ] 20260207T120612.109Z_MSC_CitypageWeather_s0000479_fr.xml 2026-02-07 12:07 38K +[   ] 20260207T120622.544Z_MSC_CitypageWeather_s0000630_en.xml 2026-02-07 12:07 37K +[   ] 20260207T120622.567Z_MSC_CitypageWeather_s0000630_fr.xml 2026-02-07 12:07 37K +[   ] 20260207T120622.795Z_MSC_CitypageWeather_s0000782_en.xml 2026-02-07 12:07 36K +[   ] 20260207T120622.821Z_MSC_CitypageWeather_s0000782_fr.xml 2026-02-07 12:07 37K +[   ] 20260207T120723.672Z_MSC_CitypageWeather_s0000588_en.xml 2026-02-07 12:08 36K +[   ] 20260207T120723.699Z_MSC_CitypageWeather_s0000588_fr.xml 2026-02-07 12:08 36K +[   ] 20260207T120740.860Z_MSC_CitypageWeather_s0000691_en.xml 2026-02-07 12:08 38K +[   ] 20260207T120740.860Z_MSC_CitypageWeather_s0000692_en.xml 2026-02-07 12:08 38K +[   ] 20260207T120740.911Z_MSC_CitypageWeather_s0000691_fr.xml 2026-02-07 12:08 39K +[   ] 20260207T120740.911Z_MSC_CitypageWeather_s0000692_fr.xml 2026-02-07 12:08 39K +[   ] 20260207T121451.572Z_MSC_CitypageWeather_s0000458_en.xml 2026-02-07 12:15 37K +[   ] 20260207T121451.572Z_MSC_CitypageWeather_s0000658_en.xml 2026-02-07 12:15 37K +[   ] 20260207T121451.572Z_MSC_CitypageWeather_s0000786_en.xml 2026-02-07 12:15 37K +[   ] 20260207T121451.572Z_MSC_CitypageWeather_s0000787_en.xml 2026-02-07 12:15 38K +[   ] 20260207T121451.572Z_MSC_CitypageWeather_s0000789_en.xml 2026-02-07 12:15 37K +[   ] 20260207T121451.691Z_MSC_CitypageWeather_s0000458_fr.xml 2026-02-07 12:15 37K +[   ] 20260207T121451.691Z_MSC_CitypageWeather_s0000658_fr.xml 2026-02-07 12:15 37K +[   ] 20260207T121451.691Z_MSC_CitypageWeather_s0000786_fr.xml 2026-02-07 12:15 37K +[   ] 20260207T121451.691Z_MSC_CitypageWeather_s0000787_fr.xml 2026-02-07 12:15 39K +[   ] 20260207T121451.691Z_MSC_CitypageWeather_s0000789_fr.xml 2026-02-07 12:15 37K +[   ] 20260207T122801.995Z_MSC_CitypageWeather_s0000430_en.xml 2026-02-07 12:28 37K +[   ] 20260207T122801.995Z_MSC_CitypageWeather_s0000623_en.xml 2026-02-07 12:28 37K +[   ] 20260207T122802.053Z_MSC_CitypageWeather_s0000430_fr.xml 2026-02-07 12:28 38K +[   ] 20260207T122802.053Z_MSC_CitypageWeather_s0000623_fr.xml 2026-02-07 12:28 38K +[   ] 20260207T124745.464Z_MSC_CitypageWeather_s0000752_en.xml 2026-02-07 12:47 36K +[   ] 20260207T124745.464Z_MSC_CitypageWeather_s0000753_en.xml 2026-02-07 12:47 36K +[   ] 20260207T124745.526Z_MSC_CitypageWeather_s0000752_fr.xml 2026-02-07 12:47 37K +[   ] 20260207T124745.526Z_MSC_CitypageWeather_s0000753_fr.xml 2026-02-07 12:47 37K +[   ] 20260207T124815.922Z_MSC_CitypageWeather_s0000458_en.xml 2026-02-07 12:48 37K +[   ] 20260207T124815.922Z_MSC_CitypageWeather_s0000658_en.xml 2026-02-07 12:48 37K +[   ] 20260207T124815.922Z_MSC_CitypageWeather_s0000786_en.xml 2026-02-07 12:48 37K +[   ] 20260207T124815.922Z_MSC_CitypageWeather_s0000787_en.xml 2026-02-07 12:48 38K +[   ] 20260207T124815.922Z_MSC_CitypageWeather_s0000789_en.xml 2026-02-07 12:48 37K +[   ] 20260207T124816.077Z_MSC_CitypageWeather_s0000458_fr.xml 2026-02-07 12:48 37K +[   ] 20260207T124816.077Z_MSC_CitypageWeather_s0000658_fr.xml 2026-02-07 12:48 37K +[   ] 20260207T124816.077Z_MSC_CitypageWeather_s0000786_fr.xml 2026-02-07 12:48 37K +[   ] 20260207T124816.077Z_MSC_CitypageWeather_s0000787_fr.xml 2026-02-07 12:48 39K +[   ] 20260207T124816.077Z_MSC_CitypageWeather_s0000789_fr.xml 2026-02-07 12:48 37K +
+ + diff --git a/tests/mocks/weather_forecast.json b/tests/mocks/weather_forecast.json new file mode 100644 index 0000000..ed062c3 --- /dev/null +++ b/tests/mocks/weather_forecast.json @@ -0,0 +1,202 @@ +{ + "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 + } + ] +} diff --git a/tests/mocks/weather_onecall_current.json b/tests/mocks/weather_onecall_current.json new file mode 100644 index 0000000..73b88d7 --- /dev/null +++ b/tests/mocks/weather_onecall_current.json @@ -0,0 +1,28 @@ +{ + "lat": 48.14, + "lon": 11.58, + "timezone": "Europe/Berlin", + "timezone_offset": 0, + "current": { + "dt": 1547387400, + "sunrise": 1547362817, + "sunset": 1547394301, + "temp": 1.49, + "feels_like": -5.6, + "pressure": 1005, + "humidity": 93.7, + "uvi": 0, + "clouds": 75, + "visibility": 7000, + "wind_speed": 11.8, + "wind_deg": 250, + "weather": [ + { + "id": 615, + "main": "Snow", + "description": "light rain and snow", + "icon": "13d" + } + ] + } +} diff --git a/tests/mocks/weather_onecall_forecast.json b/tests/mocks/weather_onecall_forecast.json new file mode 100644 index 0000000..aa70f6e --- /dev/null +++ b/tests/mocks/weather_onecall_forecast.json @@ -0,0 +1,149 @@ +{ + "lat": 48.14, + "lon": 11.58, + "timezone": "Europe/Berlin", + "timezone_offset": 3600, + "daily": [ + { + "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, + "humidity": 70, + "wind_speed": 3.35, + "wind_deg": 314, + "weather": [ + { + "id": 801, + "main": "Clouds", + "description": "few clouds", + "icon": "02d" + } + ], + "clouds": 21, + "pop": 0, + "uvi": 5 + }, + { + "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, + "humidity": 72, + "wind_speed": 2.78, + "wind_deg": 266, + "weather": [ + { + "id": 500, + "main": "Rain", + "description": "light rain", + "icon": "10d" + } + ], + "clouds": 21, + "pop": 0.56, + "rain": 2.51, + "uvi": 4.5 + }, + { + "dt": 1568545200, + "sunrise": 1568523006, + "sunset": 1568568498, + "temp": { + "day": 22.93, + "min": 13.78, + "max": 22.93, + "night": 13.78, + "eve": 17.21, + "morn": 14.56 + }, + "pressure": 1024, + "humidity": 59, + "wind_speed": 2.17, + "wind_deg": 255, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 0, + "pop": 0, + "uvi": 5.2 + }, + { + "dt": 1568631600, + "sunrise": 1568609487, + "sunset": 1568654774, + "temp": { + "day": 23.39, + "min": 13.93, + "max": 23.39, + "night": 13.93, + "eve": 17.98, + "morn": 15.05 + }, + "pressure": 1023, + "humidity": 57, + "wind_speed": 1.93, + "wind_deg": 236, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 0, + "pop": 0, + "uvi": 5.1 + }, + { + "dt": 1568718000, + "sunrise": 1568695968, + "sunset": 1568741049, + "temp": { + "day": 20.64, + "min": 10.87, + "max": 20.64, + "night": 10.87, + "eve": 15.21, + "morn": 13.67 + }, + "pressure": 1021, + "humidity": 64, + "wind_speed": 2.44, + "wind_deg": 284, + "weather": [ + { + "id": 800, + "main": "Clear", + "description": "clear sky", + "icon": "01d" + } + ], + "clouds": 3, + "pop": 0, + "uvi": 4.9 + } + ] +} diff --git a/tests/mocks/weather_onecall_hourly.json b/tests/mocks/weather_onecall_hourly.json new file mode 100644 index 0000000..bcf2b80 --- /dev/null +++ b/tests/mocks/weather_onecall_hourly.json @@ -0,0 +1,1118 @@ +{ + "lat": 48.14, + "lon": 11.58, + "timezone": "Europe/Berlin", + "timezone_offset": 3600, + "hourly": [ + { + "dt": 1673200800, + "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": 1673204400, + "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": 1673208000, + "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": 1673211600, + "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": 1673215200, + "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": 1673218800, + "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": 1673222400, + "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": 1673226000, + "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": 1673229600, + "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": 1673233200, + "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": 1673236800, + "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": 1673240400, + "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": 1673244000, + "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": 1673247600, + "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": 1673251200, + "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": 1673254800, + "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": 1673258400, + "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": 1673262000, + "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": 1673265600, + "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": 1673269200, + "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": 1673272800, + "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": 1673276400, + "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": 1673280000, + "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": 1673283600, + "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": 1673287200, + "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": 1673290800, + "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": 1673294400, + "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": 1673298000, + "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": 1673301600, + "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": 1673305200, + "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": 1673308800, + "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": 1673312400, + "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": 1673316000, + "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": 1673319600, + "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": 1673323200, + "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": 1673326800, + "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": 1673330400, + "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": 1673334000, + "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": 1673337600, + "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": 1673341200, + "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": 1673344800, + "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": 1673348400, + "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": 1673352000, + "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": 1673355600, + "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": 1673359200, + "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": 1673362800, + "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": 1673366400, + "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": 1673370000, + "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 + } + ] +} diff --git a/tests/mocks/weather_openmeteo_current.json b/tests/mocks/weather_openmeteo_current.json new file mode 100644 index 0000000..478ee1d --- /dev/null +++ b/tests/mocks/weather_openmeteo_current.json @@ -0,0 +1,218 @@ +{ + "latitude": 40.78858, + "longitude": -73.9661, + "generationtime_ms": 0.7585287094116211, + "utc_offset_seconds": -18000, + "timezone": "America/New_York", + "timezone_abbreviation": "GMT-5", + "elevation": 20.0, + "current_units": { "time": "iso8601", "interval": "seconds", "temperature_2m": "°C", "relative_humidity_2m": "%", "weather_code": "wmo code", "wind_speed_10m": "km/h", "wind_direction_10m": "°" }, + "current": { "time": "2026-02-06T16:30", "interval": 900, "temperature_2m": -1.4, "relative_humidity_2m": 60, "weather_code": 3, "wind_speed_10m": 4.8, "wind_direction_10m": 138 }, + "hourly_units": { "time": "iso8601", "temperature_2m": "°C", "precipitation": "mm", "weather_code": "wmo code", "wind_speed_10m": "km/h" }, + "hourly": { + "time": [ + "2026-02-06T00:00", + "2026-02-06T01:00", + "2026-02-06T02:00", + "2026-02-06T03:00", + "2026-02-06T04:00", + "2026-02-06T05:00", + "2026-02-06T06:00", + "2026-02-06T07:00", + "2026-02-06T08:00", + "2026-02-06T09:00", + "2026-02-06T10:00", + "2026-02-06T11:00", + "2026-02-06T12:00", + "2026-02-06T13:00", + "2026-02-06T14:00", + "2026-02-06T15:00", + "2026-02-06T16:00", + "2026-02-06T17:00", + "2026-02-06T18:00", + "2026-02-06T19:00", + "2026-02-06T20:00", + "2026-02-06T21:00", + "2026-02-06T22:00", + "2026-02-06T23:00", + "2026-02-07T00:00", + "2026-02-07T01:00", + "2026-02-07T02:00", + "2026-02-07T03:00", + "2026-02-07T04:00", + "2026-02-07T05:00", + "2026-02-07T06:00", + "2026-02-07T07:00", + "2026-02-07T08:00", + "2026-02-07T09:00", + "2026-02-07T10:00", + "2026-02-07T11:00", + "2026-02-07T12:00", + "2026-02-07T13:00", + "2026-02-07T14:00", + "2026-02-07T15:00", + "2026-02-07T16:00", + "2026-02-07T17:00", + "2026-02-07T18:00", + "2026-02-07T19:00", + "2026-02-07T20:00", + "2026-02-07T21:00", + "2026-02-07T22:00", + "2026-02-07T23:00", + "2026-02-08T00:00", + "2026-02-08T01:00", + "2026-02-08T02:00", + "2026-02-08T03:00", + "2026-02-08T04:00", + "2026-02-08T05:00", + "2026-02-08T06:00", + "2026-02-08T07:00", + "2026-02-08T08:00", + "2026-02-08T09:00", + "2026-02-08T10:00", + "2026-02-08T11:00", + "2026-02-08T12:00", + "2026-02-08T13:00", + "2026-02-08T14:00", + "2026-02-08T15:00", + "2026-02-08T16:00", + "2026-02-08T17:00", + "2026-02-08T18:00", + "2026-02-08T19:00", + "2026-02-08T20:00", + "2026-02-08T21:00", + "2026-02-08T22:00", + "2026-02-08T23:00", + "2026-02-09T00:00", + "2026-02-09T01:00", + "2026-02-09T02:00", + "2026-02-09T03:00", + "2026-02-09T04:00", + "2026-02-09T05:00", + "2026-02-09T06:00", + "2026-02-09T07:00", + "2026-02-09T08:00", + "2026-02-09T09:00", + "2026-02-09T10:00", + "2026-02-09T11:00", + "2026-02-09T12:00", + "2026-02-09T13:00", + "2026-02-09T14:00", + "2026-02-09T15:00", + "2026-02-09T16:00", + "2026-02-09T17:00", + "2026-02-09T18:00", + "2026-02-09T19:00", + "2026-02-09T20:00", + "2026-02-09T21:00", + "2026-02-09T22:00", + "2026-02-09T23:00", + "2026-02-10T00:00", + "2026-02-10T01:00", + "2026-02-10T02:00", + "2026-02-10T03:00", + "2026-02-10T04:00", + "2026-02-10T05:00", + "2026-02-10T06:00", + "2026-02-10T07:00", + "2026-02-10T08:00", + "2026-02-10T09:00", + "2026-02-10T10:00", + "2026-02-10T11:00", + "2026-02-10T12:00", + "2026-02-10T13:00", + "2026-02-10T14:00", + "2026-02-10T15:00", + "2026-02-10T16:00", + "2026-02-10T17:00", + "2026-02-10T18:00", + "2026-02-10T19:00", + "2026-02-10T20:00", + "2026-02-10T21:00", + "2026-02-10T22:00", + "2026-02-10T23:00", + "2026-02-11T00:00", + "2026-02-11T01:00", + "2026-02-11T02:00", + "2026-02-11T03:00", + "2026-02-11T04:00", + "2026-02-11T05:00", + "2026-02-11T06:00", + "2026-02-11T07:00", + "2026-02-11T08:00", + "2026-02-11T09:00", + "2026-02-11T10:00", + "2026-02-11T11:00", + "2026-02-11T12:00", + "2026-02-11T13:00", + "2026-02-11T14:00", + "2026-02-11T15:00", + "2026-02-11T16:00", + "2026-02-11T17:00", + "2026-02-11T18:00", + "2026-02-11T19:00", + "2026-02-11T20:00", + "2026-02-11T21:00", + "2026-02-11T22:00", + "2026-02-11T23:00", + "2026-02-12T00:00", + "2026-02-12T01:00", + "2026-02-12T02:00", + "2026-02-12T03:00", + "2026-02-12T04:00", + "2026-02-12T05:00", + "2026-02-12T06:00", + "2026-02-12T07:00", + "2026-02-12T08:00", + "2026-02-12T09:00", + "2026-02-12T10:00", + "2026-02-12T11:00", + "2026-02-12T12:00", + "2026-02-12T13:00", + "2026-02-12T14:00", + "2026-02-12T15:00", + "2026-02-12T16:00", + "2026-02-12T17:00", + "2026-02-12T18:00", + "2026-02-12T19:00", + "2026-02-12T20:00", + "2026-02-12T21:00", + "2026-02-12T22:00", + "2026-02-12T23:00" + ], + "temperature_2m": [ + -7.1, -7.1, -8.5, -8.8, -9.0, -7.7, -9.2, -8.8, -8.9, -5.9, -3.4, -2.4, -1.3, -0.8, -0.5, -0.2, -0.7, -2.1, -3.2, -3.7, -4.4, -5.3, -6.2, -6.8, -6.5, -6.3, -6.5, -6.3, -5.8, -5.3, -8.5, -11.2, -12.7, -13.6, -13.9, -13.8, -13.5, -13.2, + -12.9, -13.0, -13.2, -14.0, -15.2, -16.1, -16.9, -17.4, -17.5, -17.7, -18.1, -18.5, -18.9, -19.4, -20.0, -20.5, -21.0, -21.5, -20.2, -18.3, -16.4, -14.2, -12.5, -10.8, -9.3, -8.9, -10.0, -10.6, -11.2, -11.8, -12.5, -12.9, -13.4, -13.9, + -14.9, -15.7, -16.6, -17.0, -17.3, -17.5, -17.7, -18.1, -15.5, -12.6, -10.5, -8.6, -7.1, -5.8, -4.9, -4.4, -4.1, -4.9, -7.1, -9.1, -9.7, -6.9, -6.5, -6.4, -6.1, -7.2, -9.6, -10.1, -10.5, -10.8, -10.8, -11.0, -9.1, -6.7, -4.8, -3.1, -2.3, + -1.7, -1.2, -0.8, -0.7, -1.1, -1.8, -1.7, -1.7, -1.9, -4.7, -5.3, -5.3, -5.1, -5.1, -4.8, -5.0, -1.9, -1.2, -0.3, 0.4, 0.6, 0.8, 0.8, 0.6, 0.5, 0.5, 0.6, 0.8, 1.3, 1.9, 2.0, 1.3, 0.1, -0.9, -1.3, -1.6, -1.8, -2.0, -2.3, -2.6, -3.2, -3.8, + -3.9, -3.1, -1.8, -0.8, -0.3, -0.1, 0.0, 0.0, -0.1, -0.5, -1.3, -2.4, -3.3, -4.0, -4.5, -5.0, -5.3 + ], + "precipitation": [ + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.1, 0.2, 0.0, 0.0, 0.0, 0.0, 0.0, 0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.4, 1.3, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.3, 0.3, 0.3, 0.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0 + ], + "weather_code": [ + 0, 0, 0, 0, 2, 0, 3, 3, 3, 0, 3, 3, 3, 1, 1, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 71, 71, 3, 3, 3, 3, 3, 51, 3, 3, 3, 3, 3, 3, 3, 2, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 1, 1, 1, 1, 2, 2, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 56, 71, 3, 3, 45, 45, 45, 45, 51, 51, 51, 3, 3, 3, 3, 3, 3, 3, 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, + 0, 1, 2, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 2 + ], + "wind_speed_10m": [ + 4.3, 9.3, 8.1, 3.3, 11.3, 8.2, 8.8, 7.2, 3.6, 7.2, 5.4, 4.7, 5.0, 1.5, 1.1, 3.9, 4.9, 5.8, 6.5, 5.8, 7.1, 9.3, 8.5, 6.6, 6.6, 6.9, 6.9, 7.5, 8.9, 12.0, 25.8, 27.3, 27.3, 27.0, 28.5, 30.5, 30.8, 30.0, 29.3, 28.3, 27.8, 27.5, 26.3, 24.5, + 25.7, 22.9, 21.9, 19.4, 18.7, 18.4, 21.0, 19.0, 19.1, 18.5, 17.0, 13.8, 13.3, 18.1, 18.6, 19.7, 20.4, 20.5, 20.9, 21.7, 24.2, 22.2, 20.2, 18.8, 15.9, 13.8, 12.8, 11.2, 7.9, 5.9, 4.9, 4.6, 4.3, 3.2, 2.9, 3.0, 5.2, 6.9, 7.5, 6.6, 6.6, 5.2, + 4.1, 5.3, 2.2, 1.9, 2.6, 1.1, 2.2, 4.0, 3.9, 5.2, 4.0, 2.2, 5.0, 4.4, 4.5, 4.7, 4.2, 5.8, 5.8, 7.9, 6.8, 6.0, 5.4, 4.2, 3.4, 3.5, 1.1, 2.8, 5.3, 5.8, 6.1, 4.9, 4.8, 3.3, 3.7, 2.2, 1.0, 1.4, 4.0, 4.4, 3.7, 6.9, 7.6, 7.3, 6.6, 5.5, 4.1, + 3.9, 4.9, 6.0, 7.3, 9.7, 14.1, 17.1, 16.9, 15.1, 14.0, 14.7, 16.4, 17.4, 18.0, 18.1, 17.6, 15.5, 12.9, 11.9, 13.2, 15.8, 18.2, 19.0, 19.2, 19.2, 19.5, 19.7, 19.7, 19.0, 18.0, 17.4, 17.4, 17.6, 17.9, 18.1 + ] + }, + "daily_units": { "time": "iso8601", "weather_code": "wmo code", "temperature_2m_max": "°C", "temperature_2m_min": "°C", "sunrise": "iso8601", "sunset": "iso8601", "precipitation_sum": "mm" }, + "daily": { + "time": ["2026-02-06", "2026-02-07", "2026-02-08", "2026-02-09", "2026-02-10", "2026-02-11", "2026-02-12"], + "weather_code": [3, 71, 3, 3, 3, 71, 3], + "temperature_2m_max": [-0.2, -5.3, -8.9, -4.1, -0.7, 2.0, 0.0], + "temperature_2m_min": [-9.2, -17.7, -21.5, -18.1, -11.0, -5.3, -5.3], + "sunrise": ["2026-02-06T07:00", "2026-02-07T06:59", "2026-02-08T06:58", "2026-02-09T06:56", "2026-02-10T06:55", "2026-02-11T06:54", "2026-02-12T06:53"], + "sunset": ["2026-02-06T17:19", "2026-02-07T17:20", "2026-02-08T17:21", "2026-02-09T17:23", "2026-02-10T17:24", "2026-02-11T17:25", "2026-02-12T17:26"], + "precipitation_sum": [0.0, 0.4, 0.0, 0.0, 0.0, 2.6, 0.0] + } +} diff --git a/tests/mocks/weather_openmeteo_current_weather.json b/tests/mocks/weather_openmeteo_current_weather.json new file mode 100644 index 0000000..ba5f183 --- /dev/null +++ b/tests/mocks/weather_openmeteo_current_weather.json @@ -0,0 +1,84 @@ +{ + "latitude": 48.14, + "longitude": 11.58, + "generationtime_ms": 0.3949403762817383, + "utc_offset_seconds": 3600, + "timezone": "Europe/Berlin", + "timezone_abbreviation": "GMT+1", + "elevation": 524.0, + "current_weather_units": { + "time": "unixtime", + "interval": "seconds", + "temperature": "°C", + "windspeed": "km/h", + "winddirection": "°", + "is_day": "", + "weathercode": "wmo code" + }, + "current_weather": { + "time": 1770477300, + "interval": 900, + "temperature": 8.5, + "windspeed": 4.7, + "winddirection": 9, + "is_day": 1, + "weathercode": 2 + }, + "hourly_units": { + "time": "unixtime", + "temperature_2m": "°C", + "windspeed_10m": "km/h", + "winddirection_10m": "°", + "relativehumidity_2m": "%" + }, + "hourly": { + "time": [ + 1770418800, 1770422400, 1770426000, 1770429600, 1770433200, 1770436800, 1770440400, 1770444000, 1770447600, 1770451200, 1770454800, 1770458400, 1770462000, 1770465600, 1770469200, 1770472800, 1770476400, 1770480000, 1770483600, + 1770487200, 1770490800, 1770494400, 1770498000, 1770501600, 1770505200, 1770508800, 1770512400, 1770516000, 1770519600, 1770523200, 1770526800, 1770530400, 1770534000, 1770537600, 1770541200, 1770544800, 1770548400, 1770552000, + 1770555600, 1770559200, 1770562800, 1770566400, 1770570000, 1770573600, 1770577200, 1770580800, 1770584400, 1770588000, 1770591600, 1770595200, 1770598800, 1770602400, 1770606000, 1770609600, 1770613200, 1770616800, 1770620400, + 1770624000, 1770627600, 1770631200, 1770634800, 1770638400, 1770642000, 1770645600, 1770649200, 1770652800, 1770656400, 1770660000, 1770663600, 1770667200, 1770670800, 1770674400, 1770678000, 1770681600, 1770685200, 1770688800, + 1770692400, 1770696000, 1770699600, 1770703200, 1770706800, 1770710400, 1770714000, 1770717600, 1770721200, 1770724800, 1770728400, 1770732000, 1770735600, 1770739200, 1770742800, 1770746400, 1770750000, 1770753600, 1770757200, + 1770760800, 1770764400, 1770768000, 1770771600, 1770775200, 1770778800, 1770782400, 1770786000, 1770789600, 1770793200, 1770796800, 1770800400, 1770804000, 1770807600, 1770811200, 1770814800, 1770818400, 1770822000, 1770825600, + 1770829200, 1770832800, 1770836400, 1770840000, 1770843600, 1770847200, 1770850800, 1770854400, 1770858000, 1770861600, 1770865200, 1770868800, 1770872400, 1770876000, 1770879600, 1770883200, 1770886800, 1770890400, 1770894000, + 1770897600, 1770901200, 1770904800, 1770908400, 1770912000, 1770915600, 1770919200, 1770922800, 1770926400, 1770930000, 1770933600, 1770937200, 1770940800, 1770944400, 1770948000, 1770951600, 1770955200, 1770958800, 1770962400, + 1770966000, 1770969600, 1770973200, 1770976800, 1770980400, 1770984000, 1770987600, 1770991200, 1770994800, 1770998400, 1771002000, 1771005600, 1771009200, 1771012800, 1771016400, 1771020000 + ], + "temperature_2m": [ + 6.4, 6.6, 6.3, 5.8, 5.7, 5.1, 5.0, 5.5, 5.2, 5.3, 6.2, 7.0, 7.9, 9.1, 9.5, 9.0, 8.6, 8.2, 7.2, 6.4, 5.5, 5.1, 4.7, 4.9, 4.5, 4.5, 4.5, 4.7, 3.8, 3.1, 3.2, 3.0, 3.6, 3.8, 3.5, 4.3, 5.2, 6.2, 6.6, 6.8, 6.4, 5.5, 4.7, 4.7, 4.3, 4.2, 4.1, + 3.9, 3.6, 3.4, 3.2, 3.0, 2.9, 2.8, 2.7, 2.6, 2.5, 2.8, 3.1, 3.7, 4.3, 4.6, 4.7, 4.7, 4.4, 4.0, 3.5, 3.0, 2.5, 1.9, 1.2, 0.7, 0.3, 0.0, -0.1, -0.4, -0.8, -1.1, -1.3, -1.2, -1.2, -1.0, 0.2, 1.9, 3.3, 5.0, 6.1, 6.8, 7.0, 6.4, 5.2, 4.2, 3.6, + 3.1, 2.8, 2.5, 2.2, 2.2, 2.3, 2.6, 2.9, 3.0, 3.0, 3.4, 4.7, 6.3, 7.7, 8.5, 9.0, 9.3, 9.2, 8.9, 8.6, 8.3, 8.1, 7.9, 7.6, 7.4, 7.2, 7.1, 7.0, 6.9, 6.7, 6.4, 6.2, 5.6, 5.2, 4.9, 6.1, 6.4, 6.6, 6.9, 7.1, 7.2, 7.2, 7.0, 6.8, 6.4, 5.9, 5.5, + 5.4, 5.3, 5.2, 5.0, 4.8, 4.6, 4.5, 4.5, 4.6, 4.6, 4.6, 4.8, 5.2, 5.8, 6.4, 7.2, 8.1, 8.6, 8.3, 7.6, 7.0, 6.5, 6.2, 5.8, 5.5, 5.2, 5.0, 4.9 + ], + "windspeed_10m": [ + 9.4, 9.5, 9.2, 9.1, 7.9, 6.6, 6.8, 7.6, 6.7, 6.0, 5.9, 5.9, 6.6, 4.5, 7.9, 7.7, 5.4, 4.2, 3.3, 1.4, 2.6, 1.6, 2.2, 1.5, 0.8, 2.3, 2.3, 1.3, 2.5, 1.8, 1.6, 1.1, 1.8, 2.9, 5.6, 9.1, 9.0, 7.8, 9.0, 9.8, 9.7, 9.8, 10.4, 9.1, 7.9, 8.2, 7.6, + 6.0, 6.2, 5.3, 5.0, 5.5, 6.2, 5.9, 6.2, 6.5, 5.9, 5.4, 6.2, 5.9, 6.7, 6.5, 6.2, 6.2, 5.8, 5.5, 4.7, 4.2, 3.2, 3.4, 3.2, 2.7, 2.9, 3.7, 2.9, 3.4, 3.2, 3.6, 3.1, 3.6, 3.8, 4.6, 4.8, 4.3, 5.5, 5.3, 5.2, 4.9, 4.4, 3.2, 2.2, 2.0, 2.2, 3.0, + 3.4, 3.6, 4.0, 4.9, 5.2, 5.2, 5.2, 5.5, 5.8, 6.5, 9.0, 13.2, 16.9, 19.3, 20.8, 21.7, 22.3, 22.1, 22.0, 21.5, 21.2, 20.9, 20.2, 20.0, 19.3, 18.9, 18.2, 17.4, 16.4, 15.3, 14.2, 12.8, 11.7, 11.2, 21.7, 21.5, 21.5, 22.1, 23.0, 23.7, 23.8, + 23.6, 23.0, 22.0, 20.5, 19.5, 19.2, 19.3, 19.3, 19.5, 19.5, 20.0, 20.9, 21.9, 23.2, 24.7, 26.1, 26.9, 26.3, 25.4, 24.6, 25.0, 25.6, 26.1, 25.5, 24.4, 22.9, 20.5, 17.9, 15.9, 14.8, 14.3, 14.2, 14.8 + ], + "winddirection_10m": [ + 247, 241, 244, 252, 240, 229, 245, 251, 234, 245, 256, 256, 261, 284, 300, 332, 356, 59, 84, 90, 164, 207, 189, 284, 243, 198, 321, 304, 172, 180, 207, 180, 79, 30, 45, 72, 74, 68, 74, 73, 75, 54, 56, 56, 66, 67, 82, 57, 69, 62, 60, 58, + 69, 79, 83, 87, 101, 98, 97, 95, 88, 89, 97, 97, 97, 113, 122, 121, 117, 108, 117, 113, 120, 119, 120, 122, 117, 127, 135, 135, 131, 129, 117, 95, 79, 62, 56, 54, 55, 63, 90, 135, 171, 194, 198, 180, 153, 144, 146, 155, 164, 169, 173, + 186, 209, 225, 232, 237, 242, 246, 247, 248, 249, 249, 249, 249, 248, 247, 246, 246, 245, 246, 244, 240, 240, 240, 242, 245, 249, 249, 249, 251, 253, 253, 252, 251, 250, 249, 246, 245, 244, 243, 243, 242, 242, 242, 243, 245, 246, 247, + 247, 247, 247, 245, 245, 247, 250, 252, 254, 254, 254, 252, 248, 245, 244, 245, 246, 247 + ], + "relativehumidity_2m": [ + 83, 81, 81, 84, 83, 85, 86, 88, 89, 89, 85, 82, 77, 65, 62, 68, 71, 73, 81, 87, 91, 91, 93, 93, 94, 94, 94, 94, 99, 97, 94, 93, 94, 95, 96, 91, 86, 79, 77, 77, 76, 81, 85, 82, 89, 88, 87, 87, 89, 90, 90, 91, 92, 93, 94, 94, 95, 93, 89, + 83, 80, 75, 75, 75, 76, 79, 82, 83, 86, 90, 93, 93, 93, 95, 95, 95, 95, 97, 98, 99, 99, 98, 92, 84, 79, 73, 69, 67, 67, 69, 73, 77, 80, 83, 85, 87, 88, 87, 83, 76, 73, 76, 81, 85, 84, 82, 79, 76, 74, 72, 72, 72, 73, 73, 73, 74, 75, 77, + 78, 77, 75, 74, 73, 73, 74, 78, 84, 87, 81, 80, 78, 77, 77, 77, 77, 77, 78, 80, 82, 84, 85, 86, 86, 86, 87, 87, 87, 88, 88, 88, 88, 86, 82, 78, 73, 69, 65, 63, 64, 68, 72, 76, 81, 84, 85, 85, 85, 85 + ] + }, + "daily_units": { + "time": "unixtime", + "temperature_2m_max": "°C", + "temperature_2m_min": "°C", + "sunrise": "unixtime", + "sunset": "unixtime" + }, + "daily": { + "time": [1770418800, 1770505200, 1770591600, 1770678000, 1770764400, 1770850800, 1770937200], + "temperature_2m_max": [9.5, 6.8, 4.7, 7.0, 9.3, 7.2, 8.6], + "temperature_2m_min": [4.7, 3.0, 0.7, -1.3, 2.2, 4.9, 4.5], + "sunrise": [1770445978, 1770532288, 1770618596, 1770704904, 1770791211, 1770877515, 1770963818], + "sunset": [1770481348, 1770567844, 1770654340, 1770740836, 1770827331, 1770913826, 1771000321] + } +} diff --git a/tests/mocks/weather_owm_current.json b/tests/mocks/weather_owm_current.json new file mode 100644 index 0000000..021ae16 --- /dev/null +++ b/tests/mocks/weather_owm_current.json @@ -0,0 +1,28 @@ +{ + "coord": { "lon": 11.58, "lat": 48.14 }, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04d" }], + "base": "stations", + "main": { + "temp": -0.27, + "feels_like": -3.9, + "temp_min": -1.0, + "temp_max": 0.5, + "pressure": 1018, + "humidity": 54 + }, + "visibility": 10000, + "wind": { "speed": 3.09, "deg": 220 }, + "clouds": { "all": 100 }, + "dt": 1744200000, + "sys": { + "type": 2, + "id": 2002112, + "country": "DE", + "sunrise": 1744170000, + "sunset": 1744218000 + }, + "timezone": 7200, + "id": 2867714, + "name": "Munich", + "cod": 200 +} diff --git a/tests/mocks/weather_owm_forecast.json b/tests/mocks/weather_owm_forecast.json new file mode 100644 index 0000000..d9c3e3e --- /dev/null +++ b/tests/mocks/weather_owm_forecast.json @@ -0,0 +1,180 @@ +{ + "cod": "200", + "message": 0, + "cnt": 16, + "list": [ + { + "dt": 1744156800, + "main": { "temp": -1.0, "feels_like": -4.0, "temp_min": -1.5, "temp_max": -0.5, "pressure": 1018, "humidity": 60 }, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "clouds": { "all": 100 }, + "wind": { "speed": 3.0, "deg": 210 }, + "pop": 0.2, + "sys": { "pod": "n" }, + "dt_txt": "2026-04-09 00:00:00" + }, + { + "dt": 1744167600, + "main": { "temp": -1.2, "feels_like": -4.2, "temp_min": -1.5, "temp_max": -0.9, "pressure": 1018, "humidity": 62 }, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "clouds": { "all": 100 }, + "wind": { "speed": 3.1, "deg": 215 }, + "pop": 0.2, + "sys": { "pod": "n" }, + "dt_txt": "2026-04-09 03:00:00" + }, + { + "dt": 1744178400, + "main": { "temp": -0.5, "feels_like": -3.5, "temp_min": -1.0, "temp_max": 0.0, "pressure": 1019, "humidity": 58 }, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04d" }], + "clouds": { "all": 95 }, + "wind": { "speed": 2.8, "deg": 220 }, + "pop": 0.3, + "sys": { "pod": "d" }, + "dt_txt": "2026-04-09 06:00:00" + }, + { + "dt": 1744189200, + "main": { "temp": 1.0, "feels_like": -2.0, "temp_min": 0.5, "temp_max": 1.5, "pressure": 1019, "humidity": 55 }, + "weather": [{ "id": 500, "main": "Rain", "description": "light rain", "icon": "10d" }], + "clouds": { "all": 90 }, + "wind": { "speed": 2.5, "deg": 225 }, + "pop": 0.8, + "rain": { "3h": 0.6 }, + "sys": { "pod": "d" }, + "dt_txt": "2026-04-09 09:00:00" + }, + { + "dt": 1744200000, + "main": { "temp": 2.0, "feels_like": -1.0, "temp_min": 1.5, "temp_max": 2.5, "pressure": 1018, "humidity": 52 }, + "weather": [{ "id": 500, "main": "Rain", "description": "light rain", "icon": "10d" }], + "clouds": { "all": 88 }, + "wind": { "speed": 2.4, "deg": 230 }, + "pop": 0.9, + "rain": { "3h": 0.6 }, + "sys": { "pod": "d" }, + "dt_txt": "2026-04-09 12:00:00" + }, + { + "dt": 1744210800, + "main": { "temp": 1.5, "feels_like": -1.5, "temp_min": 1.0, "temp_max": 2.0, "pressure": 1018, "humidity": 54 }, + "weather": [{ "id": 500, "main": "Rain", "description": "light rain", "icon": "10d" }], + "clouds": { "all": 90 }, + "wind": { "speed": 2.6, "deg": 228 }, + "pop": 0.8, + "sys": { "pod": "d" }, + "dt_txt": "2026-04-09 15:00:00" + }, + { + "dt": 1744221600, + "main": { "temp": 0.8, "feels_like": -2.2, "temp_min": 0.5, "temp_max": 1.2, "pressure": 1018, "humidity": 57 }, + "weather": [{ "id": 500, "main": "Rain", "description": "light rain", "icon": "10d" }], + "clouds": { "all": 92 }, + "wind": { "speed": 2.7, "deg": 222 }, + "pop": 0.6, + "sys": { "pod": "d" }, + "dt_txt": "2026-04-09 18:00:00" + }, + { + "dt": 1744232400, + "main": { "temp": -0.2, "feels_like": -3.2, "temp_min": -0.5, "temp_max": 0.1, "pressure": 1019, "humidity": 60 }, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "clouds": { "all": 95 }, + "wind": { "speed": 2.9, "deg": 218 }, + "pop": 0.3, + "sys": { "pod": "n" }, + "dt_txt": "2026-04-09 21:00:00" + }, + { + "dt": 1744243200, + "main": { "temp": 0.5, "feels_like": -2.5, "temp_min": 0.0, "temp_max": 1.0, "pressure": 1020, "humidity": 58 }, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "clouds": { "all": 85 }, + "wind": { "speed": 2.5, "deg": 200 }, + "pop": 0.1, + "sys": { "pod": "n" }, + "dt_txt": "2026-04-10 00:00:00" + }, + { + "dt": 1744254000, + "main": { "temp": 1.0, "feels_like": -2.0, "temp_min": 0.5, "temp_max": 1.5, "pressure": 1021, "humidity": 56 }, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "clouds": { "all": 80 }, + "wind": { "speed": 2.3, "deg": 205 }, + "pop": 0.1, + "sys": { "pod": "n" }, + "dt_txt": "2026-04-10 03:00:00" + }, + { + "dt": 1744264800, + "main": { "temp": 2.0, "feels_like": -1.0, "temp_min": 1.5, "temp_max": 2.5, "pressure": 1021, "humidity": 53 }, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04d" }], + "clouds": { "all": 75 }, + "wind": { "speed": 2.1, "deg": 210 }, + "pop": 0.1, + "sys": { "pod": "d" }, + "dt_txt": "2026-04-10 06:00:00" + }, + { + "dt": 1744275600, + "main": { "temp": 3.5, "feels_like": 0.5, "temp_min": 3.0, "temp_max": 4.0, "pressure": 1020, "humidity": 50 }, + "weather": [{ "id": 520, "main": "Rain", "description": "light shower rain", "icon": "09d" }], + "clouds": { "all": 70 }, + "wind": { "speed": 2.0, "deg": 215 }, + "pop": 0.5, + "snow": { "3h": 0.5 }, + "sys": { "pod": "d" }, + "dt_txt": "2026-04-10 09:00:00" + }, + { + "dt": 1744286400, + "main": { "temp": 5.0, "feels_like": 2.0, "temp_min": 4.5, "temp_max": 5.5, "pressure": 1019, "humidity": 48 }, + "weather": [{ "id": 520, "main": "Rain", "description": "light shower rain", "icon": "09d" }], + "clouds": { "all": 65 }, + "wind": { "speed": 1.9, "deg": 220 }, + "pop": 0.4, + "sys": { "pod": "d" }, + "dt_txt": "2026-04-10 12:00:00" + }, + { + "dt": 1744297200, + "main": { "temp": 4.5, "feels_like": 1.5, "temp_min": 4.0, "temp_max": 5.0, "pressure": 1019, "humidity": 50 }, + "weather": [{ "id": 520, "main": "Rain", "description": "light shower rain", "icon": "09d" }], + "clouds": { "all": 68 }, + "wind": { "speed": 2.0, "deg": 218 }, + "pop": 0.4, + "sys": { "pod": "d" }, + "dt_txt": "2026-04-10 15:00:00" + }, + { + "dt": 1744308000, + "main": { "temp": 3.0, "feels_like": 0.0, "temp_min": 2.5, "temp_max": 3.5, "pressure": 1019, "humidity": 53 }, + "weather": [{ "id": 520, "main": "Rain", "description": "light shower rain", "icon": "09d" }], + "clouds": { "all": 72 }, + "wind": { "speed": 2.1, "deg": 212 }, + "pop": 0.3, + "sys": { "pod": "d" }, + "dt_txt": "2026-04-10 18:00:00" + }, + { + "dt": 1744318800, + "main": { "temp": 1.5, "feels_like": -1.5, "temp_min": 1.0, "temp_max": 2.0, "pressure": 1020, "humidity": 56 }, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "clouds": { "all": 80 }, + "wind": { "speed": 2.2, "deg": 208 }, + "pop": 0.2, + "sys": { "pod": "n" }, + "dt_txt": "2026-04-10 21:00:00" + } + ], + "city": { + "id": 2867714, + "name": "Munich", + "coord": { "lat": 48.14, "lon": 11.58 }, + "country": "DE", + "population": 1260391, + "timezone": 0, + "sunrise": 1744170000, + "sunset": 1744218000 + } +} diff --git a/tests/mocks/weather_owm_onecall.json b/tests/mocks/weather_owm_onecall.json new file mode 100644 index 0000000..0696e34 --- /dev/null +++ b/tests/mocks/weather_owm_onecall.json @@ -0,0 +1,970 @@ +{ + "lat": 40.7767, + "lon": -73.9713, + "timezone": "America/New_York", + "timezone_offset": -18000, + "current": { + "dt": 1770414297, + "sunrise": 1770379257, + "sunset": 1770416341, + "temp": -0.27, + "feels_like": -3.9, + "pressure": 1004, + "humidity": 54, + "dew_point": -7.54, + "uvi": 0, + "clouds": 75, + "visibility": 10000, + "wind_speed": 3.09, + "wind_deg": 220, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }] + }, + "hourly": [ + { + "dt": 1770411600, + "temp": -0.66, + "feels_like": -3.52, + "pressure": 1004, + "humidity": 61, + "dew_point": -6.5, + "uvi": 0.18, + "clouds": 80, + "visibility": 10000, + "wind_speed": 2.24, + "wind_deg": 187, + "wind_gust": 3.73, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770415200, + "temp": -0.27, + "feels_like": -2.6, + "pressure": 1004, + "humidity": 54, + "dew_point": -7.54, + "uvi": 0, + "clouds": 75, + "visibility": 10000, + "wind_speed": 1.87, + "wind_deg": 169, + "wind_gust": 3.26, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770418800, + "temp": -1.03, + "feels_like": -3.4, + "pressure": 1004, + "humidity": 62, + "dew_point": -6.67, + "uvi": 0, + "clouds": 80, + "visibility": 10000, + "wind_speed": 1.81, + "wind_deg": 190, + "wind_gust": 3.93, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770422400, + "temp": -1.54, + "feels_like": -5.39, + "pressure": 1004, + "humidity": 71, + "dew_point": -5.59, + "uvi": 0, + "clouds": 85, + "wind_speed": 3.04, + "wind_deg": 232, + "wind_gust": 6.25, + "weather": [{ "id": 600, "main": "Snow", "description": "light snow", "icon": "13n" }], + "pop": 0.2, + "snow": { "1h": 0.13 } + }, + { + "dt": 1770426000, + "temp": -2.25, + "feels_like": -5.2, + "pressure": 1004, + "humidity": 80, + "dew_point": -4.89, + "uvi": 0, + "clouds": 90, + "visibility": 235, + "wind_speed": 2.09, + "wind_deg": 224, + "wind_gust": 6.04, + "weather": [{ "id": 600, "main": "Snow", "description": "light snow", "icon": "13n" }], + "pop": 1, + "snow": { "1h": 0.18 } + }, + { + "dt": 1770429600, + "temp": -2.79, + "feels_like": -6.29, + "pressure": 1003, + "humidity": 89, + "dew_point": -4.17, + "uvi": 0, + "clouds": 95, + "visibility": 177, + "wind_speed": 2.47, + "wind_deg": 217, + "wind_gust": 6.99, + "weather": [{ "id": 600, "main": "Snow", "description": "light snow", "icon": "13n" }], + "pop": 1, + "snow": { "1h": 0.19 } + }, + { + "dt": 1770433200, + "temp": -3.46, + "feels_like": -7.71, + "pressure": 1002, + "humidity": 96, + "dew_point": -4.21, + "uvi": 0, + "clouds": 100, + "visibility": 501, + "wind_speed": 3.05, + "wind_deg": 236, + "wind_gust": 7.82, + "weather": [{ "id": 600, "main": "Snow", "description": "light snow", "icon": "13n" }], + "pop": 1, + "snow": { "1h": 0.19 } + }, + { + "dt": 1770436800, + "temp": -3.88, + "feels_like": -7.67, + "pressure": 1001, + "humidity": 97, + "dew_point": -4.47, + "uvi": 0, + "clouds": 100, + "visibility": 424, + "wind_speed": 2.54, + "wind_deg": 234, + "wind_gust": 7.49, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0.8 + }, + { + "dt": 1770440400, + "temp": -3.78, + "feels_like": -7.68, + "pressure": 1001, + "humidity": 96, + "dew_point": -4.57, + "uvi": 0, + "clouds": 100, + "visibility": 2576, + "wind_speed": 2.66, + "wind_deg": 231, + "wind_gust": 7.51, + "weather": [{ "id": 600, "main": "Snow", "description": "light snow", "icon": "13n" }], + "pop": 1, + "snow": { "1h": 0.14 } + }, + { + "dt": 1770444000, + "temp": -4.1, + "feels_like": -8.05, + "pressure": 1000, + "humidity": 96, + "dew_point": -4.92, + "uvi": 0, + "clouds": 100, + "visibility": 305, + "wind_speed": 2.65, + "wind_deg": 237, + "wind_gust": 7.6, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0.8 + }, + { + "dt": 1770447600, + "temp": -4.12, + "feels_like": -8.44, + "pressure": 1000, + "humidity": 95, + "dew_point": -4.97, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.99, + "wind_deg": 247, + "wind_gust": 7.23, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770451200, + "temp": -4.9, + "feels_like": -9.33, + "pressure": 999, + "humidity": 95, + "dew_point": -5.82, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 2.95, + "wind_deg": 256, + "wind_gust": 7.85, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770454800, + "temp": -4.84, + "feels_like": -9.36, + "pressure": 999, + "humidity": 94, + "dew_point": -5.93, + "uvi": 0, + "clouds": 100, + "visibility": 4481, + "wind_speed": 3.04, + "wind_deg": 273, + "wind_gust": 10.32, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770458400, + "temp": -5.46, + "feels_like": -12.46, + "pressure": 1000, + "humidity": 85, + "dew_point": -7.96, + "uvi": 0, + "clouds": 100, + "visibility": 9905, + "wind_speed": 7.66, + "wind_deg": 316, + "wind_gust": 11.92, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770462000, + "temp": -9.55, + "feels_like": -16.55, + "pressure": 1001, + "humidity": 76, + "dew_point": -13.6, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 8.25, + "wind_deg": 315, + "wind_gust": 15.03, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770465600, + "temp": -12.37, + "feels_like": -19.37, + "pressure": 1002, + "humidity": 76, + "dew_point": -16.71, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 8.55, + "wind_deg": 309, + "wind_gust": 15.72, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770469200, + "temp": -14.13, + "feels_like": -21.13, + "pressure": 1003, + "humidity": 76, + "dew_point": -18.65, + "uvi": 0.27, + "clouds": 100, + "visibility": 10000, + "wind_speed": 8.44, + "wind_deg": 308, + "wind_gust": 16.05, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770472800, + "temp": -13.41, + "feels_like": -20.41, + "pressure": 1004, + "humidity": 76, + "dew_point": -17.82, + "uvi": 0.72, + "clouds": 56, + "visibility": 10000, + "wind_speed": 8.4, + "wind_deg": 311, + "wind_gust": 16, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770476400, + "temp": -12.76, + "feels_like": -19.76, + "pressure": 1004, + "humidity": 78, + "dew_point": -16.79, + "uvi": 1.2, + "clouds": 52, + "visibility": 10000, + "wind_speed": 8.67, + "wind_deg": 317, + "wind_gust": 15.12, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770480000, + "temp": -12.33, + "feels_like": -19.33, + "pressure": 1005, + "humidity": 83, + "dew_point": -15.61, + "uvi": 1.56, + "clouds": 64, + "visibility": 3083, + "wind_speed": 8.8, + "wind_deg": 321, + "wind_gust": 15.19, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770483600, + "temp": -11.87, + "feels_like": -18.87, + "pressure": 1004, + "humidity": 82, + "dew_point": -15.28, + "uvi": 1.56, + "clouds": 71, + "visibility": 8917, + "wind_speed": 8.88, + "wind_deg": 322, + "wind_gust": 15.55, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770487200, + "temp": -11.69, + "feels_like": -18.69, + "pressure": 1005, + "humidity": 79, + "dew_point": -15.5, + "uvi": 1.57, + "clouds": 76, + "visibility": 10000, + "wind_speed": 9.46, + "wind_deg": 324, + "wind_gust": 16.31, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770490800, + "temp": -11.62, + "feels_like": -18.62, + "pressure": 1005, + "humidity": 77, + "dew_point": -15.73, + "uvi": 1.11, + "clouds": 100, + "visibility": 10000, + "wind_speed": 9.8, + "wind_deg": 327, + "wind_gust": 16.18, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770494400, + "temp": -12, + "feels_like": -19, + "pressure": 1006, + "humidity": 75, + "dew_point": -16.48, + "uvi": 0.59, + "clouds": 100, + "visibility": 10000, + "wind_speed": 9.97, + "wind_deg": 328, + "wind_gust": 16.89, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770498000, + "temp": -12.71, + "feels_like": -19.71, + "pressure": 1007, + "humidity": 74, + "dew_point": -17.39, + "uvi": 0.19, + "clouds": 100, + "visibility": 10000, + "wind_speed": 10.12, + "wind_deg": 328, + "wind_gust": 17.9, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770501600, + "temp": -13.43, + "feels_like": -20.43, + "pressure": 1009, + "humidity": 72, + "dew_point": -18.44, + "uvi": 0, + "clouds": 100, + "visibility": 10000, + "wind_speed": 10.09, + "wind_deg": 329, + "wind_gust": 18.24, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770505200, + "temp": -14.05, + "feels_like": -21.05, + "pressure": 1011, + "humidity": 72, + "dew_point": -19.28, + "uvi": 0, + "clouds": 99, + "visibility": 10000, + "wind_speed": 10.11, + "wind_deg": 329, + "wind_gust": 18.4, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770508800, + "temp": -14.31, + "feels_like": -21.31, + "pressure": 1013, + "humidity": 72, + "dew_point": -19.61, + "uvi": 0, + "clouds": 97, + "visibility": 10000, + "wind_speed": 10.18, + "wind_deg": 328, + "wind_gust": 18.77, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770512400, + "temp": -14.29, + "feels_like": -21.29, + "pressure": 1014, + "humidity": 72, + "dew_point": -19.51, + "uvi": 0, + "clouds": 97, + "visibility": 10000, + "wind_speed": 9.7, + "wind_deg": 330, + "wind_gust": 18.29, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770516000, + "temp": -14.14, + "feels_like": -21.14, + "pressure": 1015, + "humidity": 72, + "dew_point": -19.28, + "uvi": 0, + "clouds": 98, + "visibility": 10000, + "wind_speed": 9.38, + "wind_deg": 330, + "wind_gust": 17.25, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770519600, + "temp": -14.08, + "feels_like": -21.08, + "pressure": 1016, + "humidity": 73, + "dew_point": -19.05, + "uvi": 0, + "clouds": 99, + "visibility": 10000, + "wind_speed": 8.71, + "wind_deg": 329, + "wind_gust": 16.58, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770523200, + "temp": -14.19, + "feels_like": -21.19, + "pressure": 1016, + "humidity": 74, + "dew_point": -19.05, + "uvi": 0, + "clouds": 99, + "visibility": 10000, + "wind_speed": 8.24, + "wind_deg": 328, + "wind_gust": 15.71, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770526800, + "temp": -14.38, + "feels_like": -21.38, + "pressure": 1017, + "humidity": 74, + "dew_point": -19.34, + "uvi": 0, + "clouds": 99, + "visibility": 10000, + "wind_speed": 8.08, + "wind_deg": 326, + "wind_gust": 15.77, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770530400, + "temp": -14.74, + "feels_like": -21.74, + "pressure": 1018, + "humidity": 74, + "dew_point": -19.74, + "uvi": 0, + "clouds": 99, + "visibility": 10000, + "wind_speed": 7.81, + "wind_deg": 324, + "wind_gust": 15.4, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770534000, + "temp": -15.13, + "feels_like": -22.13, + "pressure": 1019, + "humidity": 73, + "dew_point": -20.25, + "uvi": 0, + "clouds": 93, + "visibility": 10000, + "wind_speed": 7.57, + "wind_deg": 325, + "wind_gust": 15.39, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770537600, + "temp": -15.57, + "feels_like": -22.57, + "pressure": 1019, + "humidity": 73, + "dew_point": -20.69, + "uvi": 0, + "clouds": 94, + "visibility": 10000, + "wind_speed": 7.36, + "wind_deg": 323, + "wind_gust": 15.29, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770541200, + "temp": -15.98, + "feels_like": -22.98, + "pressure": 1019, + "humidity": 73, + "dew_point": -21.2, + "uvi": 0, + "clouds": 88, + "visibility": 10000, + "wind_speed": 7.37, + "wind_deg": 321, + "wind_gust": 15.7, + "weather": [{ "id": 804, "main": "Clouds", "description": "overcast clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770544800, + "temp": -16.36, + "feels_like": -23.36, + "pressure": 1020, + "humidity": 73, + "dew_point": -21.64, + "uvi": 0, + "clouds": 69, + "visibility": 10000, + "wind_speed": 7.62, + "wind_deg": 322, + "wind_gust": 16.29, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770548400, + "temp": -16.63, + "feels_like": -23.63, + "pressure": 1021, + "humidity": 74, + "dew_point": -21.86, + "uvi": 0, + "clouds": 57, + "visibility": 10000, + "wind_speed": 7.52, + "wind_deg": 323, + "wind_gust": 16.46, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04n" }], + "pop": 0 + }, + { + "dt": 1770552000, + "temp": -16.84, + "feels_like": -23.84, + "pressure": 1022, + "humidity": 74, + "dew_point": -22.06, + "uvi": 0, + "clouds": 48, + "visibility": 10000, + "wind_speed": 7.59, + "wind_deg": 324, + "wind_gust": 16.2, + "weather": [{ "id": 802, "main": "Clouds", "description": "scattered clouds", "icon": "03d" }], + "pop": 0 + }, + { + "dt": 1770555600, + "temp": -16.57, + "feels_like": -23.57, + "pressure": 1023, + "humidity": 74, + "dew_point": -21.63, + "uvi": 0.3, + "clouds": 2, + "visibility": 10000, + "wind_speed": 7.27, + "wind_deg": 325, + "wind_gust": 14.68, + "weather": [{ "id": 800, "main": "Clear", "description": "clear sky", "icon": "01d" }], + "pop": 0 + }, + { + "dt": 1770559200, + "temp": -15.7, + "feels_like": -22.7, + "pressure": 1023, + "humidity": 76, + "dew_point": -20.43, + "uvi": 0.77, + "clouds": 4, + "visibility": 10000, + "wind_speed": 7.26, + "wind_deg": 324, + "wind_gust": 13.65, + "weather": [{ "id": 800, "main": "Clear", "description": "clear sky", "icon": "01d" }], + "pop": 0 + }, + { + "dt": 1770562800, + "temp": -14.48, + "feels_like": -21.48, + "pressure": 1023, + "humidity": 77, + "dew_point": -18.94, + "uvi": 1.42, + "clouds": 5, + "visibility": 10000, + "wind_speed": 6.7, + "wind_deg": 324, + "wind_gust": 12.19, + "weather": [{ "id": 800, "main": "Clear", "description": "clear sky", "icon": "01d" }], + "pop": 0 + }, + { + "dt": 1770566400, + "temp": -13.34, + "feels_like": -20.34, + "pressure": 1023, + "humidity": 73, + "dew_point": -18.23, + "uvi": 1.98, + "clouds": 5, + "visibility": 10000, + "wind_speed": 6.59, + "wind_deg": 327, + "wind_gust": 10.06, + "weather": [{ "id": 800, "main": "Clear", "description": "clear sky", "icon": "01d" }], + "pop": 0 + }, + { + "dt": 1770570000, + "temp": -11.94, + "feels_like": -18.94, + "pressure": 1022, + "humidity": 74, + "dew_point": -16.63, + "uvi": 2.19, + "clouds": 6, + "visibility": 10000, + "wind_speed": 6.3, + "wind_deg": 325, + "wind_gust": 9.29, + "weather": [{ "id": 800, "main": "Clear", "description": "clear sky", "icon": "01d" }], + "pop": 0 + }, + { + "dt": 1770573600, + "temp": -10.51, + "feels_like": -17.51, + "pressure": 1022, + "humidity": 75, + "dew_point": -14.88, + "uvi": 1.95, + "clouds": 7, + "visibility": 10000, + "wind_speed": 5.98, + "wind_deg": 321, + "wind_gust": 8.89, + "weather": [{ "id": 800, "main": "Clear", "description": "clear sky", "icon": "01d" }], + "pop": 0 + }, + { + "dt": 1770577200, + "temp": -9.42, + "feels_like": -16.42, + "pressure": 1022, + "humidity": 75, + "dew_point": -13.63, + "uvi": 1.39, + "clouds": 72, + "visibility": 10000, + "wind_speed": 5.92, + "wind_deg": 317, + "wind_gust": 8.77, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + }, + { + "dt": 1770580800, + "temp": -8.96, + "feels_like": -15.96, + "pressure": 1023, + "humidity": 79, + "dew_point": -12.4, + "uvi": 0.73, + "clouds": 80, + "visibility": 10000, + "wind_speed": 6.03, + "wind_deg": 312, + "wind_gust": 10.13, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "pop": 0 + } + ], + "daily": [ + { + "dt": 1770397200, + "sunrise": 1770379257, + "sunset": 1770416341, + "moonrise": 1770435960, + "moonset": 1770386880, + "moon_phase": 0.66, + "summary": "Expect a day of partly cloudy with snow", + "temp": { "day": -2.5, "min": -11.86, "max": -0.27, "night": -3.88, "eve": -1.03, "morn": -10.39 }, + "feels_like": { "day": -2.5, "night": -7.67, "eve": -3.4, "morn": -14.33 }, + "pressure": 1006, + "humidity": 88, + "dew_point": -4.3, + "wind_speed": 3.05, + "wind_deg": 236, + "wind_gust": 7.82, + "weather": [{ "id": 600, "main": "Snow", "description": "light snow", "icon": "13d" }], + "clouds": 95, + "pop": 1, + "snow": 0.69, + "uvi": 2.22 + }, + { + "dt": 1770483600, + "sunrise": 1770465590, + "sunset": 1770502816, + "moonrise": 1770526200, + "moonset": 1770474600, + "moon_phase": 0.69, + "summary": "There will be snow until morning, then partly cloudy", + "temp": { "day": -11.87, "min": -14.31, "max": -3.78, "night": -14.19, "eve": -14.05, "morn": -9.55 }, + "feels_like": { "day": -18.87, "night": -21.19, "eve": -21.05, "morn": -16.55 }, + "pressure": 1004, + "humidity": 82, + "dew_point": -15.28, + "wind_speed": 10.18, + "wind_deg": 328, + "wind_gust": 18.77, + "weather": [{ "id": 600, "main": "Snow", "description": "light snow", "icon": "13d" }], + "clouds": 71, + "pop": 1, + "snow": 0.14, + "uvi": 1.57 + }, + { + "dt": 1770570000, + "sunrise": 1770551923, + "sunset": 1770589291, + "moonrise": 0, + "moonset": 1770562440, + "moon_phase": 0.72, + "summary": "Expect a day of partly cloudy with clear spells", + "temp": { "day": -11.94, "min": -16.84, "max": -8.96, "night": -13.75, "eve": -11.11, "morn": -16.63 }, + "feels_like": { "day": -18.94, "night": -20.33, "eve": -18.11, "morn": -23.63 }, + "pressure": 1022, + "humidity": 74, + "dew_point": -16.63, + "wind_speed": 8.08, + "wind_deg": 326, + "wind_gust": 16.46, + "weather": [{ "id": 800, "main": "Clear", "description": "clear sky", "icon": "01d" }], + "clouds": 6, + "pop": 0, + "uvi": 2.19 + }, + { + "dt": 1770656400, + "sunrise": 1770638253, + "sunset": 1770675765, + "moonrise": 1770616380, + "moonset": 1770650520, + "moon_phase": 0.75, + "summary": "The day will start with clear sky through the late morning hours, transitioning to partly cloudy", + "temp": { "day": -6.9, "min": -17.11, "max": -3.39, "night": -5.77, "eve": -7.87, "morn": -16.94 }, + "feels_like": { "day": -10.1, "night": -5.77, "eve": -7.87, "morn": -16.94 }, + "pressure": 1024, + "humidity": 78, + "dew_point": -10.38, + "wind_speed": 2.5, + "wind_deg": 319, + "wind_gust": 7.03, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "clouds": 83, + "pop": 0, + "uvi": 2.7 + }, + { + "dt": 1770742800, + "sunrise": 1770724583, + "sunset": 1770762240, + "moonrise": 1770706560, + "moonset": 1770739020, + "moon_phase": 0.79, + "summary": "There will be partly cloudy today", + "temp": { "day": -1.46, "min": -10, "max": -0.51, "night": -3.8, "eve": -1.57, "morn": -10 }, + "feels_like": { "day": -1.46, "night": -6.36, "eve": -3.98, "morn": -13.81 }, + "pressure": 1020, + "humidity": 94, + "dew_point": -2.47, + "wind_speed": 1.83, + "wind_deg": 2, + "wind_gust": 2.92, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "clouds": 56, + "pop": 0, + "uvi": 3.1 + }, + { + "dt": 1770829200, + "sunrise": 1770810911, + "sunset": 1770848714, + "moonrise": 1770796620, + "moonset": 1770827880, + "moon_phase": 0.82, + "summary": "The day will start with partly cloudy with snow through the late morning hours, transitioning to partly cloudy with rain", + "temp": { "day": 0.7, "min": -4.02, "max": 2.06, "night": -0.6, "eve": 2.06, "morn": -0.03 }, + "feels_like": { "day": 0.7, "night": -5, "eve": -2, "morn": -3.1 }, + "pressure": 1009, + "humidity": 100, + "dew_point": 0.64, + "wind_speed": 4.4, + "wind_deg": 311, + "wind_gust": 11.56, + "weather": [{ "id": 616, "main": "Snow", "description": "rain and snow", "icon": "13d" }], + "clouds": 100, + "pop": 1, + "rain": 4.38, + "snow": 2.17, + "uvi": 4 + }, + { + "dt": 1770915600, + "sunrise": 1770897237, + "sunset": 1770935188, + "moonrise": 1770886440, + "moonset": 1770917220, + "moon_phase": 0.85, + "summary": "There will be partly cloudy today", + "temp": { "day": 0.2, "min": -4.63, "max": 0.2, "night": -4.63, "eve": -2.9, "morn": -3.67 }, + "feels_like": { "day": -4.8, "night": -10.67, "eve": -8.49, "morn": -8.22 }, + "pressure": 1012, + "humidity": 81, + "dew_point": -2.81, + "wind_speed": 5.52, + "wind_deg": 301, + "wind_gust": 12.97, + "weather": [{ "id": 802, "main": "Clouds", "description": "scattered clouds", "icon": "03d" }], + "clouds": 50, + "pop": 0, + "uvi": 4 + }, + { + "dt": 1771002000, + "sunrise": 1770983562, + "sunset": 1771021662, + "moonrise": 1770975780, + "moonset": 1771007160, + "moon_phase": 0.88, + "summary": "Expect a day of partly cloudy with clear spells", + "temp": { "day": 0.38, "min": -6.39, "max": 0.95, "night": -1.17, "eve": -0.91, "morn": -6.39 }, + "feels_like": { "day": -3.92, "night": -6.3, "eve": -5.42, "morn": -12.89 }, + "pressure": 1017, + "humidity": 80, + "dew_point": -2.71, + "wind_speed": 5.27, + "wind_deg": 298, + "wind_gust": 14.69, + "weather": [{ "id": 803, "main": "Clouds", "description": "broken clouds", "icon": "04d" }], + "clouds": 74, + "pop": 0, + "uvi": 4 + } + ] +} diff --git a/tests/mocks/weather_pirateweather.json b/tests/mocks/weather_pirateweather.json new file mode 100644 index 0000000..75a1396 --- /dev/null +++ b/tests/mocks/weather_pirateweather.json @@ -0,0 +1,1665 @@ +{ + "latitude": 40.7128, + "longitude": -74.006, + "timezone": "America/New_York", + "offset": -5.0, + "elevation": 19, + "currently": { + "time": 1770414300, + "summary": "Overcast", + "icon": "cloudy", + "nearestStormDistance": 115.95, + "nearestStormBearing": 233, + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipType": "none", + "temperature": -0.26, + "apparentTemperature": -4.77, + "dewPoint": -7.89, + "humidity": 0.56, + "pressure": 1004.92, + "windSpeed": 2.32, + "windGust": 3.2, + "windBearing": 166, + "cloudCover": 0.97, + "uvIndex": 0.54, + "visibility": 16.09, + "ozone": 401.41 + }, + "minutely": { + "summary": "Overcast for the hour.", + "icon": "cloudy", + "data": [ + { "time": 1770414300, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414360, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414420, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414480, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414540, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414600, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414660, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414720, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414780, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414840, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414900, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770414960, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415020, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415080, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415140, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415200, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415260, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415320, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415380, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415440, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415500, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415560, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415620, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415680, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415740, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415800, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415860, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415920, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770415980, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416040, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416100, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416160, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416220, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416280, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416340, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416400, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416460, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416520, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416580, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416640, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416700, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416760, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416820, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416880, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770416940, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417000, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417060, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417120, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417180, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417240, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417300, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417360, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417420, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417480, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417540, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417600, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417660, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417720, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417780, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417840, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" }, + { "time": 1770417900, "precipIntensity": 0.0, "precipProbability": 0.0, "precipIntensityError": 0.0, "precipType": "none" } + ] + }, + "hourly": { + "summary": "Hazy tonight and windy starting tomorrow morning.", + "icon": "fog", + "data": [ + { + "time": 1770411600, + "summary": "Mostly Cloudy", + "icon": "partly-cloudy-day", + "precipIntensity": 0.0, + "precipProbability": 0.16, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -2.19, + "apparentTemperature": -6.47, + "dewPoint": -6.77, + "humidity": 0.7, + "pressure": 1005.22, + "windSpeed": 3.6, + "windGust": 4.7, + "windBearing": 200, + "cloudCover": 0.77, + "uvIndex": 1.12, + "visibility": 16.09, + "ozone": 402.15, + "nearestStormDistance": 108.83, + "nearestStormBearing": 258 + }, + { + "time": 1770415200, + "summary": "Mostly Cloudy", + "icon": "partly-cloudy-day", + "precipIntensity": 0.0, + "precipProbability": 0.16, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -2.11, + "apparentTemperature": -6.3, + "dewPoint": -6.64, + "humidity": 0.71, + "pressure": 1004.82, + "windSpeed": 3.6, + "windGust": 4.77, + "windBearing": 207, + "cloudCover": 0.8, + "uvIndex": 0.35, + "visibility": 14.72, + "ozone": 401.17, + "nearestStormDistance": 118.33, + "nearestStormBearing": 233 + }, + { + "time": 1770418800, + "summary": "Mostly Cloudy", + "icon": "partly-cloudy-night", + "precipIntensity": 0.0, + "precipProbability": 0.16, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -2.02, + "apparentTemperature": -6.13, + "dewPoint": -6.5, + "humidity": 0.71, + "pressure": 1004.19, + "windSpeed": 3.6, + "windGust": 4.83, + "windBearing": 213, + "cloudCover": 0.83, + "uvIndex": 0.01, + "visibility": 13.18, + "ozone": 403.38, + "nearestStormDistance": 63.25, + "nearestStormBearing": 270 + }, + { + "time": 1770422400, + "summary": "Mostly Cloudy", + "icon": "partly-cloudy-night", + "precipIntensity": 0.0, + "precipProbability": 0.16, + "precipIntensityError": 0.02, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -1.94, + "apparentTemperature": -5.96, + "dewPoint": -6.37, + "humidity": 0.72, + "pressure": 1004.33, + "windSpeed": 3.6, + "windGust": 4.9, + "windBearing": 220, + "cloudCover": 0.86, + "uvIndex": 0.0, + "visibility": 11.65, + "ozone": 406.37, + "nearestStormDistance": 34.89, + "nearestStormBearing": 225 + }, + { + "time": 1770426000, + "summary": "Mostly Cloudy", + "icon": "partly-cloudy-night", + "precipIntensity": 0.0, + "precipProbability": 0.22, + "precipIntensityError": 0.02, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -1.87, + "apparentTemperature": -6.11, + "dewPoint": -6.26, + "humidity": 0.73, + "pressure": 1003.94, + "windSpeed": 3.6, + "windGust": 4.93, + "windBearing": 223, + "cloudCover": 0.87, + "uvIndex": 0.0, + "visibility": 8.58, + "ozone": 408.33, + "nearestStormDistance": 21.08, + "nearestStormBearing": 270 + }, + { + "time": 1770429600, + "summary": "Overcast", + "icon": "cloudy", + "precipIntensity": 0.0, + "precipProbability": 0.27, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -1.79, + "apparentTemperature": -6.25, + "dewPoint": -6.15, + "humidity": 0.73, + "pressure": 1003.89, + "windSpeed": 3.6, + "windGust": 4.97, + "windBearing": 227, + "cloudCover": 0.88, + "uvIndex": 0.0, + "visibility": 5.5, + "ozone": 405.87, + "nearestStormDistance": 34.89, + "nearestStormBearing": 135 + }, + { + "time": 1770433200, + "summary": "Hazy", + "icon": "fog", + "precipIntensity": 0.0, + "precipProbability": 0.33, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -1.72, + "apparentTemperature": -6.4, + "dewPoint": -6.04, + "humidity": 0.74, + "pressure": 1003.77, + "windSpeed": 3.6, + "windGust": 5.0, + "windBearing": 230, + "cloudCover": 0.89, + "uvIndex": 0.0, + "visibility": 2.43, + "ozone": 406.71, + "nearestStormDistance": 21.08, + "nearestStormBearing": 90 + }, + { + "time": 1770436800, + "summary": "Hazy", + "icon": "fog", + "precipIntensity": 0.0, + "precipProbability": 0.33, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -1.89, + "apparentTemperature": -6.74, + "dewPoint": -6.17, + "humidity": 0.74, + "pressure": 1003.03, + "windSpeed": 3.87, + "windGust": 5.4, + "windBearing": 237, + "cloudCover": 0.86, + "uvIndex": 0.0, + "visibility": 2.73, + "ozone": 403.27, + "nearestStormDistance": 84.33, + "nearestStormBearing": 90 + }, + { + "time": 1770440400, + "summary": "Hazy", + "icon": "fog", + "precipIntensity": 0.0, + "precipProbability": 0.33, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -2.05, + "apparentTemperature": -7.07, + "dewPoint": -6.3, + "humidity": 0.73, + "pressure": 1002.42, + "windSpeed": 4.13, + "windGust": 5.8, + "windBearing": 243, + "cloudCover": 0.84, + "uvIndex": 0.0, + "visibility": 3.03, + "ozone": 408.05, + "nearestStormDistance": 105.41, + "nearestStormBearing": 90 + }, + { + "time": 1770444000, + "summary": "Hazy", + "icon": "fog", + "precipIntensity": 0.0, + "precipProbability": 0.33, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -2.22, + "apparentTemperature": -7.41, + "dewPoint": -6.43, + "humidity": 0.73, + "pressure": 1001.45, + "windSpeed": 4.4, + "windGust": 6.2, + "windBearing": 250, + "cloudCover": 0.81, + "uvIndex": 0.0, + "visibility": 3.33, + "ozone": 412.89, + "nearestStormDistance": 118.85, + "nearestStormBearing": 111 + }, + { + "time": 1770447600, + "summary": "Mostly Cloudy", + "icon": "partly-cloudy-night", + "precipIntensity": 0.0, + "precipProbability": 0.29, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -2.58, + "apparentTemperature": -8.29, + "dewPoint": -6.62, + "humidity": 0.74, + "pressure": 1000.84, + "windSpeed": 5.33, + "windGust": 7.5, + "windBearing": 260, + "cloudCover": 0.8, + "uvIndex": 0.0, + "visibility": 5.6, + "ozone": 417.97, + "nearestStormDistance": 118.85, + "nearestStormBearing": 111 + }, + { + "time": 1770451200, + "summary": "Mostly Cloudy", + "icon": "partly-cloudy-night", + "precipIntensity": 0.0, + "precipProbability": 0.24, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -2.95, + "apparentTemperature": -9.17, + "dewPoint": -6.82, + "humidity": 0.75, + "pressure": 1000.63, + "windSpeed": 6.27, + "windGust": 8.8, + "windBearing": 270, + "cloudCover": 0.78, + "uvIndex": 0.0, + "visibility": 8.4, + "ozone": 417.69, + "nearestStormDistance": 118.85, + "nearestStormBearing": 111 + }, + { + "time": 1770454800, + "summary": "Breezy and Mostly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.2, + "precipIntensityError": 0.04, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -3.31, + "apparentTemperature": -10.05, + "dewPoint": -7.01, + "humidity": 0.76, + "pressure": 1000.26, + "windSpeed": 7.2, + "windGust": 10.1, + "windBearing": 280, + "cloudCover": 0.77, + "uvIndex": 0.0, + "visibility": 2.3, + "ozone": 416.64, + "nearestStormDistance": 55.66, + "nearestStormBearing": 180 + }, + { + "time": 1770458400, + "summary": "Breezy and Mostly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.2, + "precipIntensityError": 0.04, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -4.07, + "apparentTemperature": -11.46, + "dewPoint": -8.19, + "humidity": 0.73, + "pressure": 1000.86, + "windSpeed": 8.13, + "windGust": 11.13, + "windBearing": 287, + "cloudCover": 0.73, + "uvIndex": 0.0, + "visibility": 2.5, + "ozone": 430.55, + "nearestStormDistance": 59.55, + "nearestStormBearing": 333 + }, + { + "time": 1770462000, + "summary": "Breezy and Mostly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.2, + "precipIntensityError": 0.04, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -4.82, + "apparentTemperature": -12.87, + "dewPoint": -9.36, + "humidity": 0.71, + "pressure": 1001.53, + "windSpeed": 9.07, + "windGust": 12.17, + "windBearing": 293, + "cloudCover": 0.69, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 444.39, + "nearestStormDistance": 63.25, + "nearestStormBearing": 90 + }, + { + "time": 1770465600, + "summary": "Windy and Mostly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.2, + "precipIntensityError": 0.04, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -5.58, + "apparentTemperature": -14.28, + "dewPoint": -10.54, + "humidity": 0.68, + "pressure": 1002.21, + "windSpeed": 10.0, + "windGust": 13.2, + "windBearing": 300, + "cloudCover": 0.65, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 445.07, + "nearestStormDistance": 101.31, + "nearestStormBearing": 63 + }, + { + "time": 1770469200, + "summary": "Windy and Partly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.16, + "precipIntensityError": 0.04, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -6.72, + "apparentTemperature": -15.95, + "dewPoint": -11.67, + "humidity": 0.68, + "pressure": 1003.26, + "windSpeed": 10.4, + "windGust": 14.17, + "windBearing": 303, + "cloudCover": 0.57, + "uvIndex": 0.21, + "visibility": 16.09, + "ozone": 446.52, + "nearestStormDistance": 118.85, + "nearestStormBearing": 111 + }, + { + "time": 1770472800, + "summary": "Windy and Partly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.11, + "precipIntensityError": 0.04, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -7.85, + "apparentTemperature": -17.63, + "dewPoint": -12.79, + "humidity": 0.68, + "pressure": 1003.8, + "windSpeed": 10.8, + "windGust": 15.13, + "windBearing": 307, + "cloudCover": 0.49, + "uvIndex": 1.08, + "visibility": 16.09, + "ozone": 451.89, + "nearestStormDistance": 68.99, + "nearestStormBearing": 108 + }, + { + "time": 1770476400, + "summary": "Windy and Partly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.07, + "precipIntensityError": 0.04, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -8.99, + "apparentTemperature": -19.3, + "dewPoint": -13.92, + "humidity": 0.68, + "pressure": 1004.89, + "windSpeed": 11.2, + "windGust": 16.1, + "windBearing": 310, + "cloudCover": 0.41, + "uvIndex": 2.15, + "visibility": 6.5, + "ozone": 449.97, + "nearestStormDistance": 63.25, + "nearestStormBearing": 90 + }, + { + "time": 1770480000, + "summary": "Windy and Partly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.07, + "precipIntensityError": 0.04, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.07, + "apparentTemperature": -19.26, + "dewPoint": -14.3, + "humidity": 0.66, + "pressure": 1005.63, + "windSpeed": 11.07, + "windGust": 16.07, + "windBearing": 313, + "cloudCover": 0.39, + "uvIndex": 2.87, + "visibility": 4.3, + "ozone": 447.68, + "nearestStormDistance": 129.75, + "nearestStormBearing": 80 + }, + { + "time": 1770483600, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.07, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.14, + "apparentTemperature": -19.23, + "dewPoint": -14.68, + "humidity": 0.64, + "pressure": 1006.14, + "windSpeed": 10.93, + "windGust": 16.03, + "windBearing": 317, + "cloudCover": 0.36, + "uvIndex": 3.23, + "visibility": 9.5, + "ozone": 460.33, + "nearestStormDistance": 63.25, + "nearestStormBearing": 90 + }, + { + "time": 1770487200, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.07, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.22, + "apparentTemperature": -19.19, + "dewPoint": -15.06, + "humidity": 0.62, + "pressure": 1006.64, + "windSpeed": 10.8, + "windGust": 16.0, + "windBearing": 320, + "cloudCover": 0.34, + "uvIndex": 3.23, + "visibility": 16.09, + "ozone": 466.32, + "nearestStormDistance": 84.43, + "nearestStormBearing": 56 + }, + { + "time": 1770490800, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.06, + "precipIntensityError": 0.03, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.12, + "apparentTemperature": -19.17, + "dewPoint": -15.37, + "humidity": 0.6, + "pressure": 1007.56, + "windSpeed": 10.93, + "windGust": 16.2, + "windBearing": 320, + "cloudCover": 0.31, + "uvIndex": 2.88, + "visibility": 16.09, + "ozone": 461.77, + "nearestStormDistance": 63.25, + "nearestStormBearing": 90 + }, + { + "time": 1770494400, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.05, + "precipIntensityError": 0.02, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.02, + "apparentTemperature": -19.16, + "dewPoint": -15.67, + "humidity": 0.57, + "pressure": 1008.89, + "windSpeed": 11.07, + "windGust": 16.4, + "windBearing": 320, + "cloudCover": 0.28, + "uvIndex": 2.08, + "visibility": 16.09, + "ozone": 460.11, + "nearestStormDistance": 63.25, + "nearestStormBearing": 90 + }, + { + "time": 1770498000, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.02, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -8.92, + "apparentTemperature": -19.14, + "dewPoint": -15.98, + "humidity": 0.55, + "pressure": 1009.92, + "windSpeed": 11.2, + "windGust": 16.6, + "windBearing": 320, + "cloudCover": 0.25, + "uvIndex": 1.23, + "visibility": 16.09, + "ozone": 464.16, + "nearestStormDistance": 163.12, + "nearestStormBearing": 38 + }, + { + "time": 1770501600, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.09, + "apparentTemperature": -19.43, + "dewPoint": -16.42, + "humidity": 0.54, + "pressure": 1011.26, + "windSpeed": 11.33, + "windGust": 16.13, + "windBearing": 320, + "cloudCover": 0.23, + "uvIndex": 0.43, + "visibility": 16.09, + "ozone": 482.48, + "nearestStormDistance": 129.29, + "nearestStormBearing": 99 + }, + { + "time": 1770505200, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.25, + "apparentTemperature": -19.71, + "dewPoint": -16.86, + "humidity": 0.53, + "pressure": 1013.04, + "windSpeed": 11.47, + "windGust": 15.67, + "windBearing": 320, + "cloudCover": 0.22, + "uvIndex": 0.01, + "visibility": 16.09, + "ozone": 484.32, + "nearestStormDistance": 105.41, + "nearestStormBearing": 90 + }, + { + "time": 1770508800, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.42, + "apparentTemperature": -20.0, + "dewPoint": -17.3, + "humidity": 0.52, + "pressure": 1014.7, + "windSpeed": 11.6, + "windGust": 15.2, + "windBearing": 320, + "cloudCover": 0.2, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 476.4, + "nearestStormDistance": 63.25, + "nearestStormBearing": 90 + }, + { + "time": 1770512400, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.84, + "apparentTemperature": -20.51, + "dewPoint": -17.58, + "humidity": 0.53, + "pressure": 1015.83, + "windSpeed": 11.2, + "windGust": 14.73, + "windBearing": 320, + "cloudCover": 0.2, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 488.47, + "nearestStormDistance": 63.25, + "nearestStormBearing": 90 + }, + { + "time": 1770516000, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -10.26, + "apparentTemperature": -21.02, + "dewPoint": -17.87, + "humidity": 0.53, + "pressure": 1016.42, + "windSpeed": 10.8, + "windGust": 14.27, + "windBearing": 320, + "cloudCover": 0.19, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 488.94, + "nearestStormDistance": 42.17, + "nearestStormBearing": 90 + }, + { + "time": 1770519600, + "summary": "Windy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.02, + "precipAccumulation": 0.0, + "precipType": "none", + "temperature": -10.68, + "apparentTemperature": -21.53, + "dewPoint": -18.15, + "humidity": 0.54, + "pressure": 1017.26, + "windSpeed": 10.4, + "windGust": 13.8, + "windBearing": 320, + "cloudCover": 0.19, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 476.31, + "nearestStormDistance": 63.25, + "nearestStormBearing": 90 + }, + { + "time": 1770523200, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "none", + "temperature": -11.0, + "apparentTemperature": -21.6, + "dewPoint": -18.21, + "humidity": 0.55, + "pressure": 1017.96, + "windSpeed": 9.87, + "windGust": 13.07, + "windBearing": 320, + "cloudCover": 0.19, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 472.62, + "nearestStormDistance": 84.43, + "nearestStormBearing": 56 + }, + { + "time": 1770526800, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "none", + "temperature": -11.31, + "apparentTemperature": -21.67, + "dewPoint": -18.28, + "humidity": 0.55, + "pressure": 1018.54, + "windSpeed": 9.33, + "windGust": 12.33, + "windBearing": 320, + "cloudCover": 0.18, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 473.0, + "nearestStormDistance": 104.96, + "nearestStormBearing": 45 + }, + { + "time": 1770530400, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "none", + "temperature": -11.63, + "apparentTemperature": -21.74, + "dewPoint": -18.34, + "humidity": 0.56, + "pressure": 1018.94, + "windSpeed": 8.8, + "windGust": 11.6, + "windBearing": 320, + "cloudCover": 0.18, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 471.38, + "nearestStormDistance": 128.27, + "nearestStormBearing": 36 + }, + { + "time": 1770534000, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -11.85, + "apparentTemperature": -21.99, + "dewPoint": -18.42, + "humidity": 0.57, + "pressure": 1019.57, + "windSpeed": 8.53, + "windGust": 11.43, + "windBearing": 320, + "cloudCover": 0.2, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 469.74, + "nearestStormDistance": 163.12, + "nearestStormBearing": 38 + }, + { + "time": 1770537600, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -12.08, + "apparentTemperature": -22.23, + "dewPoint": -18.51, + "humidity": 0.58, + "pressure": 1020.57, + "windSpeed": 8.27, + "windGust": 11.27, + "windBearing": 320, + "cloudCover": 0.22, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 469.32, + "nearestStormDistance": 198.1, + "nearestStormBearing": 39 + }, + { + "time": 1770541200, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -12.3, + "apparentTemperature": -22.48, + "dewPoint": -18.59, + "humidity": 0.59, + "pressure": 1020.96, + "windSpeed": 8.0, + "windGust": 11.1, + "windBearing": 320, + "cloudCover": 0.24, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 470.4, + "nearestStormDistance": 210.33, + "nearestStormBearing": 45 + }, + { + "time": 1770544800, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -12.29, + "apparentTemperature": -22.26, + "dewPoint": -18.48, + "humidity": 0.6, + "pressure": 1021.44, + "windSpeed": 7.73, + "windGust": 10.67, + "windBearing": 317, + "cloudCover": 0.26, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 467.82, + "nearestStormDistance": 223.94, + "nearestStormBearing": 49 + }, + { + "time": 1770548400, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -12.29, + "apparentTemperature": -22.05, + "dewPoint": -18.38, + "humidity": 0.61, + "pressure": 1021.88, + "windSpeed": 7.47, + "windGust": 10.23, + "windBearing": 313, + "cloudCover": 0.27, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 467.33, + "nearestStormDistance": 222.15, + "nearestStormBearing": 35 + }, + { + "time": 1770552000, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -12.28, + "apparentTemperature": -21.83, + "dewPoint": -18.27, + "humidity": 0.62, + "pressure": 1022.51, + "windSpeed": 7.2, + "windGust": 9.8, + "windBearing": 310, + "cloudCover": 0.29, + "uvIndex": 0.0, + "visibility": 16.09, + "ozone": 470.61, + "nearestStormDistance": 222.15, + "nearestStormBearing": 35 + }, + { + "time": 1770555600, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -12.06, + "apparentTemperature": -21.37, + "dewPoint": -17.87, + "humidity": 0.63, + "pressure": 1023.17, + "windSpeed": 7.07, + "windGust": 9.83, + "windBearing": 313, + "cloudCover": 0.32, + "uvIndex": 0.23, + "visibility": 16.09, + "ozone": 475.74, + "nearestStormDistance": 198.1, + "nearestStormBearing": 39 + }, + { + "time": 1770559200, + "summary": "Breezy and Mostly Clear", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.0, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -11.85, + "apparentTemperature": -20.92, + "dewPoint": -17.48, + "humidity": 0.63, + "pressure": 1023.18, + "windSpeed": 6.93, + "windGust": 9.87, + "windBearing": 317, + "cloudCover": 0.35, + "uvIndex": 1.09, + "visibility": 16.09, + "ozone": 473.89, + "nearestStormDistance": 210.33, + "nearestStormBearing": 45 + }, + { + "time": 1770562800, + "summary": "Breezy and Partly Cloudy", + "icon": "wind", + "precipIntensity": 0.0, + "precipProbability": 0.05, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -11.63, + "apparentTemperature": -20.46, + "dewPoint": -17.08, + "humidity": 0.64, + "pressure": 1023.83, + "windSpeed": 6.8, + "windGust": 9.9, + "windBearing": 320, + "cloudCover": 0.38, + "uvIndex": 2.2, + "visibility": 16.09, + "ozone": 467.51, + "nearestStormDistance": 233.17, + "nearestStormBearing": 40 + }, + { + "time": 1770566400, + "summary": "Partly Cloudy", + "icon": "partly-cloudy-day", + "precipIntensity": 0.0, + "precipProbability": 0.05, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -10.88, + "apparentTemperature": -19.39, + "dewPoint": -16.45, + "humidity": 0.64, + "pressure": 1024.02, + "windSpeed": 6.53, + "windGust": 9.77, + "windBearing": 317, + "cloudCover": 0.41, + "uvIndex": 3.21, + "visibility": 16.09, + "ozone": 453.24, + "nearestStormDistance": 233.17, + "nearestStormBearing": 40 + }, + { + "time": 1770570000, + "summary": "Partly Cloudy", + "icon": "partly-cloudy-day", + "precipIntensity": 0.0, + "precipProbability": 0.05, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -10.12, + "apparentTemperature": -18.31, + "dewPoint": -15.82, + "humidity": 0.63, + "pressure": 1023.84, + "windSpeed": 6.27, + "windGust": 9.63, + "windBearing": 313, + "cloudCover": 0.45, + "uvIndex": 3.87, + "visibility": 16.09, + "ozone": 444.43, + "nearestStormDistance": 247.0, + "nearestStormBearing": 32 + }, + { + "time": 1770573600, + "summary": "Partly Cloudy", + "icon": "partly-cloudy-day", + "precipIntensity": 0.0, + "precipProbability": 0.05, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -9.37, + "apparentTemperature": -17.24, + "dewPoint": -15.19, + "humidity": 0.63, + "pressure": 1023.45, + "windSpeed": 6.0, + "windGust": 9.5, + "windBearing": 310, + "cloudCover": 0.48, + "uvIndex": 3.98, + "visibility": 16.09, + "ozone": 441.37, + "nearestStormDistance": 280.82, + "nearestStormBearing": 45 + }, + { + "time": 1770577200, + "summary": "Partly Cloudy", + "icon": "partly-cloudy-day", + "precipIntensity": 0.0, + "precipProbability": 0.06, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -8.97, + "apparentTemperature": -16.72, + "dewPoint": -14.92, + "humidity": 0.62, + "pressure": 1021.09, + "windSpeed": 6.07, + "windGust": 9.37, + "windBearing": 310, + "cloudCover": 0.49, + "uvIndex": 3.5, + "visibility": 16.09, + "ozone": 440.69, + "nearestStormDistance": 291.96, + "nearestStormBearing": 37 + }, + { + "time": 1770580800, + "summary": "Partly Cloudy", + "icon": "partly-cloudy-day", + "precipIntensity": 0.0, + "precipProbability": 0.06, + "precipIntensityError": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperature": -8.58, + "apparentTemperature": -16.2, + "dewPoint": -14.64, + "humidity": 0.62, + "pressure": 1021.18, + "windSpeed": 6.13, + "windGust": 9.23, + "windBearing": 310, + "cloudCover": 0.51, + "uvIndex": 2.58, + "visibility": 16.09, + "ozone": 433.53, + "nearestStormDistance": 303.53, + "nearestStormBearing": 41 + } + ] + }, + "daily": { + "summary": "Snow next Friday, with high temperatures peaking at 2°C on Wednesday.", + "icon": "snow", + "data": [ + { + "time": 1770354000, + "summary": "Hazy overnight.", + "icon": "fog", + "sunriseTime": 1770379258, + "sunsetTime": 1770416384, + "moonPhase": 0.66, + "precipIntensity": 0.0, + "precipIntensityMax": 0.0, + "precipIntensityMaxTime": 1770354000, + "precipProbability": 0.33, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperatureHigh": -2.02, + "temperatureHighTime": 1770418800, + "temperatureLow": -4.82, + "temperatureLowTime": 1770462000, + "apparentTemperatureHigh": -5.66, + "apparentTemperatureHighTime": 1770411600, + "apparentTemperatureLow": -14.37, + "apparentTemperatureLowTime": 1770462000, + "dewPoint": -9.17, + "humidity": 0.71, + "pressure": 1007.52, + "windSpeed": 3.01, + "windGust": 4.09, + "windGustTime": 1770436800, + "windBearing": 281, + "cloudCover": 0.63, + "uvIndex": 3.7, + "uvIndexTime": 1770400800, + "visibility": 13.85, + "temperatureMin": -8.2, + "temperatureMinTime": 1770379200, + "temperatureMax": -1.72, + "temperatureMaxTime": 1770433200, + "apparentTemperatureMin": -13.29, + "apparentTemperatureMinTime": 1770379200, + "apparentTemperatureMax": -5.66, + "apparentTemperatureMaxTime": 1770411600 + }, + { + "time": 1770440400, + "summary": "Windy throughout the day.", + "icon": "wind", + "sunriseTime": 1770465591, + "sunsetTime": 1770502858, + "moonPhase": 0.69, + "precipIntensity": 0.0, + "precipIntensityMax": 0.0, + "precipIntensityMaxTime": 1770440400, + "precipProbability": 0.33, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperatureHigh": -5.58, + "temperatureHighTime": 1770465600, + "temperatureLow": -12.3, + "temperatureLowTime": 1770541200, + "apparentTemperatureHigh": -15.88, + "apparentTemperatureHighTime": 1770465600, + "apparentTemperatureLow": -21.7, + "apparentTemperatureLowTime": 1770519600, + "dewPoint": -13.05, + "humidity": 0.64, + "pressure": 1007.22, + "windSpeed": 9.57, + "windGust": 13.35, + "windGustTime": 1770498000, + "windBearing": 302, + "cloudCover": 0.45, + "uvIndex": 3.23, + "uvIndexTime": 1770483600, + "visibility": 11.95, + "temperatureMin": -11.0, + "temperatureMinTime": 1770523200, + "temperatureMax": -2.05, + "temperatureMaxTime": 1770440400, + "apparentTemperatureMin": -21.7, + "apparentTemperatureMinTime": 1770519600, + "apparentTemperatureMax": -7.86, + "apparentTemperatureMaxTime": 1770440400 + }, + { + "time": 1770526800, + "summary": "Breezy in the morning.", + "icon": "wind", + "sunriseTime": 1770551923, + "sunsetTime": 1770589332, + "moonPhase": 0.72, + "precipIntensity": 0.0, + "precipIntensityMax": 0.0, + "precipIntensityMaxTime": 1770526800, + "precipProbability": 0.07, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperatureHigh": -7.97, + "temperatureHighTime": 1770591600, + "temperatureLow": -10.64, + "temperatureLowTime": 1770634800, + "apparentTemperatureHigh": -14.7, + "apparentTemperatureHighTime": 1770584400, + "apparentTemperatureLow": -17.9, + "apparentTemperatureLowTime": 1770634800, + "dewPoint": -16.36, + "humidity": 0.61, + "pressure": 1022.11, + "windSpeed": 6.89, + "windGust": 9.71, + "windGustTime": 1770526800, + "windBearing": 313, + "cloudCover": 0.37, + "uvIndex": 3.98, + "uvIndexTime": 1770573600, + "visibility": 16.09, + "temperatureMin": -12.3, + "temperatureMinTime": 1770541200, + "temperatureMax": -7.86, + "temperatureMaxTime": 1770595200, + "apparentTemperatureMin": -21.66, + "apparentTemperatureMinTime": 1770541200, + "apparentTemperatureMax": -14.7, + "apparentTemperatureMaxTime": 1770584400 + }, + { + "time": 1770613200, + "summary": "Mostly clear until night.", + "icon": "clear-day", + "sunriseTime": 1770638253, + "sunsetTime": 1770675806, + "moonPhase": 0.75, + "precipIntensity": 0.0, + "precipIntensityMax": 0.0, + "precipIntensityMaxTime": 1770613200, + "precipProbability": 0.07, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperatureHigh": -4.77, + "temperatureHighTime": 1770670800, + "temperatureLow": -7.39, + "temperatureLowTime": 1770721200, + "apparentTemperatureHigh": -10.59, + "apparentTemperatureHighTime": 1770670800, + "apparentTemperatureLow": -14.19, + "apparentTemperatureLowTime": 1770714000, + "dewPoint": -13.37, + "humidity": 0.64, + "pressure": 1023.19, + "windSpeed": 5.64, + "windGust": 8.0, + "windGustTime": 1770670800, + "windBearing": 306, + "cloudCover": 0.35, + "uvIndex": 3.54, + "uvIndexTime": 1770660000, + "visibility": 16.09, + "temperatureMin": -10.96, + "temperatureMinTime": 1770638400, + "temperatureMax": -4.77, + "temperatureMaxTime": 1770670800, + "apparentTemperatureMin": -18.23, + "apparentTemperatureMinTime": 1770638400, + "apparentTemperatureMax": -10.59, + "apparentTemperatureMaxTime": 1770670800 + }, + { + "time": 1770699600, + "summary": "Mostly clear until evening.", + "icon": "clear-day", + "sunriseTime": 1770724581, + "sunsetTime": 1770762279, + "moonPhase": 0.78, + "precipIntensity": 0.0, + "precipIntensityMax": 0.0, + "precipIntensityMaxTime": 1770699600, + "precipProbability": 0.0, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperatureHigh": -1.3, + "temperatureHighTime": 1770757200, + "temperatureLow": -4.83, + "temperatureLowTime": 1770807600, + "apparentTemperatureHigh": -6.5, + "apparentTemperatureHighTime": 1770757200, + "apparentTemperatureLow": -10.85, + "apparentTemperatureLowTime": 1770807600, + "dewPoint": -10.03, + "humidity": 0.65, + "pressure": 1021.43, + "windSpeed": 4.8, + "windGust": 6.78, + "windGustTime": 1770699600, + "windBearing": 303, + "cloudCover": 0.37, + "uvIndex": 4.35, + "uvIndexTime": 1770746400, + "visibility": 15.2, + "temperatureMin": -7.39, + "temperatureMinTime": 1770721200, + "temperatureMax": -1.3, + "temperatureMaxTime": 1770757200, + "apparentTemperatureMin": -14.19, + "apparentTemperatureMinTime": 1770714000, + "apparentTemperatureMax": -6.5, + "apparentTemperatureMaxTime": 1770757200 + }, + { + "time": 1770786000, + "summary": "Hazy in the afternoon.", + "icon": "fog", + "sunriseTime": 1770810908, + "sunsetTime": 1770848753, + "moonPhase": 0.81, + "precipIntensity": 0.0, + "precipIntensityMax": 0.0, + "precipIntensityMaxTime": 1770786000, + "precipProbability": 0.08, + "precipAccumulation": 0.0, + "precipType": "snow", + "temperatureHigh": 2.11, + "temperatureHighTime": 1770836400, + "temperatureLow": -5.5, + "temperatureLowTime": 1770865200, + "apparentTemperatureHigh": -2.01, + "apparentTemperatureHighTime": 1770836400, + "apparentTemperatureLow": -8.93, + "apparentTemperatureLowTime": 1770876000, + "dewPoint": -6.87, + "humidity": 0.78, + "pressure": 1018.44, + "windSpeed": 3.33, + "windGust": 7.15, + "windGustTime": 1770854400, + "windBearing": 303, + "cloudCover": 0.5, + "uvIndex": 0.47, + "uvIndexTime": 1770832800, + "visibility": 10.62, + "temperatureMin": -5.5, + "temperatureMinTime": 1770865200, + "temperatureMax": 2.11, + "temperatureMaxTime": 1770836400, + "apparentTemperatureMin": -10.85, + "apparentTemperatureMinTime": 1770811200, + "apparentTemperatureMax": -2.01, + "apparentTemperatureMaxTime": 1770836400 + }, + { + "time": 1770872400, + "summary": "Possible snow (< 4 cm.) starting in the evening.", + "icon": "partly-cloudy-day", + "sunriseTime": 1770897234, + "sunsetTime": 1770935226, + "moonPhase": 0.84, + "precipIntensity": 0.15, + "precipIntensityMax": 1.008, + "precipIntensityMaxTime": 1770955200, + "precipProbability": 0.12, + "precipAccumulation": 0.9236, + "precipType": "snow", + "temperatureHigh": -0.19, + "temperatureHighTime": 1770919200, + "temperatureLow": -2.21, + "temperatureLowTime": 1770962400, + "apparentTemperatureHigh": 1.12, + "apparentTemperatureHighTime": 1770919200, + "apparentTemperatureLow": -7.73, + "apparentTemperatureLowTime": 1770980400, + "dewPoint": -4.45, + "humidity": 0.78, + "pressure": 1023.22, + "windSpeed": 0.95, + "windGust": 11.32, + "windGustTime": 1770886800, + "windBearing": 248, + "cloudCover": 0.47, + "uvIndex": 3.94, + "uvIndexTime": 1770919200, + "visibility": 16.09, + "temperatureMin": -5.35, + "temperatureMinTime": 1770872400, + "temperatureMax": -0.19, + "temperatureMaxTime": 1770919200, + "apparentTemperatureMin": -8.93, + "apparentTemperatureMinTime": 1770876000, + "apparentTemperatureMax": 1.12, + "apparentTemperatureMaxTime": 1770919200 + }, + { + "time": 1770958800, + "summary": "Light snow (< 10 cm.) throughout the day.", + "icon": "snow", + "sunriseTime": 1770983559, + "sunsetTime": 1771021699, + "moonPhase": 0.87, + "precipIntensity": 0.381, + "precipIntensityMax": 1.368, + "precipIntensityMaxTime": 1770962400, + "precipProbability": 0.34, + "precipAccumulation": 4.0291, + "precipType": "snow", + "temperatureHigh": -0.77, + "temperatureHighTime": 1771023600, + "temperatureLow": -0.81, + "temperatureLowTime": 1771048800, + "apparentTemperatureHigh": -3.43, + "apparentTemperatureHighTime": 1771005600, + "apparentTemperatureLow": -5.95, + "apparentTemperatureLowTime": 1771059600, + "dewPoint": -2.67, + "humidity": 0.8, + "pressure": 1015.78, + "windSpeed": 3.24, + "windGust": 12.21, + "windGustTime": 1771038000, + "windBearing": 30, + "cloudCover": 0.36, + "uvIndex": 3.8, + "uvIndexTime": 1771005600, + "visibility": 16.09, + "temperatureMin": -2.21, + "temperatureMinTime": 1770962400, + "temperatureMax": -0.64, + "temperatureMaxTime": 1771027200, + "apparentTemperatureMin": -7.91, + "apparentTemperatureMinTime": 1770984000, + "apparentTemperatureMax": -3.43, + "apparentTemperatureMaxTime": 1771005600 + } + ] + }, + "alerts": [ + { + "title": "Extreme Cold Warning", + "regions": [ + "Eastern Passaic", + "Hudson", + "Western Bergen", + "Eastern Bergen", + "Western Essex", + "Eastern Essex", + "Western Union", + "Eastern Union", + "Putnam", + "Rockland", + "Northern Westchester", + "Southern Westchester", + "New York (Manhattan)", + "Bronx", + "Richmond (Staten Is.)", + "Kings (Brooklyn)", + "Northern Queens", + "Southern Queens" + ], + "severity": "Severe", + "time": 1770402120, + "expires": 1770458400, + "description": "* WHAT...For the Wind Advisory, northwest winds 20 to 30 mph with gusts up to 50 mph expected. For the Extreme Cold Warning, dangerously cold wind chills as low as 20 below expected.\n* WHERE...Portions of northeast New Jersey and southeast New York.\n* WHEN...For the Wind Advisory, from 9 AM Saturday to midnight EST Saturday Night. For the Extreme Cold Warning, from 10 AM Saturday to 1 PM EST Sunday.\n* IMPACTS...Gusty winds will blow around unsecured objects. Tree limbs could be blown down and a few power outages may result. The cold wind chills could cause frostbite on exposed skin in as little as 30 minutes.", + "uri": "https://api.weather.gov/alerts/urn:oid:2.49.0.1.840.0.5df73ec191a300e305a2e7beb31cdbaded01fd49.004.1" + }, + { + "title": "Wind Advisory", + "regions": [ + "Eastern Passaic", + "Hudson", + "Western Bergen", + "Eastern Bergen", + "Western Essex", + "Eastern Essex", + "Western Union", + "Eastern Union", + "Putnam", + "Rockland", + "Northern Westchester", + "Southern Westchester", + "New York (Manhattan)", + "Bronx", + "Richmond (Staten Is.)", + "Kings (Brooklyn)", + "Northern Queens", + "Southern Queens" + ], + "severity": "Moderate", + "time": 1770402120, + "expires": 1770458400, + "description": "* WHAT...For the Wind Advisory, northwest winds 20 to 30 mph with gusts up to 50 mph expected. For the Extreme Cold Warning, dangerously cold wind chills as low as 20 below expected.\n* WHERE...Portions of northeast New Jersey and southeast New York.\n* WHEN...For the Wind Advisory, from 9 AM Saturday to midnight EST Saturday Night. For the Extreme Cold Warning, from 10 AM Saturday to 1 PM EST Sunday.\n* IMPACTS...Gusty winds will blow around unsecured objects. Tree limbs could be blown down and a few power outages may result. The cold wind chills could cause frostbite on exposed skin in as little as 30 minutes.", + "uri": "https://api.weather.gov/alerts/urn:oid:2.49.0.1.840.0.5df73ec191a300e305a2e7beb31cdbaded01fd49.004.2" + } + ], + "flags": { + "sources": ["ETOPO1", "hrrrsubh", "rtma_ru", "hrrr_0-18", "nbm", "nbm_fire", "dwd_mosmix", "ecmwf_ifs", "hrrr_18-48", "gfs", "gefs"], + "sourceTimes": { + "hrrr_subh": "2026-02-06 19Z", + "rtma_ru": "2026-02-06 21:15Z", + "hrrr_0-18": "2026-02-06 19Z", + "nbm": "2026-02-03 23Z", + "nbm_fire": "2026-02-06 12Z", + "dwd_mosmix": "2026-02-06 20Z", + "ecmwf_ifs": "2026-02-06 12Z", + "hrrr_18-48": "2026-02-06 18Z", + "gfs": "2026-02-06 12Z", + "gefs": "2026-02-06 12Z" + }, + "nearest-station": 10.96, + "units": "si", + "version": "V2.9.1" + } +} diff --git a/tests/mocks/weather_smhi.json b/tests/mocks/weather_smhi.json new file mode 100644 index 0000000..6273e29 --- /dev/null +++ b/tests/mocks/weather_smhi.json @@ -0,0 +1,1986 @@ +{ + "createdTime": "2026-02-06T21:31:33Z", + "referenceTime": "2026-02-06T21:00:00Z", + "geometry": { + "type": "Point", + "coordinates": [18.089437, 59.339222] + }, + "timeSeries": [ + { + "time": "2026-02-06T22:00:00Z", + "intervalParametersStartTime": "2026-02-06T22:00:00Z", + "data": { + "air_temperature": -5.5, + "wind_from_direction": 40, + "wind_speed": 4.3, + "wind_speed_of_gust": 7.8, + "relative_humidity": 88, + "air_pressure_at_mean_sea_level": 1013.6, + "visibility_in_air": 12.3, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 8, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.1, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": 100, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 4, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-06T23:00:00Z", + "intervalParametersStartTime": "2026-02-06T23:00:00Z", + "data": { + "air_temperature": -5.4, + "wind_from_direction": 38, + "wind_speed": 4.2, + "wind_speed_of_gust": 7.7, + "relative_humidity": 88, + "air_pressure_at_mean_sea_level": 1013.9, + "visibility_in_air": 12.8, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 8, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.1, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": 100, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 4, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-07T00:00:00Z", + "intervalParametersStartTime": "2026-02-07T00:00:00Z", + "data": { + "air_temperature": -5.3, + "wind_from_direction": 38, + "wind_speed": 3.9, + "wind_speed_of_gust": 7.7, + "relative_humidity": 87, + "air_pressure_at_mean_sea_level": 1014.2, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 7, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.1, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": 100, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 4, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-07T01:00:00Z", + "intervalParametersStartTime": "2026-02-07T01:00:00Z", + "data": { + "air_temperature": -5.2, + "wind_from_direction": 37, + "wind_speed": 3.7, + "wind_speed_of_gust": 7.2, + "relative_humidity": 87, + "air_pressure_at_mean_sea_level": 1014.2, + "visibility_in_air": 12.9, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 6, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": 100, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-07T02:00:00Z", + "intervalParametersStartTime": "2026-02-07T02:00:00Z", + "data": { + "air_temperature": -5.2, + "wind_from_direction": 31, + "wind_speed": 3.5, + "wind_speed_of_gust": 6.8, + "relative_humidity": 88, + "air_pressure_at_mean_sea_level": 1014.2, + "visibility_in_air": 12.6, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 5, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-07T03:00:00Z", + "intervalParametersStartTime": "2026-02-07T03:00:00Z", + "data": { + "air_temperature": -5.2, + "wind_from_direction": 33, + "wind_speed": 3.6, + "wind_speed_of_gust": 6.5, + "relative_humidity": 88, + "air_pressure_at_mean_sea_level": 1014.1, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 6, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-07T04:00:00Z", + "intervalParametersStartTime": "2026-02-07T04:00:00Z", + "data": { + "air_temperature": -5.2, + "wind_from_direction": 35, + "wind_speed": 3.6, + "wind_speed_of_gust": 6.8, + "relative_humidity": 89, + "air_pressure_at_mean_sea_level": 1014.4, + "visibility_in_air": 12.0, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 7, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 8, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-07T05:00:00Z", + "intervalParametersStartTime": "2026-02-07T05:00:00Z", + "data": { + "air_temperature": -5.4, + "wind_from_direction": 35, + "wind_speed": 3.8, + "wind_speed_of_gust": 6.9, + "relative_humidity": 89, + "air_pressure_at_mean_sea_level": 1014.7, + "visibility_in_air": 11.8, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 5, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 8, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-07T06:00:00Z", + "intervalParametersStartTime": "2026-02-07T06:00:00Z", + "data": { + "air_temperature": -5.6, + "wind_from_direction": 37, + "wind_speed": 4.4, + "wind_speed_of_gust": 8.0, + "relative_humidity": 88, + "air_pressure_at_mean_sea_level": 1015.3, + "visibility_in_air": 12.5, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 4, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 8, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-07T07:00:00Z", + "intervalParametersStartTime": "2026-02-07T07:00:00Z", + "data": { + "air_temperature": -5.7, + "wind_from_direction": 36, + "wind_speed": 4.6, + "wind_speed_of_gust": 8.5, + "relative_humidity": 88, + "air_pressure_at_mean_sea_level": 1015.9, + "visibility_in_air": 12.8, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 3, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 8, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 4, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-07T08:00:00Z", + "intervalParametersStartTime": "2026-02-07T08:00:00Z", + "data": { + "air_temperature": -5.5, + "wind_from_direction": 42, + "wind_speed": 4.8, + "wind_speed_of_gust": 8.9, + "relative_humidity": 86, + "air_pressure_at_mean_sea_level": 1016.5, + "visibility_in_air": 14.1, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 3, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 7, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 3, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-07T09:00:00Z", + "intervalParametersStartTime": "2026-02-07T09:00:00Z", + "data": { + "air_temperature": -5.2, + "wind_from_direction": 41, + "wind_speed": 4.3, + "wind_speed_of_gust": 8.7, + "relative_humidity": 82, + "air_pressure_at_mean_sea_level": 1017.3, + "visibility_in_air": 17.1, + "thunderstorm_probability": 0, + "cloud_area_fraction": 7, + "low_type_cloud_area_fraction": 4, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 6, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 4, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-07T10:00:00Z", + "intervalParametersStartTime": "2026-02-07T10:00:00Z", + "data": { + "air_temperature": -4.7, + "wind_from_direction": 44, + "wind_speed": 4.2, + "wind_speed_of_gust": 7.9, + "relative_humidity": 77, + "air_pressure_at_mean_sea_level": 1017.8, + "visibility_in_air": 20.1, + "thunderstorm_probability": 0, + "cloud_area_fraction": 7, + "low_type_cloud_area_fraction": 5, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 4, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 4, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-07T11:00:00Z", + "intervalParametersStartTime": "2026-02-07T11:00:00Z", + "data": { + "air_temperature": -4.1, + "wind_from_direction": 48, + "wind_speed": 4.3, + "wind_speed_of_gust": 7.9, + "relative_humidity": 64, + "air_pressure_at_mean_sea_level": 1018.1, + "visibility_in_air": 28.8, + "thunderstorm_probability": 0, + "cloud_area_fraction": 7, + "low_type_cloud_area_fraction": 6, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 3, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 4, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-07T12:00:00Z", + "intervalParametersStartTime": "2026-02-07T12:00:00Z", + "data": { + "air_temperature": -3.9, + "wind_from_direction": 47, + "wind_speed": 4.3, + "wind_speed_of_gust": 7.9, + "relative_humidity": 50, + "air_pressure_at_mean_sea_level": 1018.1, + "visibility_in_air": 38.9, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 7, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 2, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 4, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-07T13:00:00Z", + "intervalParametersStartTime": "2026-02-07T13:00:00Z", + "data": { + "air_temperature": -3.6, + "wind_from_direction": 42, + "wind_speed": 4.2, + "wind_speed_of_gust": 7.9, + "relative_humidity": 52, + "air_pressure_at_mean_sea_level": 1018.1, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 1, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-07T14:00:00Z", + "intervalParametersStartTime": "2026-02-07T14:00:00Z", + "data": { + "air_temperature": -3.5, + "wind_from_direction": 38, + "wind_speed": 4.0, + "wind_speed_of_gust": 7.7, + "relative_humidity": 71, + "air_pressure_at_mean_sea_level": 1018.4, + "visibility_in_air": 23.8, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-07T15:00:00Z", + "intervalParametersStartTime": "2026-02-07T15:00:00Z", + "data": { + "air_temperature": -3.9, + "wind_from_direction": 32, + "wind_speed": 3.7, + "wind_speed_of_gust": 7.3, + "relative_humidity": 76, + "air_pressure_at_mean_sea_level": 1018.9, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-07T16:00:00Z", + "intervalParametersStartTime": "2026-02-07T16:00:00Z", + "data": { + "air_temperature": -4.2, + "wind_from_direction": 31, + "wind_speed": 3.1, + "wind_speed_of_gust": 6.6, + "relative_humidity": 80, + "air_pressure_at_mean_sea_level": 1019.2, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "cloud_area_fraction": 6, + "low_type_cloud_area_fraction": 6, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 4, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-07T17:00:00Z", + "intervalParametersStartTime": "2026-02-07T17:00:00Z", + "data": { + "air_temperature": -4.6, + "wind_from_direction": 38, + "wind_speed": 3.4, + "wind_speed_of_gust": 5.6, + "relative_humidity": 84, + "air_pressure_at_mean_sea_level": 1019.6, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "cloud_area_fraction": 4, + "low_type_cloud_area_fraction": 4, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 3, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-07T18:00:00Z", + "intervalParametersStartTime": "2026-02-07T18:00:00Z", + "data": { + "air_temperature": -5.4, + "wind_from_direction": 36, + "wind_speed": 3.6, + "wind_speed_of_gust": 7.0, + "relative_humidity": 87, + "air_pressure_at_mean_sea_level": 1020.1, + "visibility_in_air": 13.3, + "thunderstorm_probability": 0, + "cloud_area_fraction": 1, + "low_type_cloud_area_fraction": 1, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 1, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-07T19:00:00Z", + "intervalParametersStartTime": "2026-02-07T19:00:00Z", + "data": { + "air_temperature": -6.1, + "wind_from_direction": 33, + "wind_speed": 3.6, + "wind_speed_of_gust": 6.0, + "relative_humidity": 86, + "air_pressure_at_mean_sea_level": 1020.5, + "visibility_in_air": 13.7, + "thunderstorm_probability": 0, + "cloud_area_fraction": 0, + "low_type_cloud_area_fraction": 0, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 1, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-07T20:00:00Z", + "intervalParametersStartTime": "2026-02-07T20:00:00Z", + "data": { + "air_temperature": -6.5, + "wind_from_direction": 32, + "wind_speed": 4.0, + "wind_speed_of_gust": 7.0, + "relative_humidity": 84, + "air_pressure_at_mean_sea_level": 1021.0, + "visibility_in_air": 15.3, + "thunderstorm_probability": 0, + "cloud_area_fraction": 1, + "low_type_cloud_area_fraction": 1, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 2, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-07T21:00:00Z", + "intervalParametersStartTime": "2026-02-07T21:00:00Z", + "data": { + "air_temperature": -6.8, + "wind_from_direction": 32, + "wind_speed": 4.2, + "wind_speed_of_gust": 7.3, + "relative_humidity": 90, + "air_pressure_at_mean_sea_level": 1021.4, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "cloud_area_fraction": 3, + "low_type_cloud_area_fraction": 3, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 2, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-07T22:00:00Z", + "intervalParametersStartTime": "2026-02-07T22:00:00Z", + "data": { + "air_temperature": -6.3, + "wind_from_direction": 38, + "wind_speed": 4.2, + "wind_speed_of_gust": 7.3, + "relative_humidity": 90, + "air_pressure_at_mean_sea_level": 1021.9, + "visibility_in_air": 10.9, + "thunderstorm_probability": 0, + "cloud_area_fraction": 3, + "low_type_cloud_area_fraction": 3, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 2, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-07T23:00:00Z", + "intervalParametersStartTime": "2026-02-07T23:00:00Z", + "data": { + "air_temperature": -5.9, + "wind_from_direction": 44, + "wind_speed": 4.5, + "wind_speed_of_gust": 8.1, + "relative_humidity": 89, + "air_pressure_at_mean_sea_level": 1022.3, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "cloud_area_fraction": 4, + "low_type_cloud_area_fraction": 4, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 3, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-08T00:00:00Z", + "intervalParametersStartTime": "2026-02-08T00:00:00Z", + "data": { + "air_temperature": -5.9, + "wind_from_direction": 46, + "wind_speed": 4.8, + "wind_speed_of_gust": 8.6, + "relative_humidity": 86, + "air_pressure_at_mean_sea_level": 1022.5, + "visibility_in_air": 13.9, + "thunderstorm_probability": 0, + "cloud_area_fraction": 6, + "low_type_cloud_area_fraction": 6, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 4, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-08T01:00:00Z", + "intervalParametersStartTime": "2026-02-08T01:00:00Z", + "data": { + "air_temperature": -5.8, + "wind_from_direction": 53, + "wind_speed": 4.9, + "wind_speed_of_gust": 8.9, + "relative_humidity": 84, + "air_pressure_at_mean_sea_level": 1022.9, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-08T02:00:00Z", + "intervalParametersStartTime": "2026-02-08T02:00:00Z", + "data": { + "air_temperature": -5.8, + "wind_from_direction": 49, + "wind_speed": 4.6, + "wind_speed_of_gust": 8.9, + "relative_humidity": 84, + "air_pressure_at_mean_sea_level": 1023.2, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-08T03:00:00Z", + "intervalParametersStartTime": "2026-02-08T03:00:00Z", + "data": { + "air_temperature": -5.9, + "wind_from_direction": 39, + "wind_speed": 4.1, + "wind_speed_of_gust": 8.4, + "relative_humidity": 85, + "air_pressure_at_mean_sea_level": 1023.6, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-08T04:00:00Z", + "intervalParametersStartTime": "2026-02-08T04:00:00Z", + "data": { + "air_temperature": -6.0, + "wind_from_direction": 40, + "wind_speed": 3.7, + "wind_speed_of_gust": 7.6, + "relative_humidity": 86, + "air_pressure_at_mean_sea_level": 1023.9, + "visibility_in_air": 5.3, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-08T05:00:00Z", + "intervalParametersStartTime": "2026-02-08T05:00:00Z", + "data": { + "air_temperature": -5.8, + "wind_from_direction": 46, + "wind_speed": 3.3, + "wind_speed_of_gust": 6.8, + "relative_humidity": 87, + "air_pressure_at_mean_sea_level": 1024.0, + "visibility_in_air": 5.6, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-08T06:00:00Z", + "intervalParametersStartTime": "2026-02-08T06:00:00Z", + "data": { + "air_temperature": -5.4, + "wind_from_direction": 63, + "wind_speed": 3.4, + "wind_speed_of_gust": 6.2, + "relative_humidity": 83, + "air_pressure_at_mean_sea_level": 1024.2, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-08T07:00:00Z", + "intervalParametersStartTime": "2026-02-08T07:00:00Z", + "data": { + "air_temperature": -5.3, + "wind_from_direction": 55, + "wind_speed": 3.7, + "wind_speed_of_gust": 6.9, + "relative_humidity": 83, + "air_pressure_at_mean_sea_level": 1024.5, + "visibility_in_air": 16.1, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-08T08:00:00Z", + "intervalParametersStartTime": "2026-02-08T08:00:00Z", + "data": { + "air_temperature": -5.2, + "wind_from_direction": 54, + "wind_speed": 3.4, + "wind_speed_of_gust": 6.7, + "relative_humidity": 83, + "air_pressure_at_mean_sea_level": 1024.8, + "visibility_in_air": 15.9, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-08T09:00:00Z", + "intervalParametersStartTime": "2026-02-08T09:00:00Z", + "data": { + "air_temperature": -4.8, + "wind_from_direction": 53, + "wind_speed": 3.7, + "wind_speed_of_gust": 6.8, + "relative_humidity": 85, + "air_pressure_at_mean_sea_level": 1025.1, + "visibility_in_air": 14.7, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-08T10:00:00Z", + "intervalParametersStartTime": "2026-02-08T10:00:00Z", + "data": { + "air_temperature": -3.8, + "wind_from_direction": 66, + "wind_speed": 3.5, + "wind_speed_of_gust": 6.8, + "relative_humidity": 82, + "air_pressure_at_mean_sea_level": 1025.2, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-08T11:00:00Z", + "intervalParametersStartTime": "2026-02-08T11:00:00Z", + "data": { + "air_temperature": -2.6, + "wind_from_direction": 103, + "wind_speed": 3.1, + "wind_speed_of_gust": 6.4, + "relative_humidity": 64, + "air_pressure_at_mean_sea_level": 1025.1, + "visibility_in_air": 29.0, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-08T12:00:00Z", + "intervalParametersStartTime": "2026-02-08T12:00:00Z", + "data": { + "air_temperature": -2.2, + "wind_from_direction": 116, + "wind_speed": 3.7, + "wind_speed_of_gust": 7.2, + "relative_humidity": 55, + "air_pressure_at_mean_sea_level": 1025.0, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-08T13:00:00Z", + "intervalParametersStartTime": "2026-02-08T13:00:00Z", + "data": { + "air_temperature": -2.0, + "wind_from_direction": 118, + "wind_speed": 3.5, + "wind_speed_of_gust": 6.9, + "relative_humidity": 54, + "air_pressure_at_mean_sea_level": 1024.9, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": -0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": -0.0 + } + }, + { + "time": "2026-02-08T14:00:00Z", + "intervalParametersStartTime": "2026-02-08T14:00:00Z", + "data": { + "air_temperature": -2.1, + "wind_from_direction": 123, + "wind_speed": 3.1, + "wind_speed_of_gust": 6.4, + "relative_humidity": 55, + "air_pressure_at_mean_sea_level": 1025.0, + "visibility_in_air": 35.2, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": -0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 4, + "precipitation_amount_mean_deterministic": -0.0 + } + }, + { + "time": "2026-02-08T15:00:00Z", + "intervalParametersStartTime": "2026-02-08T15:00:00Z", + "data": { + "air_temperature": -2.9, + "wind_from_direction": 120, + "wind_speed": 2.5, + "wind_speed_of_gust": 5.4, + "relative_humidity": 60, + "air_pressure_at_mean_sea_level": 1025.0, + "visibility_in_air": 31.5, + "thunderstorm_probability": 0, + "cloud_area_fraction": 7, + "low_type_cloud_area_fraction": 7, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": -0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 4, + "precipitation_amount_mean_deterministic": -0.0 + } + }, + { + "time": "2026-02-08T16:00:00Z", + "intervalParametersStartTime": "2026-02-08T16:00:00Z", + "data": { + "air_temperature": -4.2, + "wind_from_direction": 116, + "wind_speed": 2.1, + "wind_speed_of_gust": 4.1, + "relative_humidity": 65, + "air_pressure_at_mean_sea_level": 1024.8, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "cloud_area_fraction": 5, + "low_type_cloud_area_fraction": 5, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": -0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 3, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-08T17:00:00Z", + "intervalParametersStartTime": "2026-02-08T17:00:00Z", + "data": { + "air_temperature": -5.2, + "wind_from_direction": 115, + "wind_speed": 1.8, + "wind_speed_of_gust": 3.1, + "relative_humidity": 71, + "air_pressure_at_mean_sea_level": 1024.6, + "visibility_in_air": 24.3, + "thunderstorm_probability": 0, + "cloud_area_fraction": 5, + "low_type_cloud_area_fraction": 5, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 3, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-08T18:00:00Z", + "intervalParametersStartTime": "2026-02-08T18:00:00Z", + "data": { + "air_temperature": -6.7, + "wind_from_direction": 107, + "wind_speed": 1.4, + "wind_speed_of_gust": 2.4, + "relative_humidity": 78, + "air_pressure_at_mean_sea_level": 1024.5, + "visibility_in_air": 19.3, + "thunderstorm_probability": 0, + "cloud_area_fraction": 5, + "low_type_cloud_area_fraction": 5, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": -0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 3, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-08T19:00:00Z", + "intervalParametersStartTime": "2026-02-08T19:00:00Z", + "data": { + "air_temperature": -7.5, + "wind_from_direction": 117, + "wind_speed": 1.3, + "wind_speed_of_gust": 1.9, + "relative_humidity": 80, + "air_pressure_at_mean_sea_level": 1024.4, + "visibility_in_air": 17.9, + "thunderstorm_probability": 0, + "cloud_area_fraction": 6, + "low_type_cloud_area_fraction": 6, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 4, + "precipitation_amount_mean_deterministic": -0.0 + } + }, + { + "time": "2026-02-08T20:00:00Z", + "intervalParametersStartTime": "2026-02-08T20:00:00Z", + "data": { + "air_temperature": -8.1, + "wind_from_direction": 124, + "wind_speed": 1.2, + "wind_speed_of_gust": 1.7, + "relative_humidity": 80, + "air_pressure_at_mean_sea_level": 1024.3, + "visibility_in_air": 75.0, + "thunderstorm_probability": 0, + "cloud_area_fraction": 7, + "low_type_cloud_area_fraction": 7, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 4, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-08T21:00:00Z", + "intervalParametersStartTime": "2026-02-08T21:00:00Z", + "data": { + "air_temperature": -8.2, + "wind_from_direction": 138, + "wind_speed": 1.2, + "wind_speed_of_gust": 1.7, + "relative_humidity": 80, + "air_pressure_at_mean_sea_level": 1024.2, + "visibility_in_air": 18.0, + "thunderstorm_probability": 0, + "cloud_area_fraction": 7, + "low_type_cloud_area_fraction": 7, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": -0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 4, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-08T22:00:00Z", + "intervalParametersStartTime": "2026-02-08T22:00:00Z", + "data": { + "air_temperature": -8.3, + "wind_from_direction": 157, + "wind_speed": 1.4, + "wind_speed_of_gust": 1.8, + "relative_humidity": 80, + "air_pressure_at_mean_sea_level": 1024.0, + "visibility_in_air": 18.2, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": -0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": -0.0 + } + }, + { + "time": "2026-02-08T23:00:00Z", + "intervalParametersStartTime": "2026-02-08T23:00:00Z", + "data": { + "air_temperature": -8.3, + "wind_from_direction": 174, + "wind_speed": 1.4, + "wind_speed_of_gust": 1.9, + "relative_humidity": 80, + "air_pressure_at_mean_sea_level": 1023.6, + "visibility_in_air": 18.2, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": -0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-09T00:00:00Z", + "intervalParametersStartTime": "2026-02-09T00:00:00Z", + "data": { + "air_temperature": -8.3, + "wind_from_direction": 182, + "wind_speed": 1.7, + "wind_speed_of_gust": 2.2, + "relative_humidity": 79, + "air_pressure_at_mean_sea_level": 1023.2, + "visibility_in_air": 18.8, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": -0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-09T03:00:00Z", + "intervalParametersStartTime": "2026-02-09T03:00:00Z", + "data": { + "air_temperature": -7.5, + "wind_from_direction": 223, + "wind_speed": 2.6, + "wind_speed_of_gust": 3.9, + "relative_humidity": 78, + "air_pressure_at_mean_sea_level": 1021.3, + "visibility_in_air": 19.5, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": -0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": -0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": -0.0 + } + }, + { + "time": "2026-02-09T06:00:00Z", + "intervalParametersStartTime": "2026-02-09T06:00:00Z", + "data": { + "air_temperature": -7.0, + "wind_from_direction": 251, + "wind_speed": 2.4, + "wind_speed_of_gust": 3.8, + "relative_humidity": 77, + "air_pressure_at_mean_sea_level": 1020.4, + "visibility_in_air": 20.4, + "thunderstorm_probability": 0, + "cloud_area_fraction": 7, + "low_type_cloud_area_fraction": 7, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": -0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 4, + "precipitation_amount_mean_deterministic": -0.0 + } + }, + { + "time": "2026-02-09T09:00:00Z", + "intervalParametersStartTime": "2026-02-09T09:00:00Z", + "data": { + "air_temperature": -7.0, + "wind_from_direction": 264, + "wind_speed": 2.8, + "wind_speed_of_gust": 4.7, + "relative_humidity": 76, + "air_pressure_at_mean_sea_level": 1019.9, + "visibility_in_air": 21.1, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-09T12:00:00Z", + "intervalParametersStartTime": "2026-02-09T12:00:00Z", + "data": { + "air_temperature": -7.0, + "wind_from_direction": 254, + "wind_speed": 3.5, + "wind_speed_of_gust": 7.8, + "relative_humidity": 84, + "air_pressure_at_mean_sea_level": 1017.7, + "visibility_in_air": 19.1, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-09T18:00:00Z", + "intervalParametersStartTime": "2026-02-09T18:00:00Z", + "data": { + "air_temperature": -9.4, + "wind_from_direction": 250, + "wind_speed": 2.3, + "wind_speed_of_gust": 4.5, + "relative_humidity": 86, + "air_pressure_at_mean_sea_level": 1015.3, + "visibility_in_air": 22.9, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 4, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-10T00:00:00Z", + "intervalParametersStartTime": "2026-02-10T00:00:00Z", + "data": { + "air_temperature": -9.9, + "wind_from_direction": 271, + "wind_speed": 0.8, + "wind_speed_of_gust": 2.0, + "relative_humidity": 87, + "air_pressure_at_mean_sea_level": 1012.9, + "visibility_in_air": 7.3, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-10T06:00:00Z", + "intervalParametersStartTime": "2026-02-10T06:00:00Z", + "data": { + "air_temperature": -10.3, + "wind_from_direction": 253, + "wind_speed": 1.3, + "wind_speed_of_gust": 3.3, + "relative_humidity": 86, + "air_pressure_at_mean_sea_level": 1009.6, + "visibility_in_air": 8.2, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 7, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-10T12:00:00Z", + "intervalParametersStartTime": "2026-02-10T12:00:00Z", + "data": { + "air_temperature": -9.1, + "wind_from_direction": 249, + "wind_speed": 2.3, + "wind_speed_of_gust": 5.3, + "relative_humidity": 80, + "air_pressure_at_mean_sea_level": 1006.5, + "visibility_in_air": 10.3, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 8, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": -9, + "predominant_precipitation_type_at_surface": 0, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-10T18:00:00Z", + "intervalParametersStartTime": "2026-02-10T18:00:00Z", + "data": { + "air_temperature": -10.3, + "wind_from_direction": 318, + "wind_speed": 1.0, + "wind_speed_of_gust": 3.0, + "relative_humidity": 85, + "air_pressure_at_mean_sea_level": 1003.7, + "visibility_in_air": 13.4, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.0, + "precipitation_amount_max": 0.0, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": 100, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-11T00:00:00Z", + "intervalParametersStartTime": "2026-02-11T00:00:00Z", + "data": { + "air_temperature": -13.1, + "wind_from_direction": 314, + "wind_speed": 0.5, + "wind_speed_of_gust": 1.5, + "relative_humidity": 91, + "air_pressure_at_mean_sea_level": 1001.5, + "visibility_in_air": 13.1, + "thunderstorm_probability": 0, + "cloud_area_fraction": 4, + "low_type_cloud_area_fraction": 3, + "medium_type_cloud_area_fraction": 1, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.1, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": 100, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 3, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-11T06:00:00Z", + "intervalParametersStartTime": "2026-02-11T06:00:00Z", + "data": { + "air_temperature": -11.2, + "wind_from_direction": 348, + "wind_speed": 1.0, + "wind_speed_of_gust": 2.3, + "relative_humidity": 88, + "air_pressure_at_mean_sea_level": 999.3, + "visibility_in_air": 43.7, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 0, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": 100, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-11T12:00:00Z", + "intervalParametersStartTime": "2026-02-11T12:00:00Z", + "data": { + "air_temperature": -5.1, + "wind_from_direction": 344, + "wind_speed": 2.0, + "wind_speed_of_gust": 4.5, + "relative_humidity": 82, + "air_pressure_at_mean_sea_level": 998.2, + "visibility_in_air": 49.2, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 7, + "medium_type_cloud_area_fraction": 0, + "high_type_cloud_area_fraction": 8, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": 100, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-11T18:00:00Z", + "intervalParametersStartTime": "2026-02-11T18:00:00Z", + "data": { + "air_temperature": -5.8, + "wind_from_direction": 52, + "wind_speed": 3.7, + "wind_speed_of_gust": 6.5, + "relative_humidity": 86, + "air_pressure_at_mean_sea_level": 996.1, + "visibility_in_air": 40.7, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 3, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.3, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": 100, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-12T00:00:00Z", + "intervalParametersStartTime": "2026-02-12T00:00:00Z", + "data": { + "air_temperature": -6.1, + "wind_from_direction": 49, + "wind_speed": 4.1, + "wind_speed_of_gust": 7.3, + "relative_humidity": 87, + "air_pressure_at_mean_sea_level": 994.7, + "visibility_in_air": 39.1, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 3, + "high_type_cloud_area_fraction": 3, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.4, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": 100, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-12T06:00:00Z", + "intervalParametersStartTime": "2026-02-12T06:00:00Z", + "data": { + "air_temperature": -6.6, + "wind_from_direction": 56, + "wind_speed": 4.9, + "wind_speed_of_gust": 8.6, + "relative_humidity": 87, + "air_pressure_at_mean_sea_level": 993.3, + "visibility_in_air": 29.9, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 3, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.3, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": 100, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-12T12:00:00Z", + "intervalParametersStartTime": "2026-02-12T12:00:00Z", + "data": { + "air_temperature": -5.7, + "wind_from_direction": 55, + "wind_speed": 5.5, + "wind_speed_of_gust": 10.3, + "relative_humidity": 81, + "air_pressure_at_mean_sea_level": 993.6, + "visibility_in_air": 31.5, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 3, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.5, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": 100, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-12T18:00:00Z", + "intervalParametersStartTime": "2026-02-12T18:00:00Z", + "data": { + "air_temperature": -7.1, + "wind_from_direction": 45, + "wind_speed": 5.2, + "wind_speed_of_gust": 10.3, + "relative_humidity": 86, + "air_pressure_at_mean_sea_level": 994.2, + "visibility_in_air": 31.7, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 4, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 0.2, + "precipitation_amount_max": 0.4, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": 100, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-13T00:00:00Z", + "intervalParametersStartTime": "2026-02-13T00:00:00Z", + "data": { + "air_temperature": -7.9, + "wind_from_direction": 38, + "wind_speed": 5.0, + "wind_speed_of_gust": 9.5, + "relative_humidity": 86, + "air_pressure_at_mean_sea_level": 994.7, + "visibility_in_air": 31.1, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 8, + "medium_type_cloud_area_fraction": 5, + "high_type_cloud_area_fraction": 4, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.6, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": 88, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 6, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-13T12:00:00Z", + "intervalParametersStartTime": "2026-02-13T12:00:00Z", + "data": { + "air_temperature": -7.7, + "wind_from_direction": 19, + "wind_speed": 5.4, + "wind_speed_of_gust": 10.5, + "relative_humidity": 80, + "air_pressure_at_mean_sea_level": 996.6, + "visibility_in_air": 33.8, + "thunderstorm_probability": 0, + "cloud_area_fraction": 8, + "low_type_cloud_area_fraction": 7, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 2, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.5, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": 100, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 4, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-14T00:00:00Z", + "intervalParametersStartTime": "2026-02-14T00:00:00Z", + "data": { + "air_temperature": -10.0, + "wind_from_direction": 3, + "wind_speed": 4.7, + "wind_speed_of_gust": 9.4, + "relative_humidity": 85, + "air_pressure_at_mean_sea_level": 999.6, + "visibility_in_air": 37.5, + "thunderstorm_probability": 0, + "cloud_area_fraction": 7, + "low_type_cloud_area_fraction": 6, + "medium_type_cloud_area_fraction": 4, + "high_type_cloud_area_fraction": 2, + "precipitation_amount_mean": 0.1, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.3, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": 100, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 4, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-14T12:00:00Z", + "intervalParametersStartTime": "2026-02-14T12:00:00Z", + "data": { + "air_temperature": -8.0, + "wind_from_direction": 350, + "wind_speed": 4.9, + "wind_speed_of_gust": 9.6, + "relative_humidity": 75, + "air_pressure_at_mean_sea_level": 1002.7, + "visibility_in_air": 38.9, + "thunderstorm_probability": 0, + "cloud_area_fraction": 6, + "low_type_cloud_area_fraction": 5, + "medium_type_cloud_area_fraction": 3, + "high_type_cloud_area_fraction": 1, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.3, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": 100, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 4, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-15T00:00:00Z", + "intervalParametersStartTime": "2026-02-15T00:00:00Z", + "data": { + "air_temperature": -11.4, + "wind_from_direction": 321, + "wind_speed": 4.2, + "wind_speed_of_gust": 8.1, + "relative_humidity": 83, + "air_pressure_at_mean_sea_level": 1007.3, + "visibility_in_air": 40.6, + "thunderstorm_probability": 0, + "cloud_area_fraction": 5, + "low_type_cloud_area_fraction": 3, + "medium_type_cloud_area_fraction": 3, + "high_type_cloud_area_fraction": 1, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.3, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": 100, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 3, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-15T12:00:00Z", + "intervalParametersStartTime": "2026-02-15T12:00:00Z", + "data": { + "air_temperature": -7.9, + "wind_from_direction": 304, + "wind_speed": 4.1, + "wind_speed_of_gust": 8.3, + "relative_humidity": 72, + "air_pressure_at_mean_sea_level": 1011.2, + "visibility_in_air": 43.3, + "thunderstorm_probability": 0, + "cloud_area_fraction": 4, + "low_type_cloud_area_fraction": 3, + "medium_type_cloud_area_fraction": 2, + "high_type_cloud_area_fraction": 1, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.4, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": 100, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 3, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-16T00:00:00Z", + "intervalParametersStartTime": "2026-02-16T00:00:00Z", + "data": { + "air_temperature": -9.4, + "wind_from_direction": 292, + "wind_speed": 4.2, + "wind_speed_of_gust": 7.7, + "relative_humidity": 85, + "air_pressure_at_mean_sea_level": 1013.8, + "visibility_in_air": 43.3, + "thunderstorm_probability": 0, + "cloud_area_fraction": 5, + "low_type_cloud_area_fraction": 4, + "medium_type_cloud_area_fraction": 2, + "high_type_cloud_area_fraction": 1, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.1, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": 100, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 3, + "precipitation_amount_mean_deterministic": 0.0 + } + }, + { + "time": "2026-02-16T12:00:00Z", + "intervalParametersStartTime": "2026-02-16T12:00:00Z", + "data": { + "air_temperature": -4.8, + "wind_from_direction": 295, + "wind_speed": 4.1, + "wind_speed_of_gust": 8.3, + "relative_humidity": 78, + "air_pressure_at_mean_sea_level": 1014.7, + "visibility_in_air": 45.8, + "thunderstorm_probability": 0, + "cloud_area_fraction": 6, + "low_type_cloud_area_fraction": 4, + "medium_type_cloud_area_fraction": 3, + "high_type_cloud_area_fraction": 2, + "precipitation_amount_mean": 0.0, + "precipitation_amount_min": 0.1, + "precipitation_amount_max": 0.2, + "precipitation_amount_median": 0.0, + "precipitation_frozen_part": 100, + "predominant_precipitation_type_at_surface": 5, + "symbol_code": 4, + "precipitation_amount_mean_deterministic": 0.0 + } + } + ] +} diff --git a/tests/mocks/weather_ukmetoffice.json b/tests/mocks/weather_ukmetoffice.json new file mode 100644 index 0000000..1a5663e --- /dev/null +++ b/tests/mocks/weather_ukmetoffice.json @@ -0,0 +1,1062 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-0.12480000000000001, 51.5081, 11.0] }, + "properties": { + "location": { "name": "London" }, + "requestPointDistance": 221.7807, + "modelRunDate": "2026-02-07T12:00Z", + "timeSeries": [ + { + "time": "2026-02-07T12:00Z", + "screenTemperature": 9.56, + "maxScreenAirTemp": 9.56, + "minScreenAirTemp": 9.11, + "screenDewPointTemperature": 8.51, + "feelsLikeTemperature": 8.74, + "windSpeed10m": 1.9, + "windDirectionFrom10m": 165, + "windGustSpeed10m": 7.72, + "max10mWindGust": 9.32, + "visibility": 8550, + "screenRelativeHumidity": 93.08, + "mslp": 99440, + "uvIndex": 1, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 15 + }, + { + "time": "2026-02-07T13:00Z", + "screenTemperature": 9.67, + "maxScreenAirTemp": 9.69, + "minScreenAirTemp": 9.56, + "screenDewPointTemperature": 8.39, + "feelsLikeTemperature": 8.76, + "windSpeed10m": 2.13, + "windDirectionFrom10m": 188, + "windGustSpeed10m": 7.31, + "max10mWindGust": 8.26, + "visibility": 7592, + "screenRelativeHumidity": 91.56, + "mslp": 99435, + "uvIndex": 1, + "significantWeatherCode": 11, + "precipitationRate": 0.06, + "totalPrecipAmount": 0.04, + "totalSnowAmount": 0, + "probOfPrecipitation": 33 + }, + { + "time": "2026-02-07T14:00Z", + "screenTemperature": 9.91, + "maxScreenAirTemp": 10.01, + "minScreenAirTemp": 9.67, + "screenDewPointTemperature": 8.62, + "feelsLikeTemperature": 8.29, + "windSpeed10m": 3.22, + "windDirectionFrom10m": 189, + "windGustSpeed10m": 8.15, + "max10mWindGust": 8.64, + "visibility": 9509, + "screenRelativeHumidity": 91.56, + "mslp": 99496, + "uvIndex": 1, + "significantWeatherCode": 14, + "precipitationRate": 1.5, + "totalPrecipAmount": 0.23, + "totalSnowAmount": 0, + "probOfPrecipitation": 62 + }, + { + "time": "2026-02-07T15:00Z", + "screenTemperature": 10.21, + "maxScreenAirTemp": 10.4, + "minScreenAirTemp": 9.91, + "screenDewPointTemperature": 8.5, + "feelsLikeTemperature": 8.19, + "windSpeed10m": 4.1, + "windDirectionFrom10m": 184, + "windGustSpeed10m": 9.49, + "max10mWindGust": 9.56, + "visibility": 9666, + "screenRelativeHumidity": 89.1, + "mslp": 99550, + "uvIndex": 1, + "significantWeatherCode": 12, + "precipitationRate": 0.24, + "totalPrecipAmount": 0.09, + "totalSnowAmount": 0, + "probOfPrecipitation": 55 + }, + { + "time": "2026-02-07T16:00Z", + "screenTemperature": 10.22, + "maxScreenAirTemp": 10.24, + "minScreenAirTemp": 10.18, + "screenDewPointTemperature": 8.24, + "feelsLikeTemperature": 8.28, + "windSpeed10m": 3.92, + "windDirectionFrom10m": 187, + "windGustSpeed10m": 8.95, + "max10mWindGust": 9.64, + "visibility": 7525, + "screenRelativeHumidity": 87.43, + "mslp": 99620, + "uvIndex": 1, + "significantWeatherCode": 12, + "precipitationRate": 0.53, + "totalPrecipAmount": 0.08, + "totalSnowAmount": 0, + "probOfPrecipitation": 59 + }, + { + "time": "2026-02-07T17:00Z", + "screenTemperature": 9.99, + "maxScreenAirTemp": 10.22, + "minScreenAirTemp": 9.98, + "screenDewPointTemperature": 8.13, + "feelsLikeTemperature": 8.22, + "windSpeed10m": 3.51, + "windDirectionFrom10m": 180, + "windGustSpeed10m": 8.31, + "max10mWindGust": 9.11, + "visibility": 11604, + "screenRelativeHumidity": 88.07, + "mslp": 99680, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2026-02-07T18:00Z", + "screenTemperature": 9.89, + "maxScreenAirTemp": 9.99, + "minScreenAirTemp": 9.84, + "screenDewPointTemperature": 8.13, + "feelsLikeTemperature": 8.07, + "windSpeed10m": 3.54, + "windDirectionFrom10m": 181, + "windGustSpeed10m": 8.86, + "max10mWindGust": 9.03, + "visibility": 11879, + "screenRelativeHumidity": 88.72, + "mslp": 99760, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-07T19:00Z", + "screenTemperature": 9.68, + "maxScreenAirTemp": 9.89, + "minScreenAirTemp": 9.67, + "screenDewPointTemperature": 8.06, + "feelsLikeTemperature": 7.86, + "windSpeed10m": 3.45, + "windDirectionFrom10m": 183, + "windGustSpeed10m": 8.57, + "max10mWindGust": 8.86, + "visibility": 12104, + "screenRelativeHumidity": 89.57, + "mslp": 99816, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-07T20:00Z", + "screenTemperature": 9.59, + "maxScreenAirTemp": 9.68, + "minScreenAirTemp": 9.57, + "screenDewPointTemperature": 8.02, + "feelsLikeTemperature": 7.96, + "windSpeed10m": 3.08, + "windDirectionFrom10m": 179, + "windGustSpeed10m": 8.15, + "max10mWindGust": 8.88, + "visibility": 12574, + "screenRelativeHumidity": 89.91, + "mslp": 99876, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 4 + }, + { + "time": "2026-02-07T21:00Z", + "screenTemperature": 9.34, + "maxScreenAirTemp": 9.59, + "minScreenAirTemp": 9.34, + "screenDewPointTemperature": 8.01, + "feelsLikeTemperature": 7.65, + "windSpeed10m": 3.12, + "windDirectionFrom10m": 180, + "windGustSpeed10m": 7.95, + "max10mWindGust": 8.46, + "visibility": 12829, + "screenRelativeHumidity": 91.36, + "mslp": 99932, + "uvIndex": 0, + "significantWeatherCode": 2, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 3 + }, + { + "time": "2026-02-07T22:00Z", + "screenTemperature": 9.0, + "maxScreenAirTemp": 9.34, + "minScreenAirTemp": 8.98, + "screenDewPointTemperature": 7.71, + "feelsLikeTemperature": 7.27, + "windSpeed10m": 3.08, + "windDirectionFrom10m": 177, + "windGustSpeed10m": 8.34, + "max10mWindGust": 8.76, + "visibility": 12923, + "screenRelativeHumidity": 91.6, + "mslp": 99986, + "uvIndex": 0, + "significantWeatherCode": 0, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 2 + }, + { + "time": "2026-02-07T23:00Z", + "screenTemperature": 8.74, + "maxScreenAirTemp": 8.98, + "minScreenAirTemp": 8.71, + "screenDewPointTemperature": 7.57, + "feelsLikeTemperature": 7.09, + "windSpeed10m": 2.86, + "windDirectionFrom10m": 177, + "windGustSpeed10m": 7.68, + "max10mWindGust": 8.78, + "visibility": 14190, + "screenRelativeHumidity": 92.32, + "mslp": 100056, + "uvIndex": 0, + "significantWeatherCode": 0, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 2 + }, + { + "time": "2026-02-08T00:00Z", + "screenTemperature": 8.56, + "maxScreenAirTemp": 8.74, + "minScreenAirTemp": 8.56, + "screenDewPointTemperature": 7.59, + "feelsLikeTemperature": 7.12, + "windSpeed10m": 2.52, + "windDirectionFrom10m": 184, + "windGustSpeed10m": 7.13, + "max10mWindGust": 8.49, + "visibility": 13732, + "screenRelativeHumidity": 93.62, + "mslp": 100096, + "uvIndex": 0, + "significantWeatherCode": 2, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 2 + }, + { + "time": "2026-02-08T01:00Z", + "screenTemperature": 8.4, + "maxScreenAirTemp": 8.56, + "minScreenAirTemp": 8.38, + "screenDewPointTemperature": 7.27, + "feelsLikeTemperature": 7.08, + "windSpeed10m": 2.32, + "windDirectionFrom10m": 180, + "windGustSpeed10m": 6.73, + "max10mWindGust": 7.62, + "visibility": 14599, + "screenRelativeHumidity": 92.57, + "mslp": 100150, + "uvIndex": 0, + "significantWeatherCode": 2, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 2 + }, + { + "time": "2026-02-08T02:00Z", + "screenTemperature": 8.14, + "maxScreenAirTemp": 8.4, + "minScreenAirTemp": 8.13, + "screenDewPointTemperature": 7.17, + "feelsLikeTemperature": 7.11, + "windSpeed10m": 1.93, + "windDirectionFrom10m": 191, + "windGustSpeed10m": 5.96, + "max10mWindGust": 7.23, + "visibility": 12665, + "screenRelativeHumidity": 93.62, + "mslp": 100190, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2026-02-08T03:00Z", + "screenTemperature": 7.9, + "maxScreenAirTemp": 8.14, + "minScreenAirTemp": 7.89, + "screenDewPointTemperature": 7.12, + "feelsLikeTemperature": 7.1, + "windSpeed10m": 1.63, + "windDirectionFrom10m": 195, + "windGustSpeed10m": 5.28, + "max10mWindGust": 6.22, + "visibility": 10018, + "screenRelativeHumidity": 94.84, + "mslp": 100224, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2026-02-08T04:00Z", + "screenTemperature": 7.78, + "maxScreenAirTemp": 7.9, + "minScreenAirTemp": 7.76, + "screenDewPointTemperature": 7.07, + "feelsLikeTemperature": 6.86, + "windSpeed10m": 1.74, + "windDirectionFrom10m": 188, + "windGustSpeed10m": 5.13, + "max10mWindGust": 5.76, + "visibility": 8777, + "screenRelativeHumidity": 95.25, + "mslp": 100253, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2026-02-08T05:00Z", + "screenTemperature": 7.67, + "maxScreenAirTemp": 7.78, + "minScreenAirTemp": 7.62, + "screenDewPointTemperature": 7.02, + "feelsLikeTemperature": 6.77, + "windSpeed10m": 1.64, + "windDirectionFrom10m": 177, + "windGustSpeed10m": 5.17, + "max10mWindGust": 5.88, + "visibility": 7296, + "screenRelativeHumidity": 95.73, + "mslp": 100280, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2026-02-08T06:00Z", + "screenTemperature": 7.52, + "maxScreenAirTemp": 7.67, + "minScreenAirTemp": 7.47, + "screenDewPointTemperature": 6.7, + "feelsLikeTemperature": 6.68, + "windSpeed10m": 1.6, + "windDirectionFrom10m": 183, + "windGustSpeed10m": 4.97, + "max10mWindGust": 5.64, + "visibility": 7420, + "screenRelativeHumidity": 94.66, + "mslp": 100327, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 5 + }, + { + "time": "2026-02-08T07:00Z", + "screenTemperature": 7.63, + "maxScreenAirTemp": 7.64, + "minScreenAirTemp": 7.52, + "screenDewPointTemperature": 6.82, + "feelsLikeTemperature": 6.29, + "windSpeed10m": 2.18, + "windDirectionFrom10m": 182, + "windGustSpeed10m": 5.54, + "max10mWindGust": 6.01, + "visibility": 7504, + "screenRelativeHumidity": 94.7, + "mslp": 100390, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 10 + }, + { + "time": "2026-02-08T08:00Z", + "screenTemperature": 7.81, + "maxScreenAirTemp": 7.81, + "minScreenAirTemp": 7.63, + "screenDewPointTemperature": 7.06, + "feelsLikeTemperature": 6.72, + "windSpeed10m": 1.93, + "windDirectionFrom10m": 190, + "windGustSpeed10m": 4.93, + "max10mWindGust": 5.86, + "visibility": 6197, + "screenRelativeHumidity": 95.08, + "mslp": 100450, + "uvIndex": 1, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 9 + }, + { + "time": "2026-02-08T09:00Z", + "screenTemperature": 8.12, + "maxScreenAirTemp": 8.13, + "minScreenAirTemp": 7.81, + "screenDewPointTemperature": 7.2, + "feelsLikeTemperature": 7.05, + "windSpeed10m": 1.95, + "windDirectionFrom10m": 180, + "windGustSpeed10m": 4.53, + "max10mWindGust": 4.92, + "visibility": 6327, + "screenRelativeHumidity": 94.03, + "mslp": 100503, + "uvIndex": 1, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 10 + }, + { + "time": "2026-02-08T10:00Z", + "screenTemperature": 8.86, + "maxScreenAirTemp": 8.86, + "minScreenAirTemp": 8.12, + "screenDewPointTemperature": 7.54, + "feelsLikeTemperature": 7.73, + "windSpeed10m": 2.17, + "windDirectionFrom10m": 176, + "windGustSpeed10m": 4.42, + "max10mWindGust": 4.54, + "visibility": 7222, + "screenRelativeHumidity": 91.55, + "mslp": 100533, + "uvIndex": 1, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 10 + }, + { + "time": "2026-02-08T11:00Z", + "screenTemperature": 9.57, + "maxScreenAirTemp": 9.57, + "minScreenAirTemp": 8.86, + "screenDewPointTemperature": 7.57, + "feelsLikeTemperature": 8.41, + "windSpeed10m": 2.37, + "windDirectionFrom10m": 181, + "windGustSpeed10m": 4.88, + "max10mWindGust": 4.88, + "visibility": 10651, + "screenRelativeHumidity": 87.5, + "mslp": 100560, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-08T12:00Z", + "screenTemperature": 10.27, + "maxScreenAirTemp": 10.28, + "minScreenAirTemp": 9.57, + "screenDewPointTemperature": 7.41, + "feelsLikeTemperature": 9.29, + "windSpeed10m": 2.28, + "windDirectionFrom10m": 185, + "windGustSpeed10m": 4.71, + "max10mWindGust": 4.71, + "visibility": 12395, + "screenRelativeHumidity": 82.51, + "mslp": 100560, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-08T13:00Z", + "screenTemperature": 10.75, + "maxScreenAirTemp": 10.76, + "minScreenAirTemp": 10.27, + "screenDewPointTemperature": 6.87, + "feelsLikeTemperature": 9.48, + "windSpeed10m": 2.77, + "windDirectionFrom10m": 184, + "windGustSpeed10m": 5.56, + "max10mWindGust": 5.56, + "visibility": 14708, + "screenRelativeHumidity": 76.97, + "mslp": 100530, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-08T14:00Z", + "screenTemperature": 10.84, + "maxScreenAirTemp": 10.88, + "minScreenAirTemp": 10.75, + "screenDewPointTemperature": 6.71, + "feelsLikeTemperature": 9.4, + "windSpeed10m": 3.1, + "windDirectionFrom10m": 186, + "windGustSpeed10m": 6.12, + "max10mWindGust": 6.29, + "visibility": 16685, + "screenRelativeHumidity": 75.74, + "mslp": 100530, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 7 + }, + { + "time": "2026-02-08T15:00Z", + "screenTemperature": 10.76, + "maxScreenAirTemp": 10.84, + "minScreenAirTemp": 10.73, + "screenDewPointTemperature": 6.67, + "feelsLikeTemperature": 9.29, + "windSpeed10m": 3.11, + "windDirectionFrom10m": 182, + "windGustSpeed10m": 6.07, + "max10mWindGust": 6.26, + "visibility": 16963, + "screenRelativeHumidity": 75.87, + "mslp": 100527, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 7 + }, + { + "time": "2026-02-08T16:00Z", + "screenTemperature": 10.36, + "maxScreenAirTemp": 10.76, + "minScreenAirTemp": 10.33, + "screenDewPointTemperature": 6.66, + "feelsLikeTemperature": 8.88, + "windSpeed10m": 3.07, + "windDirectionFrom10m": 180, + "windGustSpeed10m": 5.99, + "max10mWindGust": 6.33, + "visibility": 17519, + "screenRelativeHumidity": 77.89, + "mslp": 100530, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 7 + }, + { + "time": "2026-02-08T17:00Z", + "screenTemperature": 9.94, + "maxScreenAirTemp": 10.36, + "minScreenAirTemp": 9.93, + "screenDewPointTemperature": 6.86, + "feelsLikeTemperature": 8.52, + "windSpeed10m": 2.84, + "windDirectionFrom10m": 179, + "windGustSpeed10m": 5.58, + "max10mWindGust": 6.05, + "visibility": 16071, + "screenRelativeHumidity": 81.23, + "mslp": 100550, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 10 + }, + { + "time": "2026-02-08T18:00Z", + "screenTemperature": 9.55, + "maxScreenAirTemp": 9.94, + "minScreenAirTemp": 9.54, + "screenDewPointTemperature": 6.97, + "feelsLikeTemperature": 8.1, + "windSpeed10m": 2.81, + "windDirectionFrom10m": 176, + "windGustSpeed10m": 5.68, + "max10mWindGust": 6.14, + "visibility": 15755, + "screenRelativeHumidity": 83.93, + "mslp": 100560, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 12 + }, + { + "time": "2026-02-08T19:00Z", + "screenTemperature": 9.23, + "maxScreenAirTemp": 9.55, + "minScreenAirTemp": 9.22, + "screenDewPointTemperature": 7.05, + "feelsLikeTemperature": 7.71, + "windSpeed10m": 2.83, + "windDirectionFrom10m": 168, + "windGustSpeed10m": 5.67, + "max10mWindGust": 6.27, + "visibility": 14548, + "screenRelativeHumidity": 86.32, + "mslp": 100547, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 18 + }, + { + "time": "2026-02-08T20:00Z", + "screenTemperature": 9.05, + "maxScreenAirTemp": 9.23, + "minScreenAirTemp": 9.04, + "screenDewPointTemperature": 7.13, + "feelsLikeTemperature": 7.66, + "windSpeed10m": 2.57, + "windDirectionFrom10m": 173, + "windGustSpeed10m": 5.24, + "max10mWindGust": 6.08, + "visibility": 13961, + "screenRelativeHumidity": 87.79, + "mslp": 100547, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 17 + }, + { + "time": "2026-02-08T21:00Z", + "screenTemperature": 8.81, + "maxScreenAirTemp": 9.05, + "minScreenAirTemp": 8.81, + "screenDewPointTemperature": 7.2, + "feelsLikeTemperature": 7.4, + "windSpeed10m": 2.56, + "windDirectionFrom10m": 163, + "windGustSpeed10m": 5.38, + "max10mWindGust": 5.73, + "visibility": 13739, + "screenRelativeHumidity": 89.7, + "mslp": 100540, + "uvIndex": 0, + "significantWeatherCode": 12, + "precipitationRate": 0.07, + "totalPrecipAmount": 0.2, + "totalSnowAmount": 0, + "probOfPrecipitation": 45 + }, + { + "time": "2026-02-08T22:00Z", + "screenTemperature": 8.74, + "maxScreenAirTemp": 8.81, + "minScreenAirTemp": 8.72, + "screenDewPointTemperature": 7.12, + "feelsLikeTemperature": 7.36, + "windSpeed10m": 2.47, + "windDirectionFrom10m": 164, + "windGustSpeed10m": 5.43, + "max10mWindGust": 5.67, + "visibility": 11395, + "screenRelativeHumidity": 89.66, + "mslp": 100530, + "uvIndex": 0, + "significantWeatherCode": 9, + "precipitationRate": 0.23, + "totalPrecipAmount": 0.18, + "totalSnowAmount": 0, + "probOfPrecipitation": 44 + }, + { + "time": "2026-02-08T23:00Z", + "screenTemperature": 8.57, + "maxScreenAirTemp": 8.74, + "minScreenAirTemp": 8.53, + "screenDewPointTemperature": 7.23, + "feelsLikeTemperature": 7.31, + "windSpeed10m": 2.28, + "windDirectionFrom10m": 149, + "windGustSpeed10m": 5.28, + "max10mWindGust": 5.87, + "visibility": 10051, + "screenRelativeHumidity": 91.35, + "mslp": 100497, + "uvIndex": 0, + "significantWeatherCode": 12, + "precipitationRate": 0.22, + "totalPrecipAmount": 0.26, + "totalSnowAmount": 0, + "probOfPrecipitation": 54 + }, + { + "time": "2026-02-09T00:00Z", + "screenTemperature": 8.52, + "maxScreenAirTemp": 8.57, + "minScreenAirTemp": 8.49, + "screenDewPointTemperature": 7.17, + "feelsLikeTemperature": 7.21, + "windSpeed10m": 2.32, + "windDirectionFrom10m": 151, + "windGustSpeed10m": 5.44, + "max10mWindGust": 5.96, + "visibility": 13108, + "screenRelativeHumidity": 91.42, + "mslp": 100475, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 13 + }, + { + "time": "2026-02-09T01:00Z", + "screenTemperature": 8.39, + "maxScreenAirTemp": 8.52, + "minScreenAirTemp": 8.36, + "screenDewPointTemperature": 7.08, + "feelsLikeTemperature": 6.94, + "windSpeed10m": 2.49, + "windDirectionFrom10m": 157, + "windGustSpeed10m": 5.83, + "max10mWindGust": 6.54, + "visibility": 14678, + "screenRelativeHumidity": 91.55, + "mslp": 100430, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 12 + }, + { + "time": "2026-02-09T02:00Z", + "screenTemperature": 8.23, + "maxScreenAirTemp": 8.39, + "minScreenAirTemp": 8.18, + "screenDewPointTemperature": 6.88, + "feelsLikeTemperature": 6.86, + "windSpeed10m": 2.34, + "windDirectionFrom10m": 155, + "windGustSpeed10m": 5.35, + "max10mWindGust": 6.7, + "visibility": 13081, + "screenRelativeHumidity": 91.35, + "mslp": 100385, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 16 + }, + { + "time": "2026-02-09T03:00Z", + "screenTemperature": 8.1, + "maxScreenAirTemp": 8.23, + "minScreenAirTemp": 8.05, + "screenDewPointTemperature": 6.78, + "feelsLikeTemperature": 6.67, + "windSpeed10m": 2.37, + "windDirectionFrom10m": 150, + "windGustSpeed10m": 5.35, + "max10mWindGust": 6.67, + "visibility": 15140, + "screenRelativeHumidity": 91.56, + "mslp": 100335, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 7 + }, + { + "time": "2026-02-09T04:00Z", + "screenTemperature": 7.9, + "maxScreenAirTemp": 8.1, + "minScreenAirTemp": 7.86, + "screenDewPointTemperature": 6.58, + "feelsLikeTemperature": 6.41, + "windSpeed10m": 2.39, + "windDirectionFrom10m": 149, + "windGustSpeed10m": 5.43, + "max10mWindGust": 6.53, + "visibility": 15366, + "screenRelativeHumidity": 91.65, + "mslp": 100305, + "uvIndex": 0, + "significantWeatherCode": 8, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 10 + }, + { + "time": "2026-02-09T05:00Z", + "screenTemperature": 7.71, + "maxScreenAirTemp": 7.9, + "minScreenAirTemp": 7.65, + "screenDewPointTemperature": 6.51, + "feelsLikeTemperature": 6.28, + "windSpeed10m": 2.3, + "windDirectionFrom10m": 146, + "windGustSpeed10m": 5.3, + "max10mWindGust": 6.91, + "visibility": 14570, + "screenRelativeHumidity": 92.33, + "mslp": 100283, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-09T06:00Z", + "screenTemperature": 7.56, + "maxScreenAirTemp": 7.71, + "minScreenAirTemp": 7.54, + "screenDewPointTemperature": 6.38, + "feelsLikeTemperature": 6.11, + "windSpeed10m": 2.29, + "windDirectionFrom10m": 148, + "windGustSpeed10m": 5.34, + "max10mWindGust": 6.56, + "visibility": 13685, + "screenRelativeHumidity": 92.49, + "mslp": 100280, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-09T07:00Z", + "screenTemperature": 7.61, + "maxScreenAirTemp": 7.62, + "minScreenAirTemp": 7.56, + "screenDewPointTemperature": 6.43, + "feelsLikeTemperature": 6.17, + "windSpeed10m": 2.28, + "windDirectionFrom10m": 146, + "windGustSpeed10m": 5.26, + "max10mWindGust": 6.34, + "visibility": 13185, + "screenRelativeHumidity": 92.48, + "mslp": 100282, + "uvIndex": 0, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 7 + }, + { + "time": "2026-02-09T08:00Z", + "screenTemperature": 7.7, + "maxScreenAirTemp": 7.75, + "minScreenAirTemp": 7.61, + "screenDewPointTemperature": 6.48, + "feelsLikeTemperature": 6.25, + "windSpeed10m": 2.34, + "windDirectionFrom10m": 146, + "windGustSpeed10m": 5.57, + "max10mWindGust": 5.76, + "visibility": 13541, + "screenRelativeHumidity": 92.21, + "mslp": 100275, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-09T09:00Z", + "screenTemperature": 7.92, + "maxScreenAirTemp": 7.92, + "minScreenAirTemp": 7.7, + "screenDewPointTemperature": 6.53, + "feelsLikeTemperature": 6.42, + "windSpeed10m": 2.43, + "windDirectionFrom10m": 142, + "windGustSpeed10m": 5.54, + "max10mWindGust": 6.4, + "visibility": 13747, + "screenRelativeHumidity": 91.19, + "mslp": 100275, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-09T10:00Z", + "screenTemperature": 8.6, + "maxScreenAirTemp": 8.65, + "minScreenAirTemp": 7.92, + "screenDewPointTemperature": 6.57, + "feelsLikeTemperature": 7.09, + "windSpeed10m": 2.66, + "windDirectionFrom10m": 146, + "windGustSpeed10m": 5.71, + "max10mWindGust": 5.71, + "visibility": 14552, + "screenRelativeHumidity": 87.48, + "mslp": 100241, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-09T11:00Z", + "screenTemperature": 9.43, + "maxScreenAirTemp": 9.43, + "minScreenAirTemp": 8.6, + "screenDewPointTemperature": 6.49, + "feelsLikeTemperature": 7.83, + "windSpeed10m": 3.0, + "windDirectionFrom10m": 151, + "windGustSpeed10m": 6.25, + "max10mWindGust": 6.25, + "visibility": 19055, + "screenRelativeHumidity": 82.28, + "mslp": 100209, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "totalPrecipAmount": 0.0, + "totalSnowAmount": 0, + "probOfPrecipitation": 6 + }, + { + "time": "2026-02-09T12:00Z", + "screenTemperature": 10.25, + "screenDewPointTemperature": 6.37, + "feelsLikeTemperature": 8.61, + "windSpeed10m": 3.28, + "windDirectionFrom10m": 155, + "windGustSpeed10m": 6.87, + "visibility": 20517, + "screenRelativeHumidity": 77.18, + "mslp": 100150, + "uvIndex": 1, + "significantWeatherCode": 7, + "precipitationRate": 0.0, + "probOfPrecipitation": 6 + } + ] + } + } + ], + "parameters": [ + { + "totalSnowAmount": { "type": "Parameter", "description": "Total Snow Amount Over Previous Hour", "unit": { "label": "millimetres", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "mm" } } }, + "screenTemperature": { "type": "Parameter", "description": "Screen Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "visibility": { "type": "Parameter", "description": "Visibility", "unit": { "label": "metres", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m" } } }, + "windDirectionFrom10m": { "type": "Parameter", "description": "10m Wind From Direction", "unit": { "label": "degrees", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "deg" } } }, + "precipitationRate": { "type": "Parameter", "description": "Precipitation Rate", "unit": { "label": "millimetres per hour", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "mm/h" } } }, + "maxScreenAirTemp": { "type": "Parameter", "description": "Maximum Screen Air Temperature Over Previous Hour", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "feelsLikeTemperature": { "type": "Parameter", "description": "Feels Like Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "screenDewPointTemperature": { "type": "Parameter", "description": "Screen Dew Point Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "screenRelativeHumidity": { "type": "Parameter", "description": "Screen Relative Humidity", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "windSpeed10m": { "type": "Parameter", "description": "10m Wind Speed", "unit": { "label": "metres per second", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s" } } }, + "probOfPrecipitation": { "type": "Parameter", "description": "Probability of Precipitation", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "max10mWindGust": { "type": "Parameter", "description": "Maximum 10m Wind Gust Speed Over Previous Hour", "unit": { "label": "metres per second", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s" } } }, + "significantWeatherCode": { "type": "Parameter", "description": "Significant Weather Code", "unit": { "label": "dimensionless", "symbol": { "value": "https://datahub.metoffice.gov.uk/", "type": "1" } } }, + "minScreenAirTemp": { "type": "Parameter", "description": "Minimum Screen Air Temperature Over Previous Hour", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "totalPrecipAmount": { "type": "Parameter", "description": "Total Precipitation Amount Over Previous Hour", "unit": { "label": "millimetres", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "mm" } } }, + "mslp": { "type": "Parameter", "description": "Mean Sea Level Pressure", "unit": { "label": "pascals", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Pa" } } }, + "windGustSpeed10m": { "type": "Parameter", "description": "10m Wind Gust Speed", "unit": { "label": "metres per second", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s" } } }, + "uvIndex": { "type": "Parameter", "description": "UV Index", "unit": { "label": "dimensionless", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "1" } } } + } + ] +} diff --git a/tests/mocks/weather_ukmetoffice_daily.json b/tests/mocks/weather_ukmetoffice_daily.json new file mode 100644 index 0000000..e774a0f --- /dev/null +++ b/tests/mocks/weather_ukmetoffice_daily.json @@ -0,0 +1,419 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-0.12480000000000001, 51.5081, 11.0] }, + "properties": { + "location": { "name": "London" }, + "requestPointDistance": 221.7807, + "modelRunDate": "2026-02-07T12:00Z", + "timeSeries": [ + { + "time": "2026-02-06T00:00Z", + "midday10MWindSpeed": 0.82, + "midnight10MWindSpeed": 1.59, + "midday10MWindDirection": 121, + "midnight10MWindDirection": 175, + "midday10MWindGust": 3.09, + "midnight10MWindGust": 7.72, + "middayVisibility": 4000, + "midnightVisibility": 12560, + "middayRelativeHumidity": 92.93, + "midnightRelativeHumidity": 89.85, + "middayMslp": 98480, + "midnightMslp": 99260, + "nightSignificantWeatherCode": 2, + "dayMaxScreenTemperature": 11.37, + "nightMinScreenTemperature": 7.26, + "dayUpperBoundMaxTemp": 12.53, + "nightUpperBoundMinTemp": 8.77, + "dayLowerBoundMaxTemp": 9.86, + "nightLowerBoundMinTemp": 6.62, + "nightMinFeelsLikeTemp": 5.98, + "dayUpperBoundMaxFeelsLikeTemp": 11.58, + "nightUpperBoundMinFeelsLikeTemp": 6.83, + "dayLowerBoundMaxFeelsLikeTemp": 8.89, + "nightLowerBoundMinFeelsLikeTemp": 5.23, + "nightProbabilityOfPrecipitation": 85, + "nightProbabilityOfSnow": 0, + "nightProbabilityOfHeavySnow": 0, + "nightProbabilityOfRain": 85, + "nightProbabilityOfHeavyRain": 80, + "nightProbabilityOfHail": 16, + "nightProbabilityOfSferics": 8 + }, + { + "time": "2026-02-07T00:00Z", + "midday10MWindSpeed": 1.9, + "midnight10MWindSpeed": 2.52, + "midday10MWindDirection": 165, + "midnight10MWindDirection": 184, + "midday10MWindGust": 7.72, + "midnight10MWindGust": 7.13, + "middayVisibility": 8550, + "midnightVisibility": 13732, + "middayRelativeHumidity": 93.08, + "midnightRelativeHumidity": 93.62, + "middayMslp": 99440, + "midnightMslp": 100100, + "maxUvIndex": 1, + "daySignificantWeatherCode": 12, + "nightSignificantWeatherCode": 7, + "dayMaxScreenTemperature": 10.5, + "nightMinScreenTemperature": 7.52, + "dayUpperBoundMaxTemp": 11.72, + "nightUpperBoundMinTemp": 9.39, + "dayLowerBoundMaxTemp": 9.78, + "nightLowerBoundMinTemp": 5.83, + "dayMaxFeelsLikeTemp": 8.76, + "nightMinFeelsLikeTemp": 6.29, + "dayUpperBoundMaxFeelsLikeTemp": 9.47, + "nightUpperBoundMinFeelsLikeTemp": 8.28, + "dayLowerBoundMaxFeelsLikeTemp": 8.06, + "nightLowerBoundMinFeelsLikeTemp": 5.22, + "dayProbabilityOfPrecipitation": 91, + "nightProbabilityOfPrecipitation": 10, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 91, + "nightProbabilityOfRain": 10, + "dayProbabilityOfHeavyRain": 86, + "nightProbabilityOfHeavyRain": 2, + "dayProbabilityOfHail": 17, + "nightProbabilityOfHail": 0, + "dayProbabilityOfSferics": 11, + "nightProbabilityOfSferics": 0 + }, + { + "time": "2026-02-08T00:00Z", + "midday10MWindSpeed": 2.28, + "midnight10MWindSpeed": 2.32, + "midday10MWindDirection": 185, + "midnight10MWindDirection": 151, + "midday10MWindGust": 4.71, + "midnight10MWindGust": 5.44, + "middayVisibility": 12395, + "midnightVisibility": 13108, + "middayRelativeHumidity": 82.51, + "midnightRelativeHumidity": 91.42, + "middayMslp": 100559, + "midnightMslp": 100474, + "maxUvIndex": 1, + "daySignificantWeatherCode": 7, + "nightSignificantWeatherCode": 7, + "dayMaxScreenTemperature": 11.07, + "nightMinScreenTemperature": 7.56, + "dayUpperBoundMaxTemp": 11.84, + "nightUpperBoundMinTemp": 8.59, + "dayLowerBoundMaxTemp": 9.76, + "nightLowerBoundMinTemp": 5.18, + "dayMaxFeelsLikeTemp": 9.48, + "nightMinFeelsLikeTemp": 6.11, + "dayUpperBoundMaxFeelsLikeTemp": 10.97, + "nightUpperBoundMinFeelsLikeTemp": 7.32, + "dayLowerBoundMaxFeelsLikeTemp": 8.13, + "nightLowerBoundMinFeelsLikeTemp": 5.38, + "dayProbabilityOfPrecipitation": 10, + "nightProbabilityOfPrecipitation": 54, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 10, + "nightProbabilityOfRain": 54, + "dayProbabilityOfHeavyRain": 2, + "nightProbabilityOfHeavyRain": 32, + "dayProbabilityOfHail": 0, + "nightProbabilityOfHail": 4, + "dayProbabilityOfSferics": 0, + "nightProbabilityOfSferics": 6 + }, + { + "time": "2026-02-09T00:00Z", + "midday10MWindSpeed": 3.28, + "midnight10MWindSpeed": 3.42, + "midday10MWindDirection": 155, + "midnight10MWindDirection": 121, + "midday10MWindGust": 6.87, + "midnight10MWindGust": 7.02, + "middayVisibility": 20517, + "midnightVisibility": 18708, + "middayRelativeHumidity": 77.18, + "midnightRelativeHumidity": 86.28, + "middayMslp": 100150, + "midnightMslp": 99580, + "maxUvIndex": 1, + "daySignificantWeatherCode": 7, + "nightSignificantWeatherCode": 7, + "dayMaxScreenTemperature": 10.89, + "nightMinScreenTemperature": 6.93, + "dayUpperBoundMaxTemp": 11.87, + "nightUpperBoundMinTemp": 8.61, + "dayLowerBoundMaxTemp": 8.55, + "nightLowerBoundMinTemp": 4.78, + "dayMaxFeelsLikeTemp": 9.06, + "nightMinFeelsLikeTemp": 5.13, + "dayUpperBoundMaxFeelsLikeTemp": 9.87, + "nightUpperBoundMinFeelsLikeTemp": 6.29, + "dayLowerBoundMaxFeelsLikeTemp": 6.57, + "nightLowerBoundMinFeelsLikeTemp": 3.3, + "dayProbabilityOfPrecipitation": 6, + "nightProbabilityOfPrecipitation": 18, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 6, + "nightProbabilityOfRain": 18, + "dayProbabilityOfHeavyRain": 1, + "nightProbabilityOfHeavyRain": 7, + "dayProbabilityOfHail": 0, + "nightProbabilityOfHail": 1, + "dayProbabilityOfSferics": 0, + "nightProbabilityOfSferics": 0 + }, + { + "time": "2026-02-10T00:00Z", + "midday10MWindSpeed": 3.09, + "midnight10MWindSpeed": 3.12, + "midday10MWindDirection": 150, + "midnight10MWindDirection": 191, + "midday10MWindGust": 6.52, + "midnight10MWindGust": 6.18, + "middayVisibility": 17148, + "midnightVisibility": 12750, + "middayRelativeHumidity": 86.68, + "midnightRelativeHumidity": 93.78, + "middayMslp": 98991, + "midnightMslp": 98238, + "maxUvIndex": 1, + "daySignificantWeatherCode": 8, + "nightSignificantWeatherCode": 12, + "dayMaxScreenTemperature": 10.47, + "nightMinScreenTemperature": 8.75, + "dayUpperBoundMaxTemp": 13.15, + "nightUpperBoundMinTemp": 10.63, + "dayLowerBoundMaxTemp": 7.91, + "nightLowerBoundMinTemp": 6.14, + "dayMaxFeelsLikeTemp": 8.44, + "nightMinFeelsLikeTemp": 7.65, + "dayUpperBoundMaxFeelsLikeTemp": 11.11, + "nightUpperBoundMinFeelsLikeTemp": 8.73, + "dayLowerBoundMaxFeelsLikeTemp": 6.87, + "nightLowerBoundMinFeelsLikeTemp": 5.59, + "dayProbabilityOfPrecipitation": 49, + "nightProbabilityOfPrecipitation": 54, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 49, + "nightProbabilityOfRain": 54, + "dayProbabilityOfHeavyRain": 26, + "nightProbabilityOfHeavyRain": 32, + "dayProbabilityOfHail": 1, + "nightProbabilityOfHail": 2, + "dayProbabilityOfSferics": 1, + "nightProbabilityOfSferics": 2 + }, + { + "time": "2026-02-11T00:00Z", + "midday10MWindSpeed": 4.2, + "midnight10MWindSpeed": 3.4, + "midday10MWindDirection": 228, + "midnight10MWindDirection": 241, + "midday10MWindGust": 9.23, + "midnight10MWindGust": 6.88, + "middayVisibility": 20709, + "midnightVisibility": 18608, + "middayRelativeHumidity": 82.88, + "midnightRelativeHumidity": 89.7, + "middayMslp": 98098, + "midnightMslp": 97870, + "maxUvIndex": 1, + "daySignificantWeatherCode": 12, + "nightSignificantWeatherCode": 12, + "dayMaxScreenTemperature": 11.71, + "nightMinScreenTemperature": 7.6, + "dayUpperBoundMaxTemp": 13.23, + "nightUpperBoundMinTemp": 9.77, + "dayLowerBoundMaxTemp": 7.85, + "nightLowerBoundMinTemp": 4.71, + "dayMaxFeelsLikeTemp": 9.43, + "nightMinFeelsLikeTemp": 5.5, + "dayUpperBoundMaxFeelsLikeTemp": 11.39, + "nightUpperBoundMinFeelsLikeTemp": 7.6, + "dayLowerBoundMaxFeelsLikeTemp": 8.0, + "nightLowerBoundMinFeelsLikeTemp": 4.33, + "dayProbabilityOfPrecipitation": 50, + "nightProbabilityOfPrecipitation": 46, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 0, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 50, + "nightProbabilityOfRain": 46, + "dayProbabilityOfHeavyRain": 28, + "nightProbabilityOfHeavyRain": 25, + "dayProbabilityOfHail": 2, + "nightProbabilityOfHail": 1, + "dayProbabilityOfSferics": 5, + "nightProbabilityOfSferics": 4 + }, + { + "time": "2026-02-12T00:00Z", + "midday10MWindSpeed": 3.99, + "midnight10MWindSpeed": 3.62, + "midday10MWindDirection": 297, + "midnight10MWindDirection": 321, + "midday10MWindGust": 8.71, + "midnight10MWindGust": 7.52, + "middayVisibility": 21894, + "midnightVisibility": 24612, + "middayRelativeHumidity": 80.41, + "midnightRelativeHumidity": 83.6, + "middayMslp": 98255, + "midnightMslp": 98981, + "maxUvIndex": 1, + "daySignificantWeatherCode": 8, + "nightSignificantWeatherCode": 7, + "dayMaxScreenTemperature": 9.92, + "nightMinScreenTemperature": 3.15, + "dayUpperBoundMaxTemp": 12.14, + "nightUpperBoundMinTemp": 9.05, + "dayLowerBoundMaxTemp": 5.26, + "nightLowerBoundMinTemp": 0.41, + "dayMaxFeelsLikeTemp": 6.69, + "nightMinFeelsLikeTemp": -0.03, + "dayUpperBoundMaxFeelsLikeTemp": 10.16, + "nightUpperBoundMinFeelsLikeTemp": 7.3, + "dayLowerBoundMaxFeelsLikeTemp": 4.45, + "nightLowerBoundMinFeelsLikeTemp": -3.64, + "dayProbabilityOfPrecipitation": 21, + "nightProbabilityOfPrecipitation": 22, + "dayProbabilityOfSnow": 0, + "nightProbabilityOfSnow": 2, + "dayProbabilityOfHeavySnow": 0, + "nightProbabilityOfHeavySnow": 1, + "dayProbabilityOfRain": 21, + "nightProbabilityOfRain": 22, + "dayProbabilityOfHeavyRain": 9, + "nightProbabilityOfHeavyRain": 10, + "dayProbabilityOfHail": 1, + "nightProbabilityOfHail": 1, + "dayProbabilityOfSferics": 2, + "nightProbabilityOfSferics": 2 + }, + { + "time": "2026-02-13T00:00Z", + "midday10MWindSpeed": 4.14, + "midnight10MWindSpeed": 2.83, + "midday10MWindDirection": 322, + "midnight10MWindDirection": 307, + "midday10MWindGust": 9.4, + "midnight10MWindGust": 5.92, + "middayVisibility": 34312, + "midnightVisibility": 34597, + "middayRelativeHumidity": 66.26, + "midnightRelativeHumidity": 77.93, + "middayMslp": 99718, + "midnightMslp": 100243, + "maxUvIndex": 1, + "daySignificantWeatherCode": 1, + "nightSignificantWeatherCode": 0, + "dayMaxScreenTemperature": 6.79, + "nightMinScreenTemperature": 0.85, + "dayUpperBoundMaxTemp": 11.07, + "nightUpperBoundMinTemp": 7.3, + "dayLowerBoundMaxTemp": 2.84, + "nightLowerBoundMinTemp": -1.75, + "dayMaxFeelsLikeTemp": 2.21, + "nightMinFeelsLikeTemp": -2.05, + "dayUpperBoundMaxFeelsLikeTemp": 9.57, + "nightUpperBoundMinFeelsLikeTemp": 6.72, + "dayLowerBoundMaxFeelsLikeTemp": -0.29, + "nightLowerBoundMinFeelsLikeTemp": -3.77, + "dayProbabilityOfPrecipitation": 11, + "nightProbabilityOfPrecipitation": 6, + "dayProbabilityOfSnow": 2, + "nightProbabilityOfSnow": 1, + "dayProbabilityOfHeavySnow": 1, + "nightProbabilityOfHeavySnow": 0, + "dayProbabilityOfRain": 9, + "nightProbabilityOfRain": 6, + "dayProbabilityOfHeavyRain": 4, + "nightProbabilityOfHeavyRain": 3, + "dayProbabilityOfHail": 1, + "nightProbabilityOfHail": 0, + "dayProbabilityOfSferics": 1, + "nightProbabilityOfSferics": 0 + } + ] + } + } + ], + "parameters": [ + { + "daySignificantWeatherCode": { "type": "Parameter", "description": "Day Significant Weather Code", "unit": { "label": "dimensionless", "symbol": { "value": "https://datahub.metoffice.gov.uk/", "type": "1" } } }, + "midnightRelativeHumidity": { "type": "Parameter", "description": "Relative Humidity at Local Midnight", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "nightProbabilityOfHeavyRain": { "type": "Parameter", "description": "Probability of Heavy Rain During The Night", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "midnight10MWindSpeed": { "type": "Parameter", "description": "10m Wind Speed at Local Midnight", "unit": { "label": "metres per second", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s" } } }, + "nightUpperBoundMinFeelsLikeTemp": { + "type": "Parameter", + "description": "Upper Bound on Night Minimum Feels Like Air Temperature", + "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } + }, + "nightUpperBoundMinTemp": { "type": "Parameter", "description": "Upper Bound on Night Minimum Screen Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "midnightVisibility": { "type": "Parameter", "description": "Visibility at Local Midnight", "unit": { "label": "metres", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m" } } }, + "dayUpperBoundMaxFeelsLikeTemp": { + "type": "Parameter", + "description": "Upper Bound on Day Maximum Feels Like Air Temperature", + "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } + }, + "nightProbabilityOfRain": { "type": "Parameter", "description": "Probability of Rain During The Night", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "midday10MWindDirection": { "type": "Parameter", "description": "10m Wind Direction at Local Midday", "unit": { "label": "degrees", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "deg" } } }, + "nightLowerBoundMinFeelsLikeTemp": { + "type": "Parameter", + "description": "Lower Bound on Night Minimum Feels Like Air Temperature", + "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } + }, + "nightProbabilityOfHail": { "type": "Parameter", "description": "Probability of Hail During The Night", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "middayMslp": { "type": "Parameter", "description": "Mean Sea Level Pressure at Local Midday", "unit": { "label": "pascals", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Pa" } } }, + "dayProbabilityOfHeavySnow": { "type": "Parameter", "description": "Probability of Heavy Snow During The Day", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "nightProbabilityOfPrecipitation": { "type": "Parameter", "description": "Probability of Precipitation During The Night", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "dayProbabilityOfHail": { "type": "Parameter", "description": "Probability of Hail During The Day", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "dayProbabilityOfRain": { "type": "Parameter", "description": "Probability of Rain During The Day", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "midday10MWindSpeed": { "type": "Parameter", "description": "10m Wind Speed at Local Midday", "unit": { "label": "metres per second", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s" } } }, + "midday10MWindGust": { "type": "Parameter", "description": "10m Wind Gust Speed at Local Midday", "unit": { "label": "metres per second", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s" } } }, + "middayVisibility": { "type": "Parameter", "description": "Visibility at Local Midday", "unit": { "label": "metres", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m" } } }, + "midnight10MWindGust": { "type": "Parameter", "description": "10m Wind Gust Speed at Local Midnight", "unit": { "label": "metres per second", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "m/s" } } }, + "midnightMslp": { "type": "Parameter", "description": "Mean Sea Level Pressure at Local Midnight", "unit": { "label": "pascals", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Pa" } } }, + "dayProbabilityOfSferics": { "type": "Parameter", "description": "Probability of Sferics During The Day", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "nightSignificantWeatherCode": { "type": "Parameter", "description": "Night Significant Weather Code", "unit": { "label": "dimensionless", "symbol": { "value": "https://datahub.metoffice.gov.uk/", "type": "1" } } }, + "dayProbabilityOfPrecipitation": { "type": "Parameter", "description": "Probability of Precipitation During The Day", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "dayProbabilityOfHeavyRain": { "type": "Parameter", "description": "Probability of Heavy Rain During The Day", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "dayMaxScreenTemperature": { "type": "Parameter", "description": "Day Maximum Screen Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "nightMinScreenTemperature": { "type": "Parameter", "description": "Night Minimum Screen Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "midnight10MWindDirection": { "type": "Parameter", "description": "10m Wind Direction at Local Midnight", "unit": { "label": "degrees", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "deg" } } }, + "maxUvIndex": { "type": "Parameter", "description": "Day Maximum UV Index", "unit": { "label": "dimensionless", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "1" } } }, + "dayProbabilityOfSnow": { "type": "Parameter", "description": "Probability of Snow During The Day", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "nightProbabilityOfSnow": { "type": "Parameter", "description": "Probability of Snow During The Night", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "dayLowerBoundMaxTemp": { "type": "Parameter", "description": "Lower Bound on Day Maximum Screen Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "nightProbabilityOfHeavySnow": { "type": "Parameter", "description": "Probability of Heavy Snow During The Night", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "dayLowerBoundMaxFeelsLikeTemp": { + "type": "Parameter", + "description": "Lower Bound on Day Maximum Feels Like Air Temperature", + "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } + }, + "dayUpperBoundMaxTemp": { "type": "Parameter", "description": "Upper Bound on Day Maximum Screen Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "dayMaxFeelsLikeTemp": { "type": "Parameter", "description": "Day Maximum Feels Like Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "middayRelativeHumidity": { "type": "Parameter", "description": "Relative Humidity at Local Midday", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } }, + "nightLowerBoundMinTemp": { "type": "Parameter", "description": "Lower Bound on Night Minimum Screen Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "nightMinFeelsLikeTemp": { "type": "Parameter", "description": "Night Minimum Feels Like Air Temperature", "unit": { "label": "degrees Celsius", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "Cel" } } }, + "nightProbabilityOfSferics": { "type": "Parameter", "description": "Probability of Sferics During The Night", "unit": { "label": "percentage", "symbol": { "value": "http://www.opengis.net/def/uom/UCUM/", "type": "%" } } } + } + ] +} diff --git a/tests/mocks/weather_weatherbit.json b/tests/mocks/weather_weatherbit.json new file mode 100644 index 0000000..bc8dfb5 --- /dev/null +++ b/tests/mocks/weather_weatherbit.json @@ -0,0 +1,45 @@ +{ + "count": 1, + "data": [ + { + "app_temp": -0.6, + "aqi": 44, + "city_name": "New York City", + "clouds": 100, + "country_code": "US", + "datetime": "2026-02-06:21", + "dewpt": -9, + "dhi": 62, + "dni": 555, + "elev_angle": 12.55, + "ghi": 175, + "gust": 3.1, + "h_angle": 60, + "lat": 40.7128, + "lon": -74.006, + "ob_time": "2026-02-06 21:25", + "pod": "d", + "precip": 0, + "pres": 1004, + "rh": 47, + "slp": 1004, + "snow": 0, + "solar_rad": 35, + "sources": ["KJRB", "radar", "satellite"], + "state_code": "NY", + "station": "KJRB", + "sunrise": "11:59", + "sunset": "22:21", + "temp": 1, + "timezone": "America/New_York", + "ts": 1770413100, + "uv": 0, + "vis": 16, + "weather": { "code": 804, "description": "Overcast clouds", "icon": "c04d" }, + "wind_cdir": "SSW", + "wind_cdir_full": "south-southwest", + "wind_dir": 210, + "wind_spd": 1.5 + } + ] +} diff --git a/tests/mocks/weather_weatherbit_forecast.json b/tests/mocks/weather_weatherbit_forecast.json new file mode 100644 index 0000000..b5239b8 --- /dev/null +++ b/tests/mocks/weather_weatherbit_forecast.json @@ -0,0 +1,290 @@ +{ + "city_name": "New York City", + "country_code": "US", + "data": [ + { + "app_max_temp": -2.7, + "app_min_temp": -9.8, + "clouds": 76, + "clouds_hi": 8, + "clouds_low": 40, + "clouds_mid": 90, + "datetime": "2026-02-06", + "dewpt": -7.6, + "high_temp": 0.8, + "low_temp": -6.5, + "max_dhi": null, + "max_temp": 0.5, + "min_temp": -6.3, + "moon_phase": 0.68, + "moon_phase_lunation": 0.65, + "moonrise_ts": 1770432076, + "moonset_ts": 1770388275, + "ozone": 405, + "pop": 50, + "precip": 0.25, + "pres": 1005, + "rh": 66, + "slp": 1008, + "snow": 3.75, + "snow_depth": 216.91895, + "sunrise_ts": 1770379197, + "sunset_ts": 1770416511, + "temp": -2, + "ts": 1770372060, + "uv": 1, + "valid_date": "2026-02-06", + "vis": 21.7, + "weather": { "code": 600, "description": "Light snow", "icon": "s01d" }, + "wind_cdir": "SSW", + "wind_cdir_full": "south-southwest", + "wind_dir": 192, + "wind_gust_spd": 3.4, + "wind_spd": 2.4 + }, + { + "app_max_temp": -4.9, + "app_min_temp": -22.5, + "clouds": 76, + "clouds_hi": 0, + "clouds_low": 87, + "clouds_mid": 57, + "datetime": "2026-02-07", + "dewpt": -13.7, + "high_temp": -5.9, + "low_temp": -13.9, + "max_dhi": null, + "max_temp": -0.2, + "min_temp": -12.2, + "moon_phase": 0.59, + "moon_phase_lunation": 0.68, + "moonrise_ts": 1770522306, + "moonset_ts": 1770476147, + "ozone": 452, + "pop": 0, + "precip": 0, + "pres": 1006, + "rh": 61, + "slp": 1009, + "snow": 0, + "snow_depth": 201.76189, + "sunrise_ts": 1770465530, + "sunset_ts": 1770502984, + "temp": -7.5, + "ts": 1770440460, + "uv": 1, + "valid_date": "2026-02-07", + "vis": 17.8, + "weather": { "code": 804, "description": "Overcast clouds", "icon": "c04d" }, + "wind_cdir": "NW", + "wind_cdir_full": "northwest", + "wind_dir": 307, + "wind_gust_spd": 13.2, + "wind_spd": 9.6 + }, + { + "app_max_temp": -16.5, + "app_min_temp": -23.8, + "clouds": 9, + "clouds_hi": 0, + "clouds_low": 17, + "clouds_mid": 0, + "datetime": "2026-02-08", + "dewpt": -18.1, + "high_temp": -7.5, + "low_temp": -12.4, + "max_dhi": null, + "max_temp": -7.5, + "min_temp": -13.9, + "moon_phase": 0.49, + "moon_phase_lunation": 0.71, + "moonrise_ts": 1770612516, + "moonset_ts": 1770564257, + "ozone": 453, + "pop": 0, + "precip": 0, + "pres": 1021, + "rh": 54, + "slp": 1024, + "snow": 0, + "snow_depth": 190.89763, + "sunrise_ts": 1770551862, + "sunset_ts": 1770589457, + "temp": -10.7, + "ts": 1770526860, + "uv": 3, + "valid_date": "2026-02-08", + "vis": 24, + "weather": { "code": 801, "description": "Few clouds", "icon": "c02d" }, + "wind_cdir": "NW", + "wind_cdir_full": "northwest", + "wind_dir": 321, + "wind_gust_spd": 11.3, + "wind_spd": 8.2 + }, + { + "app_max_temp": -7.5, + "app_min_temp": -18.4, + "clouds": 38, + "clouds_hi": 23, + "clouds_low": 36, + "clouds_mid": 53, + "datetime": "2026-02-09", + "dewpt": -13.7, + "high_temp": -1.6, + "low_temp": -7.4, + "max_dhi": null, + "max_temp": -1.6, + "min_temp": -12.4, + "moon_phase": 0.4, + "moon_phase_lunation": 0.75, + "moonrise_ts": 1770616323, + "moonset_ts": 1770652706, + "ozone": 379, + "pop": 0, + "precip": 0, + "pres": 1021, + "rh": 59, + "slp": 1024, + "snow": 0, + "snow_depth": 174.14348, + "sunrise_ts": 1770638193, + "sunset_ts": 1770675930, + "temp": -7, + "ts": 1770613260, + "uv": 2, + "valid_date": "2026-02-09", + "vis": 23.5, + "weather": { "code": 802, "description": "Scattered clouds", "icon": "c02d" }, + "wind_cdir": "WNW", + "wind_cdir_full": "west-northwest", + "wind_dir": 301, + "wind_gust_spd": 5.9, + "wind_spd": 4.3 + }, + { + "app_max_temp": -3.1, + "app_min_temp": -11.5, + "clouds": 36, + "clouds_hi": 45, + "clouds_low": 39, + "clouds_mid": 25, + "datetime": "2026-02-10", + "dewpt": -8.5, + "high_temp": 1.5, + "low_temp": -3, + "max_dhi": null, + "max_temp": 1.5, + "min_temp": -7.4, + "moon_phase": 0.3, + "moon_phase_lunation": 0.78, + "moonrise_ts": 1770706492, + "moonset_ts": 1770741592, + "ozone": 348, + "pop": 0, + "precip": 0, + "pres": 1018, + "rh": 65, + "slp": 1021, + "snow": 0, + "snow_depth": 150.55084, + "sunrise_ts": 1770724522, + "sunset_ts": 1770762403, + "temp": -2.9, + "ts": 1770699660, + "uv": 3, + "valid_date": "2026-02-10", + "vis": 23, + "weather": { "code": 802, "description": "Scattered clouds", "icon": "c02d" }, + "wind_cdir": "WNW", + "wind_cdir_full": "west-northwest", + "wind_dir": 290, + "wind_gust_spd": 3.6, + "wind_spd": 3.5 + }, + { + "app_max_temp": -1.4, + "app_min_temp": -5.8, + "clouds": 73, + "clouds_hi": 97, + "clouds_low": 55, + "clouds_mid": 81, + "datetime": "2026-02-11", + "dewpt": -4.2, + "high_temp": 3.8, + "low_temp": -3.4, + "max_dhi": null, + "max_temp": 3.8, + "min_temp": -3, + "moon_phase": 0.22, + "moon_phase_lunation": 0.81, + "moonrise_ts": 1770796533, + "moonset_ts": 1770830974, + "ozone": 327, + "pop": 80, + "precip": 6.33, + "pres": 1012, + "rh": 72, + "slp": 1014, + "snow": 8.82, + "snow_depth": 103.88888, + "sunrise_ts": 1770810850, + "sunset_ts": 1770848875, + "temp": 0.3, + "ts": 1770786060, + "uv": 2, + "valid_date": "2026-02-11", + "vis": 16.7, + "weather": { "code": 500, "description": "Light rain", "icon": "r01d" }, + "wind_cdir": "WNW", + "wind_cdir_full": "west-northwest", + "wind_dir": 302, + "wind_gust_spd": 4.2, + "wind_spd": 4.2 + }, + { + "app_max_temp": -5.3, + "app_min_temp": -10.9, + "clouds": 40, + "clouds_hi": 0, + "clouds_low": 40, + "clouds_mid": 0, + "datetime": "2026-02-12", + "dewpt": -5.2, + "high_temp": 0, + "low_temp": -6.3, + "max_dhi": null, + "max_temp": 0, + "min_temp": -4.6, + "moon_phase": 0.14, + "moon_phase_lunation": 0.85, + "moonrise_ts": 1770886312, + "moonset_ts": 1770920833, + "ozone": 378, + "pop": 20, + "precip": 0.125, + "pres": 1010, + "rh": 79, + "slp": 1013, + "snow": 0.625, + "snow_depth": 49.526215, + "sunrise_ts": 1770897177, + "sunset_ts": 1770935347, + "temp": -2, + "ts": 1770872460, + "uv": 4, + "valid_date": "2026-02-12", + "vis": 24, + "weather": { "code": 610, "description": "Mix snow/rain", "icon": "s04d" }, + "wind_cdir": "WNW", + "wind_cdir_full": "west-northwest", + "wind_dir": 299, + "wind_gust_spd": 12, + "wind_spd": 5.3 + } + ], + "lat": 40.7128, + "lon": -74.006, + "state_code": "NY", + "timezone": "America/New_York" +} diff --git a/tests/mocks/weather_weatherbit_hourly.json b/tests/mocks/weather_weatherbit_hourly.json new file mode 100644 index 0000000..d277750 --- /dev/null +++ b/tests/mocks/weather_weatherbit_hourly.json @@ -0,0 +1 @@ +{ "error": "Your API key does not allow access to this endpoint." } diff --git a/tests/mocks/weather_weatherflow.json b/tests/mocks/weather_weatherflow.json new file mode 100644 index 0000000..38e4fbd --- /dev/null +++ b/tests/mocks/weather_weatherflow.json @@ -0,0 +1,4875 @@ +{ + "current_conditions": { + "air_density": 1.22, + "air_temperature": 16.0, + "brightness": 22546, + "conditions": "Clear", + "delta_t": 8.0, + "dew_point": -2.0, + "feels_like": 16.0, + "icon": "clear-day", + "is_precip_local_day_rain_check": true, + "is_precip_local_yesterday_rain_check": true, + "lightning_strike_count_last_1hr": 0, + "lightning_strike_count_last_3hr": 0, + "lightning_strike_last_distance": 25, + "lightning_strike_last_distance_msg": "23 - 27 km", + "lightning_strike_last_epoch": 1765159221, + "precip_accum_local_day": 0, + "precip_accum_local_yesterday": 1.75, + "precip_minutes_local_day": 0, + "precip_minutes_local_yesterday": 141, + "precip_probability": 0, + "pressure_trend": "falling", + "relative_humidity": 28, + "sea_level_pressure": 1013.6, + "solar_radiation": 188, + "station_pressure": 1013.0, + "time": 1770414299, + "uv": 1, + "wet_bulb_globe_temperature": 10.0, + "wet_bulb_temperature": 8.0, + "wind_avg": 15.0, + "wind_direction": 269, + "wind_direction_cardinal": "W", + "wind_gust": 20.0 + }, + "forecast": { + "daily": [ + { + "air_temp_high": 15.0, + "air_temp_low": 4.0, + "conditions": "Clear", + "day_num": 6, + "day_start_local": 1770354000, + "icon": "clear-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "sunrise": 1770379765, + "sunset": 1770419180 + }, + { + "air_temp_high": 14.0, + "air_temp_low": 10.0, + "conditions": "Clear", + "day_num": 7, + "day_start_local": 1770440400, + "icon": "clear-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "sunrise": 1770466123, + "sunset": 1770505628 + }, + { + "air_temp_high": 16.0, + "air_temp_low": 10.0, + "conditions": "Clear", + "day_num": 8, + "day_start_local": 1770526800, + "icon": "clear-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "sunrise": 1770552481, + "sunset": 1770592076 + }, + { + "air_temp_high": 18.0, + "air_temp_low": 9.0, + "conditions": "Clear", + "day_num": 9, + "day_start_local": 1770613200, + "icon": "clear-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "sunrise": 1770638837, + "sunset": 1770678523 + }, + { + "air_temp_high": 20.0, + "air_temp_low": 10.0, + "conditions": "Partly Cloudy", + "day_num": 10, + "day_start_local": 1770699600, + "icon": "partly-cloudy-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "sunrise": 1770725192, + "sunset": 1770764970 + }, + { + "air_temp_high": 21.0, + "air_temp_low": 13.0, + "conditions": "Partly Cloudy", + "day_num": 11, + "day_start_local": 1770786000, + "icon": "partly-cloudy-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "sunrise": 1770811546, + "sunset": 1770851417 + }, + { + "air_temp_high": 20.0, + "air_temp_low": 14.0, + "conditions": "Partly Cloudy", + "day_num": 12, + "day_start_local": 1770872400, + "icon": "partly-cloudy-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "sunrise": 1770897898, + "sunset": 1770937863 + }, + { + "air_temp_high": 20.0, + "air_temp_low": 14.0, + "conditions": "Partly Cloudy", + "day_num": 13, + "day_start_local": 1770958800, + "icon": "partly-cloudy-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "sunrise": 1770984250, + "sunset": 1771024309 + }, + { + "air_temp_high": 19.0, + "air_temp_low": 14.0, + "conditions": "Rain Possible", + "day_num": 14, + "day_start_local": 1771045200, + "icon": "possibly-rainy-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 20, + "precip_type": "rain", + "sunrise": 1771070600, + "sunset": 1771110755 + }, + { + "air_temp_high": 19.0, + "air_temp_low": 14.0, + "conditions": "Partly Cloudy", + "day_num": 15, + "day_start_local": 1771131600, + "icon": "partly-cloudy-day", + "month_num": 2, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "sunrise": 1771156949, + "sunset": 1771197200 + } + ], + "hourly": [ + { + "air_temperature": 15.0, + "conditions": "Clear", + "feels_like": 15.0, + "icon": "clear-day", + "local_day": 6, + "local_hour": 17, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 42, + "sea_level_pressure": 1013.6, + "station_pressure": 1013.2, + "time": 1770415200, + "uv": 2.0, + "wind_avg": 27.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 40.0 + }, + { + "air_temperature": 15.0, + "conditions": "Clear", + "feels_like": 15.0, + "icon": "clear-day", + "local_day": 6, + "local_hour": 18, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 45, + "sea_level_pressure": 1014.4, + "station_pressure": 1014.0, + "time": 1770418800, + "uv": 0.0, + "wind_avg": 32.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 44.0 + }, + { + "air_temperature": 14.0, + "conditions": "Clear", + "feels_like": 14.0, + "icon": "clear-night", + "local_day": 6, + "local_hour": 19, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 52, + "sea_level_pressure": 1015.0, + "station_pressure": 1014.6, + "time": 1770422400, + "uv": 0.0, + "wind_avg": 33.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 48.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-night", + "local_day": 6, + "local_hour": 20, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 61, + "sea_level_pressure": 1015.3, + "station_pressure": 1014.9, + "time": 1770426000, + "uv": 0.0, + "wind_avg": 35.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 48.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 6, + "local_hour": 21, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1015.2, + "station_pressure": 1014.8, + "time": 1770429600, + "uv": 0.0, + "wind_avg": 33.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 50.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 6, + "local_hour": 22, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 76, + "sea_level_pressure": 1015.2, + "station_pressure": 1014.8, + "time": 1770433200, + "uv": 0.0, + "wind_avg": 32.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 48.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 6, + "local_hour": 23, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1015.0, + "station_pressure": 1014.6, + "time": 1770436800, + "uv": 0.0, + "wind_avg": 30.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 47.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 0, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 88, + "sea_level_pressure": 1015.0, + "station_pressure": 1014.6, + "time": 1770440400, + "uv": 0.0, + "wind_avg": 29.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 45.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 88, + "sea_level_pressure": 1015.1, + "station_pressure": 1014.7, + "time": 1770444000, + "uv": 0.0, + "wind_avg": 27.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 44.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 2, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 86, + "sea_level_pressure": 1014.9, + "station_pressure": 1014.5, + "time": 1770447600, + "uv": 0.0, + "wind_avg": 27.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 44.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 3, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 86, + "sea_level_pressure": 1015.0, + "station_pressure": 1014.6, + "time": 1770451200, + "uv": 0.0, + "wind_avg": 26.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 44.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 4, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 85, + "sea_level_pressure": 1015.1, + "station_pressure": 1014.7, + "time": 1770454800, + "uv": 0.0, + "wind_avg": 26.0, + "wind_direction": 290, + "wind_direction_cardinal": "WNW", + "wind_gust": 42.0 + }, + { + "air_temperature": 11.0, + "conditions": "Clear", + "feels_like": 11.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 5, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1015.6, + "station_pressure": 1015.2, + "time": 1770458400, + "uv": 0.0, + "wind_avg": 24.0, + "wind_direction": 290, + "wind_direction_cardinal": "WNW", + "wind_gust": 41.0 + }, + { + "air_temperature": 11.0, + "conditions": "Clear", + "feels_like": 11.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 6, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1016.4, + "station_pressure": 1016.0, + "time": 1770462000, + "uv": 0.0, + "wind_avg": 24.0, + "wind_direction": 300, + "wind_direction_cardinal": "WNW", + "wind_gust": 39.0 + }, + { + "air_temperature": 10.0, + "conditions": "Clear", + "feels_like": 10.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1017.2, + "station_pressure": 1016.8, + "time": 1770465600, + "uv": 0.0, + "wind_avg": 24.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 39.0 + }, + { + "air_temperature": 11.0, + "conditions": "Clear", + "feels_like": 11.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 8, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 82, + "sea_level_pressure": 1018.5, + "station_pressure": 1018.1, + "time": 1770469200, + "uv": 1.0, + "wind_avg": 26.0, + "wind_direction": 320, + "wind_direction_cardinal": "NW", + "wind_gust": 39.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 9, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1019.8, + "station_pressure": 1019.4, + "time": 1770472800, + "uv": 2.0, + "wind_avg": 27.0, + "wind_direction": 330, + "wind_direction_cardinal": "NNW", + "wind_gust": 39.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 10, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 67, + "sea_level_pressure": 1020.4, + "station_pressure": 1020.0, + "time": 1770476400, + "uv": 4.0, + "wind_avg": 29.0, + "wind_direction": 340, + "wind_direction_cardinal": "NNW", + "wind_gust": 39.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 11, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 64, + "sea_level_pressure": 1020.8, + "station_pressure": 1020.4, + "time": 1770480000, + "uv": 5.0, + "wind_avg": 29.0, + "wind_direction": 340, + "wind_direction_cardinal": "NNW", + "wind_gust": 39.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 12, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 61, + "sea_level_pressure": 1020.7, + "station_pressure": 1020.3, + "time": 1770483600, + "uv": 6.0, + "wind_avg": 29.0, + "wind_direction": 350, + "wind_direction_cardinal": "N", + "wind_gust": 38.0 + }, + { + "air_temperature": 14.0, + "conditions": "Clear", + "feels_like": 14.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 13, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 59, + "sea_level_pressure": 1020.0, + "station_pressure": 1019.6, + "time": 1770487200, + "uv": 7.0, + "wind_avg": 27.0, + "wind_direction": 350, + "wind_direction_cardinal": "N", + "wind_gust": 39.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 14, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 63, + "sea_level_pressure": 1019.8, + "station_pressure": 1019.4, + "time": 1770490800, + "uv": 7.0, + "wind_avg": 27.0, + "wind_direction": 360, + "wind_direction_cardinal": "N", + "wind_gust": 38.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 15, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 61, + "sea_level_pressure": 1019.9, + "station_pressure": 1019.5, + "time": 1770494400, + "uv": 6.0, + "wind_avg": 27.0, + "wind_direction": 0, + "wind_direction_cardinal": "N", + "wind_gust": 38.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 16, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 61, + "sea_level_pressure": 1020.4, + "station_pressure": 1020.0, + "time": 1770498000, + "uv": 4.0, + "wind_avg": 27.0, + "wind_direction": 10, + "wind_direction_cardinal": "N", + "wind_gust": 36.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 17, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 63, + "sea_level_pressure": 1021.1, + "station_pressure": 1020.7, + "time": 1770501600, + "uv": 2.0, + "wind_avg": 27.0, + "wind_direction": 10, + "wind_direction_cardinal": "N", + "wind_gust": 36.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-day", + "local_day": 7, + "local_hour": 18, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 65, + "sea_level_pressure": 1021.6, + "station_pressure": 1021.2, + "time": 1770505200, + "uv": 1.0, + "wind_avg": 26.0, + "wind_direction": 10, + "wind_direction_cardinal": "N", + "wind_gust": 36.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 19, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 66, + "sea_level_pressure": 1022.3, + "station_pressure": 1021.9, + "time": 1770508800, + "uv": 0.0, + "wind_avg": 23.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 34.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 20, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1022.9, + "station_pressure": 1022.5, + "time": 1770512400, + "uv": 0.0, + "wind_avg": 23.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 32.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 21, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 65, + "sea_level_pressure": 1023.4, + "station_pressure": 1023.0, + "time": 1770516000, + "uv": 0.0, + "wind_avg": 22.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 32.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 22, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1023.7, + "station_pressure": 1023.3, + "time": 1770519600, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 31.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 7, + "local_hour": 23, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 67, + "sea_level_pressure": 1024.0, + "station_pressure": 1023.6, + "time": 1770523200, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 28.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 0, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 66, + "sea_level_pressure": 1024.1, + "station_pressure": 1023.7, + "time": 1770526800, + "uv": 0.0, + "wind_avg": 17.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 28.0 + }, + { + "air_temperature": 11.0, + "conditions": "Clear", + "feels_like": 11.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 67, + "sea_level_pressure": 1024.1, + "station_pressure": 1023.7, + "time": 1770530400, + "uv": 0.0, + "wind_avg": 16.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 26.0 + }, + { + "air_temperature": 11.0, + "conditions": "Clear", + "feels_like": 11.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 2, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 66, + "sea_level_pressure": 1024.2, + "station_pressure": 1023.8, + "time": 1770534000, + "uv": 0.0, + "wind_avg": 15.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 26.0 + }, + { + "air_temperature": 11.0, + "conditions": "Clear", + "feels_like": 11.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 3, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 67, + "sea_level_pressure": 1024.2, + "station_pressure": 1023.8, + "time": 1770537600, + "uv": 0.0, + "wind_avg": 15.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 25.0 + }, + { + "air_temperature": 10.0, + "conditions": "Clear", + "feels_like": 10.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 4, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1024.3, + "station_pressure": 1023.9, + "time": 1770541200, + "uv": 0.0, + "wind_avg": 14.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 25.0 + }, + { + "air_temperature": 10.0, + "conditions": "Clear", + "feels_like": 10.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 5, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 70, + "sea_level_pressure": 1024.7, + "station_pressure": 1024.3, + "time": 1770544800, + "uv": 0.0, + "wind_avg": 14.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 24.0 + }, + { + "air_temperature": 10.0, + "conditions": "Clear", + "feels_like": 8.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 6, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 72, + "sea_level_pressure": 1025.1, + "station_pressure": 1024.7, + "time": 1770548400, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 23.0 + }, + { + "air_temperature": 10.0, + "conditions": "Clear", + "feels_like": 8.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1025.4, + "station_pressure": 1025.0, + "time": 1770552000, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 20, + "wind_direction_cardinal": "NNE", + "wind_gust": 22.0 + }, + { + "air_temperature": 11.0, + "conditions": "Clear", + "feels_like": 11.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 8, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 74, + "sea_level_pressure": 1025.9, + "station_pressure": 1025.5, + "time": 1770555600, + "uv": 4.0, + "wind_avg": 13.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 22.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 9, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 67, + "sea_level_pressure": 1026.4, + "station_pressure": 1026.0, + "time": 1770559200, + "uv": 4.0, + "wind_avg": 14.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 23.0 + }, + { + "air_temperature": 14.0, + "conditions": "Clear", + "feels_like": 14.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 10, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 61, + "sea_level_pressure": 1026.9, + "station_pressure": 1026.5, + "time": 1770562800, + "uv": 4.0, + "wind_avg": 14.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 23.0 + }, + { + "air_temperature": 15.0, + "conditions": "Clear", + "feels_like": 15.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 11, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 62, + "sea_level_pressure": 1026.4, + "station_pressure": 1026.0, + "time": 1770566400, + "uv": 6.0, + "wind_avg": 15.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 22.0 + }, + { + "air_temperature": 15.0, + "conditions": "Clear", + "feels_like": 15.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 12, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 61, + "sea_level_pressure": 1026.0, + "station_pressure": 1025.6, + "time": 1770570000, + "uv": 6.0, + "wind_avg": 15.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 22.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 13, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 60, + "sea_level_pressure": 1025.5, + "station_pressure": 1025.1, + "time": 1770573600, + "uv": 6.0, + "wind_avg": 16.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 21.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 14, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 63, + "sea_level_pressure": 1025.3, + "station_pressure": 1024.9, + "time": 1770577200, + "uv": 5.0, + "wind_avg": 15.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 21.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 15, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 62, + "sea_level_pressure": 1025.1, + "station_pressure": 1024.7, + "time": 1770580800, + "uv": 5.0, + "wind_avg": 15.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 20.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-day", + "local_day": 8, + "local_hour": 16, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 63, + "sea_level_pressure": 1025.0, + "station_pressure": 1024.6, + "time": 1770584400, + "uv": 5.0, + "wind_avg": 14.0, + "wind_direction": 30, + "wind_direction_cardinal": "NNE", + "wind_gust": 19.0 + }, + { + "air_temperature": 15.0, + "conditions": "Clear", + "feels_like": 15.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 17, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1025.3, + "station_pressure": 1024.9, + "time": 1770588000, + "uv": 1.0, + "wind_avg": 12.0, + "wind_direction": 40, + "wind_direction_cardinal": "NE", + "wind_gust": 18.0 + }, + { + "air_temperature": 14.0, + "conditions": "Clear", + "feels_like": 14.0, + "icon": "clear-day", + "local_day": 8, + "local_hour": 18, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1025.7, + "station_pressure": 1025.3, + "time": 1770591600, + "uv": 1.0, + "wind_avg": 10.0, + "wind_direction": 40, + "wind_direction_cardinal": "NE", + "wind_gust": 16.0 + }, + { + "air_temperature": 14.0, + "conditions": "Clear", + "feels_like": 14.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 19, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 74, + "sea_level_pressure": 1026.1, + "station_pressure": 1025.7, + "time": 1770595200, + "uv": 1.0, + "wind_avg": 7.0, + "wind_direction": 40, + "wind_direction_cardinal": "NE", + "wind_gust": 14.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 20, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 78, + "sea_level_pressure": 1026.1, + "station_pressure": 1025.7, + "time": 1770598800, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 60, + "wind_direction_cardinal": "ENE", + "wind_gust": 13.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 21, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 80, + "sea_level_pressure": 1026.1, + "station_pressure": 1025.7, + "time": 1770602400, + "uv": 0.0, + "wind_avg": 6.0, + "wind_direction": 60, + "wind_direction_cardinal": "ENE", + "wind_gust": 12.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 22, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1026.2, + "station_pressure": 1025.8, + "time": 1770606000, + "uv": 0.0, + "wind_avg": 6.0, + "wind_direction": 60, + "wind_direction_cardinal": "ENE", + "wind_gust": 11.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 8, + "local_hour": 23, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 79, + "sea_level_pressure": 1026.0, + "station_pressure": 1025.6, + "time": 1770609600, + "uv": 0.0, + "wind_avg": 5.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 11.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 9, + "local_hour": 0, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 80, + "sea_level_pressure": 1025.9, + "station_pressure": 1025.5, + "time": 1770613200, + "uv": 0.0, + "wind_avg": 5.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 11.0 + }, + { + "air_temperature": 12.0, + "conditions": "Clear", + "feels_like": 12.0, + "icon": "clear-night", + "local_day": 9, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 82, + "sea_level_pressure": 1025.8, + "station_pressure": 1025.4, + "time": 1770616800, + "uv": 0.0, + "wind_avg": 4.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 11.0 + }, + { + "air_temperature": 11.0, + "conditions": "Clear", + "feels_like": 11.0, + "icon": "clear-night", + "local_day": 9, + "local_hour": 2, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 80, + "sea_level_pressure": 1025.3, + "station_pressure": 1024.9, + "time": 1770620400, + "uv": 0.0, + "wind_avg": 5.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 12.0 + }, + { + "air_temperature": 10.0, + "conditions": "Partly Cloudy", + "feels_like": 10.0, + "icon": "partly-cloudy-night", + "local_day": 9, + "local_hour": 3, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 84, + "sea_level_pressure": 1024.8, + "station_pressure": 1024.4, + "time": 1770624000, + "uv": 0.0, + "wind_avg": 5.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 12.0 + }, + { + "air_temperature": 10.0, + "conditions": "Partly Cloudy", + "feels_like": 9.0, + "icon": "partly-cloudy-night", + "local_day": 9, + "local_hour": 4, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 87, + "sea_level_pressure": 1024.3, + "station_pressure": 1023.9, + "time": 1770627600, + "uv": 0.0, + "wind_avg": 6.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 13.0 + }, + { + "air_temperature": 9.0, + "conditions": "Partly Cloudy", + "feels_like": 9.0, + "icon": "partly-cloudy-night", + "local_day": 9, + "local_hour": 5, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 90, + "sea_level_pressure": 1024.7, + "station_pressure": 1024.3, + "time": 1770631200, + "uv": 0.0, + "wind_avg": 6.0, + "wind_direction": 290, + "wind_direction_cardinal": "WNW", + "wind_gust": 14.0 + }, + { + "air_temperature": 9.0, + "conditions": "Partly Cloudy", + "feels_like": 8.0, + "icon": "partly-cloudy-night", + "local_day": 9, + "local_hour": 6, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 92, + "sea_level_pressure": 1025.0, + "station_pressure": 1024.6, + "time": 1770634800, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 290, + "wind_direction_cardinal": "WNW", + "wind_gust": 14.0 + }, + { + "air_temperature": 10.0, + "conditions": "Partly Cloudy", + "feels_like": 9.0, + "icon": "partly-cloudy-night", + "local_day": 9, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 89, + "sea_level_pressure": 1025.3, + "station_pressure": 1024.9, + "time": 1770638400, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 290, + "wind_direction_cardinal": "WNW", + "wind_gust": 15.0 + }, + { + "air_temperature": 11.0, + "conditions": "Partly Cloudy", + "feels_like": 11.0, + "icon": "partly-cloudy-day", + "local_day": 9, + "local_hour": 8, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 90, + "sea_level_pressure": 1025.8, + "station_pressure": 1025.4, + "time": 1770642000, + "uv": 2.0, + "wind_avg": 8.0, + "wind_direction": 300, + "wind_direction_cardinal": "WNW", + "wind_gust": 15.0 + }, + { + "air_temperature": 13.0, + "conditions": "Partly Cloudy", + "feels_like": 13.0, + "icon": "partly-cloudy-day", + "local_day": 9, + "local_hour": 9, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 79, + "sea_level_pressure": 1026.3, + "station_pressure": 1025.9, + "time": 1770645600, + "uv": 2.0, + "wind_avg": 8.0, + "wind_direction": 300, + "wind_direction_cardinal": "WNW", + "wind_gust": 16.0 + }, + { + "air_temperature": 15.0, + "conditions": "Clear", + "feels_like": 15.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 10, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1026.8, + "station_pressure": 1026.4, + "time": 1770649200, + "uv": 2.0, + "wind_avg": 9.0, + "wind_direction": 300, + "wind_direction_cardinal": "WNW", + "wind_gust": 16.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 11, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 61, + "sea_level_pressure": 1026.2, + "station_pressure": 1025.8, + "time": 1770652800, + "uv": 4.0, + "wind_avg": 10.0, + "wind_direction": 330, + "wind_direction_cardinal": "NNW", + "wind_gust": 16.0 + }, + { + "air_temperature": 17.0, + "conditions": "Clear", + "feels_like": 17.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 12, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 59, + "sea_level_pressure": 1025.5, + "station_pressure": 1025.1, + "time": 1770656400, + "uv": 4.0, + "wind_avg": 10.0, + "wind_direction": 330, + "wind_direction_cardinal": "NNW", + "wind_gust": 17.0 + }, + { + "air_temperature": 17.0, + "conditions": "Clear", + "feels_like": 17.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 13, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 57, + "sea_level_pressure": 1024.8, + "station_pressure": 1024.4, + "time": 1770660000, + "uv": 4.0, + "wind_avg": 12.0, + "wind_direction": 330, + "wind_direction_cardinal": "NNW", + "wind_gust": 17.0 + }, + { + "air_temperature": 18.0, + "conditions": "Clear", + "feels_like": 18.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 14, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 57, + "sea_level_pressure": 1024.1, + "station_pressure": 1023.7, + "time": 1770663600, + "uv": 5.0, + "wind_avg": 12.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 18.0 + }, + { + "air_temperature": 18.0, + "conditions": "Clear", + "feels_like": 18.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 15, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 56, + "sea_level_pressure": 1023.4, + "station_pressure": 1023.0, + "time": 1770667200, + "uv": 5.0, + "wind_avg": 12.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 18.0 + }, + { + "air_temperature": 18.0, + "conditions": "Clear", + "feels_like": 18.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 16, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 56, + "sea_level_pressure": 1022.7, + "station_pressure": 1022.3, + "time": 1770670800, + "uv": 5.0, + "wind_avg": 12.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 19.0 + }, + { + "air_temperature": 17.0, + "conditions": "Clear", + "feels_like": 17.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 17, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 66, + "sea_level_pressure": 1022.9, + "station_pressure": 1022.5, + "time": 1770674400, + "uv": 3.0, + "wind_avg": 11.0, + "wind_direction": 160, + "wind_direction_cardinal": "SSE", + "wind_gust": 18.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-day", + "local_day": 9, + "local_hour": 18, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 69, + "sea_level_pressure": 1023.1, + "station_pressure": 1022.7, + "time": 1770678000, + "uv": 3.0, + "wind_avg": 10.0, + "wind_direction": 160, + "wind_direction_cardinal": "SSE", + "wind_gust": 16.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-night", + "local_day": 9, + "local_hour": 19, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1023.3, + "station_pressure": 1022.9, + "time": 1770681600, + "uv": 3.0, + "wind_avg": 9.0, + "wind_direction": 160, + "wind_direction_cardinal": "SSE", + "wind_gust": 15.0 + }, + { + "air_temperature": 15.0, + "conditions": "Clear", + "feels_like": 15.0, + "icon": "clear-night", + "local_day": 9, + "local_hour": 20, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 74, + "sea_level_pressure": 1023.4, + "station_pressure": 1023.0, + "time": 1770685200, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 240, + "wind_direction_cardinal": "WSW", + "wind_gust": 16.0 + }, + { + "air_temperature": 15.0, + "conditions": "Clear", + "feels_like": 15.0, + "icon": "clear-night", + "local_day": 9, + "local_hour": 21, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 76, + "sea_level_pressure": 1023.5, + "station_pressure": 1023.1, + "time": 1770688800, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 240, + "wind_direction_cardinal": "WSW", + "wind_gust": 17.0 + }, + { + "air_temperature": 14.0, + "conditions": "Clear", + "feels_like": 14.0, + "icon": "clear-night", + "local_day": 9, + "local_hour": 22, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 79, + "sea_level_pressure": 1023.6, + "station_pressure": 1023.2, + "time": 1770692400, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 240, + "wind_direction_cardinal": "WSW", + "wind_gust": 17.0 + }, + { + "air_temperature": 14.0, + "conditions": "Clear", + "feels_like": 14.0, + "icon": "clear-night", + "local_day": 9, + "local_hour": 23, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1023.4, + "station_pressure": 1023.0, + "time": 1770696000, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 18.0 + }, + { + "air_temperature": 13.0, + "conditions": "Clear", + "feels_like": 13.0, + "icon": "clear-night", + "local_day": 10, + "local_hour": 0, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1023.2, + "station_pressure": 1022.8, + "time": 1770699600, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 18.0 + }, + { + "air_temperature": 13.0, + "conditions": "Partly Cloudy", + "feels_like": 13.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 76, + "sea_level_pressure": 1023.0, + "station_pressure": 1022.6, + "time": 1770703200, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 19.0 + }, + { + "air_temperature": 12.0, + "conditions": "Partly Cloudy", + "feels_like": 12.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 2, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 74, + "sea_level_pressure": 1022.9, + "station_pressure": 1022.5, + "time": 1770706800, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 18.0 + }, + { + "air_temperature": 12.0, + "conditions": "Partly Cloudy", + "feels_like": 12.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 3, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 76, + "sea_level_pressure": 1022.9, + "station_pressure": 1022.5, + "time": 1770710400, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 17.0 + }, + { + "air_temperature": 11.0, + "conditions": "Partly Cloudy", + "feels_like": 11.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 4, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 79, + "sea_level_pressure": 1022.8, + "station_pressure": 1022.4, + "time": 1770714000, + "uv": 0.0, + "wind_avg": 9.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 16.0 + }, + { + "air_temperature": 11.0, + "conditions": "Partly Cloudy", + "feels_like": 11.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 5, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 82, + "sea_level_pressure": 1023.2, + "station_pressure": 1022.8, + "time": 1770717600, + "uv": 0.0, + "wind_avg": 8.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 15.0 + }, + { + "air_temperature": 10.0, + "conditions": "Partly Cloudy", + "feels_like": 10.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 6, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 85, + "sea_level_pressure": 1023.5, + "station_pressure": 1023.1, + "time": 1770721200, + "uv": 0.0, + "wind_avg": 8.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 15.0 + }, + { + "air_temperature": 11.0, + "conditions": "Partly Cloudy", + "feels_like": 11.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1023.9, + "station_pressure": 1023.5, + "time": 1770724800, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 15.0 + }, + { + "air_temperature": 12.0, + "conditions": "Partly Cloudy", + "feels_like": 12.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 8, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 89, + "sea_level_pressure": 1024.2, + "station_pressure": 1023.8, + "time": 1770728400, + "uv": 2.0, + "wind_avg": 7.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 15.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 9, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 79, + "sea_level_pressure": 1024.5, + "station_pressure": 1024.1, + "time": 1770732000, + "uv": 2.0, + "wind_avg": 7.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 15.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 10, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1024.8, + "station_pressure": 1024.4, + "time": 1770735600, + "uv": 2.0, + "wind_avg": 7.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 15.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 11, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 67, + "sea_level_pressure": 1024.1, + "station_pressure": 1023.7, + "time": 1770739200, + "uv": 4.0, + "wind_avg": 8.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 14.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 12, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 63, + "sea_level_pressure": 1023.3, + "station_pressure": 1022.9, + "time": 1770742800, + "uv": 4.0, + "wind_avg": 8.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 14.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 13, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 60, + "sea_level_pressure": 1022.5, + "station_pressure": 1022.1, + "time": 1770746400, + "uv": 4.0, + "wind_avg": 9.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 14.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 14, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 61, + "sea_level_pressure": 1021.9, + "station_pressure": 1021.5, + "time": 1770750000, + "uv": 5.0, + "wind_avg": 9.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 14.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 15, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 59, + "sea_level_pressure": 1021.3, + "station_pressure": 1020.9, + "time": 1770753600, + "uv": 5.0, + "wind_avg": 10.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 15.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 16, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 59, + "sea_level_pressure": 1020.6, + "station_pressure": 1020.2, + "time": 1770757200, + "uv": 5.0, + "wind_avg": 10.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 15.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 17, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1021.1, + "station_pressure": 1020.7, + "time": 1770760800, + "uv": 3.0, + "wind_avg": 9.0, + "wind_direction": 140, + "wind_direction_cardinal": "SE", + "wind_gust": 15.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 10, + "local_hour": 18, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1021.5, + "station_pressure": 1021.1, + "time": 1770764400, + "uv": 3.0, + "wind_avg": 8.0, + "wind_direction": 140, + "wind_direction_cardinal": "SE", + "wind_gust": 14.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 19, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 77, + "sea_level_pressure": 1022.0, + "station_pressure": 1021.6, + "time": 1770768000, + "uv": 3.0, + "wind_avg": 7.0, + "wind_direction": 140, + "wind_direction_cardinal": "SE", + "wind_gust": 14.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 20, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 78, + "sea_level_pressure": 1022.2, + "station_pressure": 1021.8, + "time": 1770771600, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 200, + "wind_direction_cardinal": "SSW", + "wind_gust": 14.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 10, + "local_hour": 21, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 79, + "sea_level_pressure": 1022.4, + "station_pressure": 1022.0, + "time": 1770775200, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 200, + "wind_direction_cardinal": "SSW", + "wind_gust": 14.0 + }, + { + "air_temperature": 16.0, + "conditions": "Cloudy", + "feels_like": 16.0, + "icon": "cloudy", + "local_day": 10, + "local_hour": 22, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 80, + "sea_level_pressure": 1022.6, + "station_pressure": 1022.2, + "time": 1770778800, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 200, + "wind_direction_cardinal": "SSW", + "wind_gust": 15.0 + }, + { + "air_temperature": 16.0, + "conditions": "Cloudy", + "feels_like": 16.0, + "icon": "cloudy", + "local_day": 10, + "local_hour": 23, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 78, + "sea_level_pressure": 1022.4, + "station_pressure": 1022.0, + "time": 1770782400, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 15.0 + }, + { + "air_temperature": 15.0, + "conditions": "Cloudy", + "feels_like": 15.0, + "icon": "cloudy", + "local_day": 11, + "local_hour": 0, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 81, + "sea_level_pressure": 1022.3, + "station_pressure": 1021.9, + "time": 1770786000, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 15.0 + }, + { + "air_temperature": 15.0, + "conditions": "Cloudy", + "feels_like": 15.0, + "icon": "cloudy", + "local_day": 11, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1022.1, + "station_pressure": 1021.7, + "time": 1770789600, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 16.0 + }, + { + "air_temperature": 14.0, + "conditions": "Cloudy", + "feels_like": 14.0, + "icon": "cloudy", + "local_day": 11, + "local_hour": 2, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 85, + "sea_level_pressure": 1021.8, + "station_pressure": 1021.4, + "time": 1770793200, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 16.0 + }, + { + "air_temperature": 14.0, + "conditions": "Cloudy", + "feels_like": 14.0, + "icon": "cloudy", + "local_day": 11, + "local_hour": 3, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 87, + "sea_level_pressure": 1021.6, + "station_pressure": 1021.2, + "time": 1770796800, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 15.0 + }, + { + "air_temperature": 14.0, + "conditions": "Cloudy", + "feels_like": 14.0, + "icon": "cloudy", + "local_day": 11, + "local_hour": 4, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 89, + "sea_level_pressure": 1021.4, + "station_pressure": 1021.0, + "time": 1770800400, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 15.0 + }, + { + "air_temperature": 13.0, + "conditions": "Partly Cloudy", + "feels_like": 13.0, + "icon": "partly-cloudy-night", + "local_day": 11, + "local_hour": 5, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 91, + "sea_level_pressure": 1021.4, + "station_pressure": 1021.0, + "time": 1770804000, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 16.0 + }, + { + "air_temperature": 13.0, + "conditions": "Partly Cloudy", + "feels_like": 13.0, + "icon": "partly-cloudy-night", + "local_day": 11, + "local_hour": 6, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 92, + "sea_level_pressure": 1021.5, + "station_pressure": 1021.1, + "time": 1770807600, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 16.0 + }, + { + "air_temperature": 13.0, + "conditions": "Partly Cloudy", + "feels_like": 13.0, + "icon": "partly-cloudy-night", + "local_day": 11, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 91, + "sea_level_pressure": 1021.5, + "station_pressure": 1021.1, + "time": 1770811200, + "uv": 0.0, + "wind_avg": 7.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 17.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-day", + "local_day": 11, + "local_hour": 8, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 91, + "sea_level_pressure": 1021.7, + "station_pressure": 1021.3, + "time": 1770814800, + "uv": 2.0, + "wind_avg": 8.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 17.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-day", + "local_day": 11, + "local_hour": 9, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1022.0, + "station_pressure": 1021.6, + "time": 1770818400, + "uv": 2.0, + "wind_avg": 8.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 17.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-day", + "local_day": 11, + "local_hour": 10, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 76, + "sea_level_pressure": 1022.2, + "station_pressure": 1021.8, + "time": 1770822000, + "uv": 2.0, + "wind_avg": 9.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 17.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 11, + "local_hour": 11, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1021.3, + "station_pressure": 1020.9, + "time": 1770825600, + "uv": 4.0, + "wind_avg": 10.0, + "wind_direction": 240, + "wind_direction_cardinal": "WSW", + "wind_gust": 18.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 11, + "local_hour": 12, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1020.4, + "station_pressure": 1020.0, + "time": 1770829200, + "uv": 4.0, + "wind_avg": 10.0, + "wind_direction": 240, + "wind_direction_cardinal": "WSW", + "wind_gust": 18.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 11, + "local_hour": 13, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 65, + "sea_level_pressure": 1019.5, + "station_pressure": 1019.1, + "time": 1770832800, + "uv": 4.0, + "wind_avg": 12.0, + "wind_direction": 240, + "wind_direction_cardinal": "WSW", + "wind_gust": 19.0 + }, + { + "air_temperature": 21.0, + "conditions": "Partly Cloudy", + "feels_like": 21.0, + "icon": "partly-cloudy-day", + "local_day": 11, + "local_hour": 14, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 66, + "sea_level_pressure": 1019.1, + "station_pressure": 1018.7, + "time": 1770836400, + "uv": 5.0, + "wind_avg": 12.0, + "wind_direction": 170, + "wind_direction_cardinal": "S", + "wind_gust": 19.0 + }, + { + "air_temperature": 21.0, + "conditions": "Clear", + "feels_like": 21.0, + "icon": "clear-day", + "local_day": 11, + "local_hour": 15, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 65, + "sea_level_pressure": 1018.6, + "station_pressure": 1018.2, + "time": 1770840000, + "uv": 5.0, + "wind_avg": 13.0, + "wind_direction": 170, + "wind_direction_cardinal": "S", + "wind_gust": 19.0 + }, + { + "air_temperature": 21.0, + "conditions": "Clear", + "feels_like": 21.0, + "icon": "clear-day", + "local_day": 11, + "local_hour": 16, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 66, + "sea_level_pressure": 1018.1, + "station_pressure": 1017.7, + "time": 1770843600, + "uv": 5.0, + "wind_avg": 13.0, + "wind_direction": 170, + "wind_direction_cardinal": "S", + "wind_gust": 19.0 + }, + { + "air_temperature": 20.0, + "conditions": "Clear", + "feels_like": 20.0, + "icon": "clear-day", + "local_day": 11, + "local_hour": 17, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 69, + "sea_level_pressure": 1018.4, + "station_pressure": 1018.0, + "time": 1770847200, + "uv": 3.0, + "wind_avg": 13.0, + "wind_direction": 170, + "wind_direction_cardinal": "S", + "wind_gust": 20.0 + }, + { + "air_temperature": 20.0, + "conditions": "Clear", + "feels_like": 20.0, + "icon": "clear-day", + "local_day": 11, + "local_hour": 18, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 72, + "sea_level_pressure": 1018.7, + "station_pressure": 1018.3, + "time": 1770850800, + "uv": 3.0, + "wind_avg": 12.0, + "wind_direction": 170, + "wind_direction_cardinal": "S", + "wind_gust": 20.0 + }, + { + "air_temperature": 19.0, + "conditions": "Clear", + "feels_like": 19.0, + "icon": "clear-night", + "local_day": 11, + "local_hour": 19, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 76, + "sea_level_pressure": 1019.0, + "station_pressure": 1018.6, + "time": 1770854400, + "uv": 3.0, + "wind_avg": 12.0, + "wind_direction": 170, + "wind_direction_cardinal": "S", + "wind_gust": 20.0 + }, + { + "air_temperature": 18.0, + "conditions": "Clear", + "feels_like": 18.0, + "icon": "clear-night", + "local_day": 11, + "local_hour": 20, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 77, + "sea_level_pressure": 1019.1, + "station_pressure": 1018.7, + "time": 1770858000, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 20.0 + }, + { + "air_temperature": 18.0, + "conditions": "Clear", + "feels_like": 18.0, + "icon": "clear-night", + "local_day": 11, + "local_hour": 21, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 80, + "sea_level_pressure": 1019.3, + "station_pressure": 1018.9, + "time": 1770861600, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 21.0 + }, + { + "air_temperature": 17.0, + "conditions": "Clear", + "feels_like": 17.0, + "icon": "clear-night", + "local_day": 11, + "local_hour": 22, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1019.4, + "station_pressure": 1019.0, + "time": 1770865200, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 21.0 + }, + { + "air_temperature": 17.0, + "conditions": "Clear", + "feels_like": 17.0, + "icon": "clear-night", + "local_day": 11, + "local_hour": 23, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1019.0, + "station_pressure": 1018.6, + "time": 1770868800, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 21.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-night", + "local_day": 12, + "local_hour": 0, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 85, + "sea_level_pressure": 1018.5, + "station_pressure": 1018.1, + "time": 1770872400, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 22.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-night", + "local_day": 12, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 87, + "sea_level_pressure": 1018.1, + "station_pressure": 1017.7, + "time": 1770876000, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 22.0 + }, + { + "air_temperature": 16.0, + "conditions": "Clear", + "feels_like": 16.0, + "icon": "clear-night", + "local_day": 12, + "local_hour": 2, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 88, + "sea_level_pressure": 1017.9, + "station_pressure": 1017.5, + "time": 1770879600, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 22.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 3, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 90, + "sea_level_pressure": 1017.7, + "station_pressure": 1017.3, + "time": 1770883200, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 22.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 4, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 92, + "sea_level_pressure": 1017.5, + "station_pressure": 1017.1, + "time": 1770886800, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 23.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 5, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 97, + "sea_level_pressure": 1017.9, + "station_pressure": 1017.5, + "time": 1770890400, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 23.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 6, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 99, + "sea_level_pressure": 1018.2, + "station_pressure": 1017.8, + "time": 1770894000, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 22.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 98, + "sea_level_pressure": 1018.5, + "station_pressure": 1018.1, + "time": 1770897600, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 22.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 8, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 100, + "sea_level_pressure": 1018.6, + "station_pressure": 1018.2, + "time": 1770901200, + "uv": 2.0, + "wind_avg": 13.0, + "wind_direction": 290, + "wind_direction_cardinal": "WNW", + "wind_gust": 22.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 9, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 93, + "sea_level_pressure": 1018.7, + "station_pressure": 1018.3, + "time": 1770904800, + "uv": 2.0, + "wind_avg": 14.0, + "wind_direction": 290, + "wind_direction_cardinal": "WNW", + "wind_gust": 22.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 10, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 85, + "sea_level_pressure": 1018.8, + "station_pressure": 1018.4, + "time": 1770908400, + "uv": 2.0, + "wind_avg": 14.0, + "wind_direction": 290, + "wind_direction_cardinal": "WNW", + "wind_gust": 22.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 11, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 81, + "sea_level_pressure": 1018.1, + "station_pressure": 1017.7, + "time": 1770912000, + "uv": 4.0, + "wind_avg": 15.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 22.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 12, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 77, + "sea_level_pressure": 1017.5, + "station_pressure": 1017.1, + "time": 1770915600, + "uv": 4.0, + "wind_avg": 15.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 22.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 13, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 75, + "sea_level_pressure": 1016.8, + "station_pressure": 1016.4, + "time": 1770919200, + "uv": 4.0, + "wind_avg": 16.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 22.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 14, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1016.4, + "station_pressure": 1016.0, + "time": 1770922800, + "uv": 1.0, + "wind_avg": 16.0, + "wind_direction": 350, + "wind_direction_cardinal": "N", + "wind_gust": 22.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 15, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1016.1, + "station_pressure": 1015.7, + "time": 1770926400, + "uv": 1.0, + "wind_avg": 15.0, + "wind_direction": 350, + "wind_direction_cardinal": "N", + "wind_gust": 22.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 16, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 75, + "sea_level_pressure": 1015.7, + "station_pressure": 1015.3, + "time": 1770930000, + "uv": 1.0, + "wind_avg": 15.0, + "wind_direction": 350, + "wind_direction_cardinal": "N", + "wind_gust": 22.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 17, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 77, + "sea_level_pressure": 1016.2, + "station_pressure": 1015.8, + "time": 1770933600, + "uv": 1.0, + "wind_avg": 14.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 21.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 12, + "local_hour": 18, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 79, + "sea_level_pressure": 1016.7, + "station_pressure": 1016.3, + "time": 1770937200, + "uv": 1.0, + "wind_avg": 13.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 21.0 + }, + { + "air_temperature": 18.0, + "conditions": "Clear", + "feels_like": 18.0, + "icon": "clear-night", + "local_day": 12, + "local_hour": 19, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 82, + "sea_level_pressure": 1017.2, + "station_pressure": 1016.8, + "time": 1770940800, + "uv": 1.0, + "wind_avg": 12.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 20.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 20, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 81, + "sea_level_pressure": 1017.4, + "station_pressure": 1017.0, + "time": 1770944400, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 21.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 21, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 84, + "sea_level_pressure": 1017.6, + "station_pressure": 1017.2, + "time": 1770948000, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 21.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 22, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 86, + "sea_level_pressure": 1017.8, + "station_pressure": 1017.4, + "time": 1770951600, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 260, + "wind_direction_cardinal": "W", + "wind_gust": 21.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 12, + "local_hour": 23, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 86, + "sea_level_pressure": 1017.6, + "station_pressure": 1017.2, + "time": 1770955200, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 21.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 0, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 88, + "sea_level_pressure": 1017.5, + "station_pressure": 1017.1, + "time": 1770958800, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 22.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 90, + "sea_level_pressure": 1017.3, + "station_pressure": 1016.9, + "time": 1770962400, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 280, + "wind_direction_cardinal": "W", + "wind_gust": 22.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 2, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 89, + "sea_level_pressure": 1017.2, + "station_pressure": 1016.8, + "time": 1770966000, + "uv": 0.0, + "wind_avg": 12.0, + "wind_direction": 300, + "wind_direction_cardinal": "WNW", + "wind_gust": 23.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 3, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 90, + "sea_level_pressure": 1017.1, + "station_pressure": 1016.7, + "time": 1770969600, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 300, + "wind_direction_cardinal": "WNW", + "wind_gust": 23.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 4, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 91, + "sea_level_pressure": 1017.0, + "station_pressure": 1016.6, + "time": 1770973200, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 300, + "wind_direction_cardinal": "WNW", + "wind_gust": 23.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 5, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 91, + "sea_level_pressure": 1017.5, + "station_pressure": 1017.1, + "time": 1770976800, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 23.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 6, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 94, + "sea_level_pressure": 1017.9, + "station_pressure": 1017.5, + "time": 1770980400, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 24.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 93, + "sea_level_pressure": 1018.3, + "station_pressure": 1017.9, + "time": 1770984000, + "uv": 0.0, + "wind_avg": 13.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 24.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 8, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 94, + "sea_level_pressure": 1018.6, + "station_pressure": 1018.2, + "time": 1770987600, + "uv": 2.0, + "wind_avg": 14.0, + "wind_direction": 340, + "wind_direction_cardinal": "NNW", + "wind_gust": 24.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 9, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 86, + "sea_level_pressure": 1019.0, + "station_pressure": 1018.6, + "time": 1770991200, + "uv": 2.0, + "wind_avg": 15.0, + "wind_direction": 340, + "wind_direction_cardinal": "NNW", + "wind_gust": 24.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 10, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 79, + "sea_level_pressure": 1019.4, + "station_pressure": 1019.0, + "time": 1770994800, + "uv": 2.0, + "wind_avg": 16.0, + "wind_direction": 340, + "wind_direction_cardinal": "NNW", + "wind_gust": 24.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 11, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 75, + "sea_level_pressure": 1018.9, + "station_pressure": 1018.5, + "time": 1770998400, + "uv": 4.0, + "wind_avg": 17.0, + "wind_direction": 50, + "wind_direction_cardinal": "NE", + "wind_gust": 25.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 12, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 72, + "sea_level_pressure": 1018.5, + "station_pressure": 1018.1, + "time": 1771002000, + "uv": 4.0, + "wind_avg": 17.0, + "wind_direction": 50, + "wind_direction_cardinal": "NE", + "wind_gust": 26.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 13, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1018.1, + "station_pressure": 1017.7, + "time": 1771005600, + "uv": 4.0, + "wind_avg": 18.0, + "wind_direction": 50, + "wind_direction_cardinal": "NE", + "wind_gust": 26.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 14, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1017.6, + "station_pressure": 1017.2, + "time": 1771009200, + "uv": 5.0, + "wind_avg": 18.0, + "wind_direction": 90, + "wind_direction_cardinal": "E", + "wind_gust": 26.0 + }, + { + "air_temperature": 20.0, + "conditions": "Partly Cloudy", + "feels_like": 20.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 15, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1017.1, + "station_pressure": 1016.7, + "time": 1771012800, + "uv": 5.0, + "wind_avg": 18.0, + "wind_direction": 90, + "wind_direction_cardinal": "E", + "wind_gust": 27.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 16, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1016.6, + "station_pressure": 1016.2, + "time": 1771016400, + "uv": 5.0, + "wind_avg": 19.0, + "wind_direction": 90, + "wind_direction_cardinal": "E", + "wind_gust": 27.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 17, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 75, + "sea_level_pressure": 1016.9, + "station_pressure": 1016.5, + "time": 1771020000, + "uv": 3.0, + "wind_avg": 18.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 26.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 13, + "local_hour": 18, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 78, + "sea_level_pressure": 1017.2, + "station_pressure": 1016.8, + "time": 1771023600, + "uv": 3.0, + "wind_avg": 17.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 26.0 + }, + { + "air_temperature": 18.0, + "conditions": "Clear", + "feels_like": 18.0, + "icon": "clear-night", + "local_day": 13, + "local_hour": 19, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 80, + "sea_level_pressure": 1017.5, + "station_pressure": 1017.1, + "time": 1771027200, + "uv": 3.0, + "wind_avg": 16.0, + "wind_direction": 110, + "wind_direction_cardinal": "ESE", + "wind_gust": 25.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 20, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 81, + "sea_level_pressure": 1017.6, + "station_pressure": 1017.2, + "time": 1771030800, + "uv": 0.0, + "wind_avg": 16.0, + "wind_direction": 100, + "wind_direction_cardinal": "E", + "wind_gust": 26.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 21, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1017.6, + "station_pressure": 1017.2, + "time": 1771034400, + "uv": 0.0, + "wind_avg": 16.0, + "wind_direction": 100, + "wind_direction_cardinal": "E", + "wind_gust": 26.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 22, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 85, + "sea_level_pressure": 1017.7, + "station_pressure": 1017.3, + "time": 1771038000, + "uv": 0.0, + "wind_avg": 16.0, + "wind_direction": 100, + "wind_direction_cardinal": "E", + "wind_gust": 27.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 13, + "local_hour": 23, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 84, + "sea_level_pressure": 1017.2, + "station_pressure": 1016.8, + "time": 1771041600, + "uv": 0.0, + "wind_avg": 17.0, + "wind_direction": 340, + "wind_direction_cardinal": "NNW", + "wind_gust": 27.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 0, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 86, + "sea_level_pressure": 1016.8, + "station_pressure": 1016.4, + "time": 1771045200, + "uv": 0.0, + "wind_avg": 17.0, + "wind_direction": 340, + "wind_direction_cardinal": "NNW", + "wind_gust": 27.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 88, + "sea_level_pressure": 1016.4, + "station_pressure": 1016.0, + "time": 1771048800, + "uv": 0.0, + "wind_avg": 18.0, + "wind_direction": 340, + "wind_direction_cardinal": "NNW", + "wind_gust": 28.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 2, + "precip": 0.09, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 87, + "sea_level_pressure": 1015.9, + "station_pressure": 1015.5, + "time": 1771052400, + "uv": 0.0, + "wind_avg": 18.0, + "wind_direction": 320, + "wind_direction_cardinal": "NW", + "wind_gust": 28.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 3, + "precip": 0.17, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 90, + "sea_level_pressure": 1015.5, + "station_pressure": 1015.1, + "time": 1771056000, + "uv": 0.0, + "wind_avg": 18.0, + "wind_direction": 320, + "wind_direction_cardinal": "NW", + "wind_gust": 28.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 4, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 91, + "sea_level_pressure": 1015.0, + "station_pressure": 1014.6, + "time": 1771059600, + "uv": 0.0, + "wind_avg": 18.0, + "wind_direction": 320, + "wind_direction_cardinal": "NW", + "wind_gust": 29.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 5, + "precip": 0.17, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 92, + "sea_level_pressure": 1015.4, + "station_pressure": 1015.0, + "time": 1771063200, + "uv": 0.0, + "wind_avg": 18.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 29.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 6, + "precip": 0.09, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 95, + "sea_level_pressure": 1015.8, + "station_pressure": 1015.4, + "time": 1771066800, + "uv": 0.0, + "wind_avg": 18.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 30.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 94, + "sea_level_pressure": 1016.2, + "station_pressure": 1015.8, + "time": 1771070400, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 310, + "wind_direction_cardinal": "NW", + "wind_gust": 30.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 8, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 0, + "precip_type": "rain", + "relative_humidity": 90, + "sea_level_pressure": 1016.4, + "station_pressure": 1016.0, + "time": 1771074000, + "uv": 2.0, + "wind_avg": 19.0, + "wind_direction": 320, + "wind_direction_cardinal": "NW", + "wind_gust": 30.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 9, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 83, + "sea_level_pressure": 1016.6, + "station_pressure": 1016.2, + "time": 1771077600, + "uv": 2.0, + "wind_avg": 20.0, + "wind_direction": 320, + "wind_direction_cardinal": "NW", + "wind_gust": 31.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 10, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 77, + "sea_level_pressure": 1016.8, + "station_pressure": 1016.4, + "time": 1771081200, + "uv": 2.0, + "wind_avg": 21.0, + "wind_direction": 320, + "wind_direction_cardinal": "NW", + "wind_gust": 31.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 11, + "precip": 0.09, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1016.1, + "station_pressure": 1015.7, + "time": 1771084800, + "uv": 4.0, + "wind_avg": 21.0, + "wind_direction": 210, + "wind_direction_cardinal": "SSW", + "wind_gust": 32.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 12, + "precip": 0.17, + "precip_icon": "chance-rain", + "precip_probability": 5, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1015.4, + "station_pressure": 1015.0, + "time": 1771088400, + "uv": 4.0, + "wind_avg": 22.0, + "wind_direction": 210, + "wind_direction_cardinal": "SSW", + "wind_gust": 32.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 13, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 69, + "sea_level_pressure": 1014.7, + "station_pressure": 1014.3, + "time": 1771092000, + "uv": 4.0, + "wind_avg": 22.0, + "wind_direction": 210, + "wind_direction_cardinal": "SSW", + "wind_gust": 33.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 14, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 67, + "sea_level_pressure": 1014.4, + "station_pressure": 1014.0, + "time": 1771095600, + "uv": 3.0, + "wind_avg": 22.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 33.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 15, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 67, + "sea_level_pressure": 1014.2, + "station_pressure": 1013.8, + "time": 1771099200, + "uv": 3.0, + "wind_avg": 22.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 32.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 16, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1013.9, + "station_pressure": 1013.5, + "time": 1771102800, + "uv": 3.0, + "wind_avg": 21.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 32.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 17, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 69, + "sea_level_pressure": 1013.7, + "station_pressure": 1013.3, + "time": 1771106400, + "uv": 3.0, + "wind_avg": 21.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 32.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 14, + "local_hour": 18, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1013.4, + "station_pressure": 1013.0, + "time": 1771110000, + "uv": 3.0, + "wind_avg": 21.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 32.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 19, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 15, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1013.2, + "station_pressure": 1012.8, + "time": 1771113600, + "uv": 3.0, + "wind_avg": 21.0, + "wind_direction": 180, + "wind_direction_cardinal": "S", + "wind_gust": 32.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 20, + "precip": 0.21, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 72, + "sea_level_pressure": 1013.3, + "station_pressure": 1012.9, + "time": 1771117200, + "uv": 0.0, + "wind_avg": 21.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 32.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 21, + "precip": 0.17, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1013.4, + "station_pressure": 1013.0, + "time": 1771120800, + "uv": 0.0, + "wind_avg": 20.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 32.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 22, + "precip": 0.13, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 75, + "sea_level_pressure": 1013.6, + "station_pressure": 1013.2, + "time": 1771124400, + "uv": 0.0, + "wind_avg": 20.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 32.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 14, + "local_hour": 23, + "precip": 0.09, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 78, + "sea_level_pressure": 1013.7, + "station_pressure": 1013.3, + "time": 1771128000, + "uv": 0.0, + "wind_avg": 20.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 0, + "precip": 0.04, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 80, + "sea_level_pressure": 1013.8, + "station_pressure": 1013.4, + "time": 1771131600, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 1, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 82, + "sea_level_pressure": 1014.0, + "station_pressure": 1013.6, + "time": 1771135200, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 2, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 86, + "sea_level_pressure": 1014.1, + "station_pressure": 1013.7, + "time": 1771138800, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 3, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 88, + "sea_level_pressure": 1014.2, + "station_pressure": 1013.8, + "time": 1771142400, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 4, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 91, + "sea_level_pressure": 1014.3, + "station_pressure": 1013.9, + "time": 1771146000, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 5, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 92, + "sea_level_pressure": 1014.4, + "station_pressure": 1014.0, + "time": 1771149600, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 6, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 92, + "sea_level_pressure": 1014.5, + "station_pressure": 1014.1, + "time": 1771153200, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 14.0, + "conditions": "Partly Cloudy", + "feels_like": 14.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 7, + "precip": 0, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 90, + "sea_level_pressure": 1014.6, + "station_pressure": 1014.2, + "time": 1771156800, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 230, + "wind_direction_cardinal": "SW", + "wind_gust": 31.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 8, + "precip": 0.04, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 93, + "sea_level_pressure": 1014.4, + "station_pressure": 1014.0, + "time": 1771160400, + "uv": 4.0, + "wind_avg": 19.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 31.0 + }, + { + "air_temperature": 15.0, + "conditions": "Partly Cloudy", + "feels_like": 15.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 9, + "precip": 0.09, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 88, + "sea_level_pressure": 1014.2, + "station_pressure": 1013.8, + "time": 1771164000, + "uv": 4.0, + "wind_avg": 19.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 31.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 10, + "precip": 0.13, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 82, + "sea_level_pressure": 1014.0, + "station_pressure": 1013.6, + "time": 1771167600, + "uv": 4.0, + "wind_avg": 20.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 31.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 11, + "precip": 0.17, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 76, + "sea_level_pressure": 1013.8, + "station_pressure": 1013.4, + "time": 1771171200, + "uv": 4.0, + "wind_avg": 20.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 31.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 12, + "precip": 0.21, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1013.5, + "station_pressure": 1013.1, + "time": 1771174800, + "uv": 4.0, + "wind_avg": 21.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 31.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 13, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 70, + "sea_level_pressure": 1013.3, + "station_pressure": 1012.9, + "time": 1771178400, + "uv": 4.0, + "wind_avg": 21.0, + "wind_direction": 250, + "wind_direction_cardinal": "WSW", + "wind_gust": 30.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 14, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1013.4, + "station_pressure": 1013.0, + "time": 1771182000, + "uv": 3.0, + "wind_avg": 21.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 30.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 15, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 68, + "sea_level_pressure": 1013.5, + "station_pressure": 1013.1, + "time": 1771185600, + "uv": 3.0, + "wind_avg": 21.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 30.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 16, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 69, + "sea_level_pressure": 1013.6, + "station_pressure": 1013.2, + "time": 1771189200, + "uv": 3.0, + "wind_avg": 20.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 30.0 + }, + { + "air_temperature": 19.0, + "conditions": "Partly Cloudy", + "feels_like": 19.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 17, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 71, + "sea_level_pressure": 1013.7, + "station_pressure": 1013.3, + "time": 1771192800, + "uv": 3.0, + "wind_avg": 20.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 30.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-day", + "local_day": 15, + "local_hour": 18, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 73, + "sea_level_pressure": 1013.8, + "station_pressure": 1013.4, + "time": 1771196400, + "uv": 3.0, + "wind_avg": 20.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 30.0 + }, + { + "air_temperature": 18.0, + "conditions": "Partly Cloudy", + "feels_like": 18.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 19, + "precip": 0.25, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 75, + "sea_level_pressure": 1013.8, + "station_pressure": 1013.4, + "time": 1771200000, + "uv": 3.0, + "wind_avg": 19.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 30.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 20, + "precip": 0.21, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 74, + "sea_level_pressure": 1014.0, + "station_pressure": 1013.6, + "time": 1771203600, + "uv": 0.0, + "wind_avg": 19.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 29.0 + }, + { + "air_temperature": 17.0, + "conditions": "Partly Cloudy", + "feels_like": 17.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 21, + "precip": 0.17, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 76, + "sea_level_pressure": 1014.2, + "station_pressure": 1013.8, + "time": 1771207200, + "uv": 0.0, + "wind_avg": 18.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 29.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 22, + "precip": 0.13, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 78, + "sea_level_pressure": 1014.4, + "station_pressure": 1014.0, + "time": 1771210800, + "uv": 0.0, + "wind_avg": 18.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 29.0 + }, + { + "air_temperature": 16.0, + "conditions": "Partly Cloudy", + "feels_like": 16.0, + "icon": "partly-cloudy-night", + "local_day": 15, + "local_hour": 23, + "precip": 0.09, + "precip_icon": "chance-rain", + "precip_probability": 10, + "precip_type": "rain", + "relative_humidity": 81, + "sea_level_pressure": 1014.6, + "station_pressure": 1014.2, + "time": 1771214400, + "uv": 0.0, + "wind_avg": 17.0, + "wind_direction": 270, + "wind_direction_cardinal": "W", + "wind_gust": 29.0 + } + ] + }, + "latitude": 29.05592, + "location_name": "OG Pergola", + "longitude": -80.90748, + "source_id_conditions": 5, + "station": { "agl": 1.8288, "elevation": 3.0345869064331055, "is_station_online": true, "state": 1, "station_id": 151283 }, + "status": { "status_code": 0, "status_message": "SUCCESS" }, + "timezone": "America/New_York", + "timezone_offset_minutes": -300, + "units": { "units_air_density": "kg/m3", "units_brightness": "lux", "units_distance": "km", "units_other": "metric", "units_precip": "mm", "units_pressure": "mb", "units_solar_radiation": "w/m2", "units_temp": "c", "units_wind": "kph" } +} diff --git a/tests/mocks/weather_weathergov_current.json b/tests/mocks/weather_weathergov_current.json new file mode 100644 index 0000000..770bba1 --- /dev/null +++ b/tests/mocks/weather_weathergov_current.json @@ -0,0 +1,151 @@ +{ + "@context": [ + "https://geojson.org/geojson-ld/geojson-context.jsonld", + { + "@version": "1.1", + "wx": "https://api.weather.gov/ontology#", + "s": "https://schema.org/", + "geo": "http://www.opengis.net/ont/geosparql#", + "unit": "http://codes.wmo.int/common/unit/", + "@vocab": "https://api.weather.gov/ontology#", + "geometry": { + "@id": "s:GeoCoordinates", + "@type": "geo:wktLiteral" + }, + "city": "s:addressLocality", + "state": "s:addressRegion", + "distance": { + "@id": "s:Distance", + "@type": "s:QuantitativeValue" + }, + "bearing": { + "@type": "s:QuantitativeValue" + }, + "value": { + "@id": "s:value" + }, + "unitCode": { + "@id": "s:unitCode", + "@type": "@id" + }, + "forecastOffice": { + "@type": "@id" + }, + "forecastGridData": { + "@type": "@id" + }, + "publicZone": { + "@type": "@id" + }, + "county": { + "@type": "@id" + } + } + ], + "id": "https://api.weather.gov/stations/KDCA/observations/2026-02-06T21:30:00+00:00", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.03, 38.85] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KDCA/observations/2026-02-06T21:30:00+00:00", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 20 + }, + "station": "https://api.weather.gov/stations/KDCA", + "stationId": "KDCA", + "stationName": "Washington/Reagan National Airport, DC", + "timestamp": "2026-02-06T21:30:00+00:00", + "rawMessage": "", + "textDescription": "Light Snow", + "icon": "https://api.weather.gov/icons/land/day/snow?size=medium", + "presentWeather": [ + { + "intensity": "light", + "modifier": null, + "weather": "snow", + "rawString": "-SN" + } + ], + "temperature": { + "unitCode": "wmoUnit:degC", + "value": -1, + "qualityControl": "V" + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -7, + "qualityControl": "V" + }, + "windDirection": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 0, + "qualityControl": "V" + }, + "windSpeed": { + "unitCode": "wmoUnit:km_h-1", + "value": 0, + "qualityControl": "V" + }, + "windGust": { + "unitCode": "wmoUnit:km_h-1", + "value": null, + "qualityControl": "Z" + }, + "barometricPressure": { + "unitCode": "wmoUnit:Pa", + "value": 100372.55, + "qualityControl": "V" + }, + "seaLevelPressure": { + "unitCode": "wmoUnit:Pa", + "value": null, + "qualityControl": "Z" + }, + "visibility": { + "unitCode": "wmoUnit:m", + "value": 9656.06, + "qualityControl": "C" + }, + "maxTemperatureLast24Hours": { + "unitCode": "wmoUnit:degC", + "value": null + }, + "minTemperatureLast24Hours": { + "unitCode": "wmoUnit:degC", + "value": null + }, + "precipitationLast3Hours": { + "unitCode": "wmoUnit:mm", + "value": null, + "qualityControl": "Z" + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 63.771213893297, + "qualityControl": "V" + }, + "windChill": { + "unitCode": "wmoUnit:degC", + "value": null, + "qualityControl": "V" + }, + "heatIndex": { + "unitCode": "wmoUnit:degC", + "value": null, + "qualityControl": "V" + }, + "cloudLayers": [ + { + "base": { + "unitCode": "wmoUnit:m", + "value": 944.88 + }, + "amount": "OVC" + } + ] + } +} diff --git a/tests/mocks/weather_weathergov_forecast.json b/tests/mocks/weather_weathergov_forecast.json new file mode 100644 index 0000000..258d865 --- /dev/null +++ b/tests/mocks/weather_weathergov_forecast.json @@ -0,0 +1,304 @@ +{ + "@context": [ + "https://geojson.org/geojson-ld/geojson-context.jsonld", + { + "@version": "1.1", + "wx": "https://api.weather.gov/ontology#", + "geo": "http://www.opengis.net/ont/geosparql#", + "unit": "http://codes.wmo.int/common/unit/", + "@vocab": "https://api.weather.gov/ontology#" + } + ], + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-77.0445, 38.8569], + [-77.0408, 38.8788], + [-77.0689, 38.8818], + [-77.0727, 38.8598], + [-77.0445, 38.8569] + ] + ] + }, + "properties": { + "units": "si", + "forecastGenerator": "BaselineForecastGenerator", + "generatedAt": "2026-02-06T21:45:05+00:00", + "updateTime": "2026-02-06T20:53:00+00:00", + "validTimes": "2026-02-06T14:00:00+00:00/P7DT14H", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 7.9248 + }, + "periods": [ + { + "number": 1, + "name": "This Afternoon", + "startTime": "2026-02-06T16:00:00-05:00", + "endTime": "2026-02-06T18:00:00-05:00", + "isDaytime": true, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 71 + }, + "windSpeed": "4 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/day/snow,70?size=medium", + "shortForecast": "Light Snow Likely", + "detailedForecast": "Snow likely. Cloudy, with a high near 1. South wind around 4 km/h. Chance of precipitation is 70%. New snow accumulation of less than one cm possible." + }, + { + "number": 2, + "name": "Tonight", + "startTime": "2026-02-06T18:00:00-05:00", + "endTime": "2026-02-07T06:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 45 + }, + "windSpeed": "2 to 35 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/night/snow,50/wind_bkn?size=medium", + "shortForecast": "Chance Light Snow then Mostly Cloudy", + "detailedForecast": "A chance of snow before 10pm. Mostly cloudy. Low around -11, with temperatures rising to around -7 overnight. West wind 2 to 35 km/h, with gusts as high as 63 km/h. Chance of precipitation is 50%. New snow accumulation of less than two cm possible." + }, + { + "number": 3, + "name": "Saturday", + "startTime": "2026-02-07T06:00:00-05:00", + "endTime": "2026-02-07T18:00:00-05:00", + "isDaytime": true, + "temperature": -7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "windSpeed": "37 to 48 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_sct?size=medium", + "shortForecast": "Mostly Sunny", + "detailedForecast": "Mostly sunny, with a high near -7. Wind chill values as low as -21. Northwest wind 37 to 48 km/h, with gusts as high as 94 km/h." + }, + { + "number": 4, + "name": "Saturday Night", + "startTime": "2026-02-07T18:00:00-05:00", + "endTime": "2026-02-08T06:00:00-05:00", + "isDaytime": false, + "temperature": -12, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "windSpeed": "22 to 43 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/wind_few?size=medium", + "shortForecast": "Mostly Clear", + "detailedForecast": "Mostly clear, with a low around -12. Wind chill values as low as -21. Northwest wind 22 to 43 km/h, with gusts as high as 76 km/h." + }, + { + "number": 5, + "name": "Sunday", + "startTime": "2026-02-08T06:00:00-05:00", + "endTime": "2026-02-08T18:00:00-05:00", + "isDaytime": true, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "windSpeed": "13 to 22 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/sct?size=medium", + "shortForecast": "Mostly Sunny", + "detailedForecast": "Mostly sunny, with a high near -4. Northwest wind 13 to 22 km/h, with gusts as high as 43 km/h." + }, + { + "number": 6, + "name": "Sunday Night", + "startTime": "2026-02-08T18:00:00-05:00", + "endTime": "2026-02-09T06:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 2 + }, + "windSpeed": "4 to 9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/sct?size=medium", + "shortForecast": "Partly Cloudy", + "detailedForecast": "Partly cloudy, with a low around -11." + }, + { + "number": 7, + "name": "Monday", + "startTime": "2026-02-09T06:00:00-05:00", + "endTime": "2026-02-09T18:00:00-05:00", + "isDaytime": true, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "windSpeed": "7 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=medium", + "shortForecast": "Partly Sunny", + "detailedForecast": "Partly sunny, with a high near 0." + }, + { + "number": 8, + "name": "Monday Night", + "startTime": "2026-02-09T18:00:00-05:00", + "endTime": "2026-02-10T06:00:00-05:00", + "isDaytime": false, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "windSpeed": "2 km/h", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=medium", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "Mostly cloudy, with a low around -6." + }, + { + "number": 9, + "name": "Tuesday", + "startTime": "2026-02-10T06:00:00-05:00", + "endTime": "2026-02-10T18:00:00-05:00", + "isDaytime": true, + "temperature": 7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 1 + }, + "windSpeed": "6 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/sct?size=medium", + "shortForecast": "Mostly Sunny", + "detailedForecast": "Mostly sunny, with a high near 7." + }, + { + "number": 10, + "name": "Tuesday Night", + "startTime": "2026-02-10T18:00:00-05:00", + "endTime": "2026-02-11T06:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 18 + }, + "windSpeed": "4 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/bkn/rain,20?size=medium", + "shortForecast": "Mostly Cloudy then Slight Chance Light Rain", + "detailedForecast": "A slight chance of rain after 1am. Mostly cloudy, with a low around -1." + }, + { + "number": 11, + "name": "Wednesday", + "startTime": "2026-02-11T06:00:00-05:00", + "endTime": "2026-02-11T18:00:00-05:00", + "isDaytime": true, + "temperature": 8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "windSpeed": "4 to 11 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=medium", + "shortForecast": "Chance Light Rain", + "detailedForecast": "A chance of rain. Mostly cloudy, with a high near 8. Chance of precipitation is 50%." + }, + { + "number": 12, + "name": "Wednesday Night", + "startTime": "2026-02-11T18:00:00-05:00", + "endTime": "2026-02-12T06:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,50/rain,30?size=medium", + "shortForecast": "Chance Light Rain", + "detailedForecast": "A chance of rain. Mostly cloudy, with a low around -1. Chance of precipitation is 50%." + }, + { + "number": 13, + "name": "Thursday", + "startTime": "2026-02-12T06:00:00-05:00", + "endTime": "2026-02-12T18:00:00-05:00", + "isDaytime": true, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 27 + }, + "windSpeed": "11 to 17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,30/rain,20?size=medium", + "shortForecast": "Chance Light Rain", + "detailedForecast": "A chance of rain before 1pm. Mostly cloudy, with a high near 4. Chance of precipitation is 30%." + }, + { + "number": 14, + "name": "Thursday Night", + "startTime": "2026-02-12T18:00:00-05:00", + "endTime": "2026-02-13T06:00:00-05:00", + "isDaytime": false, + "temperature": -3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "windSpeed": "13 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=medium", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "A slight chance of snow after 7pm. Mostly cloudy, with a low around -3." + } + ] + } +} diff --git a/tests/mocks/weather_weathergov_hourly.json b/tests/mocks/weather_weathergov_hourly.json new file mode 100644 index 0000000..e4fc3bb --- /dev/null +++ b/tests/mocks/weather_weathergov_hourly.json @@ -0,0 +1,4250 @@ +{ + "@context": [ + "https://geojson.org/geojson-ld/geojson-context.jsonld", + { + "@version": "1.1", + "wx": "https://api.weather.gov/ontology#", + "geo": "http://www.opengis.net/ont/geosparql#", + "unit": "http://codes.wmo.int/common/unit/", + "@vocab": "https://api.weather.gov/ontology#" + } + ], + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-77.0445, 38.8569], + [-77.0408, 38.8788], + [-77.0689, 38.8818], + [-77.0727, 38.8598], + [-77.0445, 38.8569] + ] + ] + }, + "properties": { + "units": "si", + "forecastGenerator": "HourlyForecastGenerator", + "generatedAt": "2026-02-06T21:45:06+00:00", + "updateTime": "2026-02-06T20:53:00+00:00", + "validTimes": "2026-02-06T14:00:00+00:00/P7DT14H", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 7.9248 + }, + "periods": [ + { + "number": 1, + "name": "", + "startTime": "2026-02-06T16:00:00-05:00", + "endTime": "2026-02-06T17:00:00-05:00", + "isDaytime": true, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 71 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.888888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 72 + }, + "windSpeed": "4 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/day/snow,70?size=small", + "shortForecast": "Light Snow Likely", + "detailedForecast": "" + }, + { + "number": 2, + "name": "", + "startTime": "2026-02-06T17:00:00-05:00", + "endTime": "2026-02-06T18:00:00-05:00", + "isDaytime": true, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 57 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.3333333333333335 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "4 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/day/snow,60?size=small", + "shortForecast": "Light Snow Likely", + "detailedForecast": "" + }, + { + "number": 3, + "name": "", + "startTime": "2026-02-06T18:00:00-05:00", + "endTime": "2026-02-06T19:00:00-05:00", + "isDaytime": false, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 45 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.888888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "2 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/night/snow,50?size=small", + "shortForecast": "Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 4, + "name": "", + "startTime": "2026-02-06T19:00:00-05:00", + "endTime": "2026-02-06T20:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 37 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -4.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "4 km/h", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/night/snow,40?size=small", + "shortForecast": "Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 5, + "name": "", + "startTime": "2026-02-06T20:00:00-05:00", + "endTime": "2026-02-06T21:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 30 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.888888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 81 + }, + "windSpeed": "4 km/h", + "windDirection": "SW", + "icon": "https://api.weather.gov/icons/land/night/snow,30?size=small", + "shortForecast": "Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 6, + "name": "", + "startTime": "2026-02-06T21:00:00-05:00", + "endTime": "2026-02-06T22:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 17 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.888888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 81 + }, + "windSpeed": "4 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 7, + "name": "", + "startTime": "2026-02-06T22:00:00-05:00", + "endTime": "2026-02-06T23:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 13 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.888888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 81 + }, + "windSpeed": "6 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 8, + "name": "", + "startTime": "2026-02-06T23:00:00-05:00", + "endTime": "2026-02-07T00:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 5 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -4.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "7 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 9, + "name": "", + "startTime": "2026-02-07T00:00:00-05:00", + "endTime": "2026-02-07T01:00:00-05:00", + "isDaytime": false, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 7 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -5 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "9 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 10, + "name": "", + "startTime": "2026-02-07T01:00:00-05:00", + "endTime": "2026-02-07T02:00:00-05:00", + "isDaytime": false, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 6 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -5.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 11, + "name": "", + "startTime": "2026-02-07T02:00:00-05:00", + "endTime": "2026-02-07T03:00:00-05:00", + "isDaytime": false, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 4 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -6.666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 69 + }, + "windSpeed": "19 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 12, + "name": "", + "startTime": "2026-02-07T03:00:00-05:00", + "endTime": "2026-02-07T04:00:00-05:00", + "isDaytime": false, + "temperature": -3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 4 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -7.777777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 68 + }, + "windSpeed": "24 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 13, + "name": "", + "startTime": "2026-02-07T04:00:00-05:00", + "endTime": "2026-02-07T05:00:00-05:00", + "isDaytime": false, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 68 + }, + "windSpeed": "31 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 14, + "name": "", + "startTime": "2026-02-07T05:00:00-05:00", + "endTime": "2026-02-07T06:00:00-05:00", + "isDaytime": false, + "temperature": -7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -11.11111111111111 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 71 + }, + "windSpeed": "35 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/wind_sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 15, + "name": "", + "startTime": "2026-02-07T06:00:00-05:00", + "endTime": "2026-02-07T07:00:00-05:00", + "isDaytime": true, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.333333333333334 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 64 + }, + "windSpeed": "37 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 16, + "name": "", + "startTime": "2026-02-07T07:00:00-05:00", + "endTime": "2026-02-07T08:00:00-05:00", + "isDaytime": true, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 61 + }, + "windSpeed": "39 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 17, + "name": "", + "startTime": "2026-02-07T08:00:00-05:00", + "endTime": "2026-02-07T09:00:00-05:00", + "isDaytime": true, + "temperature": -10, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -16.11111111111111 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 61 + }, + "windSpeed": "43 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 18, + "name": "", + "startTime": "2026-02-07T09:00:00-05:00", + "endTime": "2026-02-07T10:00:00-05:00", + "isDaytime": true, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.22222222222222 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 53 + }, + "windSpeed": "44 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 19, + "name": "", + "startTime": "2026-02-07T10:00:00-05:00", + "endTime": "2026-02-07T11:00:00-05:00", + "isDaytime": true, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 48 + }, + "windSpeed": "48 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 20, + "name": "", + "startTime": "2026-02-07T11:00:00-05:00", + "endTime": "2026-02-07T12:00:00-05:00", + "isDaytime": true, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 48 + }, + "windSpeed": "48 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 21, + "name": "", + "startTime": "2026-02-07T12:00:00-05:00", + "endTime": "2026-02-07T13:00:00-05:00", + "isDaytime": true, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 44 + }, + "windSpeed": "48 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 22, + "name": "", + "startTime": "2026-02-07T13:00:00-05:00", + "endTime": "2026-02-07T14:00:00-05:00", + "isDaytime": true, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 46 + }, + "windSpeed": "48 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 23, + "name": "", + "startTime": "2026-02-07T14:00:00-05:00", + "endTime": "2026-02-07T15:00:00-05:00", + "isDaytime": true, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 42 + }, + "windSpeed": "46 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 24, + "name": "", + "startTime": "2026-02-07T15:00:00-05:00", + "endTime": "2026-02-07T16:00:00-05:00", + "isDaytime": true, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 42 + }, + "windSpeed": "43 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 25, + "name": "", + "startTime": "2026-02-07T16:00:00-05:00", + "endTime": "2026-02-07T17:00:00-05:00", + "isDaytime": true, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.77777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 44 + }, + "windSpeed": "41 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 26, + "name": "", + "startTime": "2026-02-07T17:00:00-05:00", + "endTime": "2026-02-07T18:00:00-05:00", + "isDaytime": true, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 42 + }, + "windSpeed": "41 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/wind_few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 27, + "name": "", + "startTime": "2026-02-07T18:00:00-05:00", + "endTime": "2026-02-07T19:00:00-05:00", + "isDaytime": false, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 46 + }, + "windSpeed": "43 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/wind_few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 28, + "name": "", + "startTime": "2026-02-07T19:00:00-05:00", + "endTime": "2026-02-07T20:00:00-05:00", + "isDaytime": false, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 44 + }, + "windSpeed": "43 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/wind_few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 29, + "name": "", + "startTime": "2026-02-07T20:00:00-05:00", + "endTime": "2026-02-07T21:00:00-05:00", + "isDaytime": false, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.22222222222222 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 51 + }, + "windSpeed": "39 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/wind_few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 30, + "name": "", + "startTime": "2026-02-07T21:00:00-05:00", + "endTime": "2026-02-07T22:00:00-05:00", + "isDaytime": false, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.77777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "windSpeed": "37 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/wind_few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 31, + "name": "", + "startTime": "2026-02-07T22:00:00-05:00", + "endTime": "2026-02-07T23:00:00-05:00", + "isDaytime": false, + "temperature": -10, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.77777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 53 + }, + "windSpeed": "33 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/wind_few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 32, + "name": "", + "startTime": "2026-02-07T23:00:00-05:00", + "endTime": "2026-02-08T00:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.77777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 55 + }, + "windSpeed": "31 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 33, + "name": "", + "startTime": "2026-02-08T00:00:00-05:00", + "endTime": "2026-02-08T01:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 53 + }, + "windSpeed": "28 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 34, + "name": "", + "startTime": "2026-02-08T01:00:00-05:00", + "endTime": "2026-02-08T02:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 55 + }, + "windSpeed": "28 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 35, + "name": "", + "startTime": "2026-02-08T02:00:00-05:00", + "endTime": "2026-02-08T03:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 55 + }, + "windSpeed": "26 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 36, + "name": "", + "startTime": "2026-02-08T03:00:00-05:00", + "endTime": "2026-02-08T04:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.77777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 58 + }, + "windSpeed": "24 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 37, + "name": "", + "startTime": "2026-02-08T04:00:00-05:00", + "endTime": "2026-02-08T05:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.77777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 58 + }, + "windSpeed": "24 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 38, + "name": "", + "startTime": "2026-02-08T05:00:00-05:00", + "endTime": "2026-02-08T06:00:00-05:00", + "isDaytime": false, + "temperature": -12, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.77777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 60 + }, + "windSpeed": "22 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 39, + "name": "", + "startTime": "2026-02-08T06:00:00-05:00", + "endTime": "2026-02-08T07:00:00-05:00", + "isDaytime": true, + "temperature": -12, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 57 + }, + "windSpeed": "22 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 40, + "name": "", + "startTime": "2026-02-08T07:00:00-05:00", + "endTime": "2026-02-08T08:00:00-05:00", + "isDaytime": true, + "temperature": -12, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -18.333333333333332 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 57 + }, + "windSpeed": "20 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 41, + "name": "", + "startTime": "2026-02-08T08:00:00-05:00", + "endTime": "2026-02-08T09:00:00-05:00", + "isDaytime": true, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.77777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 55 + }, + "windSpeed": "20 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 42, + "name": "", + "startTime": "2026-02-08T09:00:00-05:00", + "endTime": "2026-02-08T10:00:00-05:00", + "isDaytime": true, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -17.22222222222222 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 53 + }, + "windSpeed": "19 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 43, + "name": "", + "startTime": "2026-02-08T10:00:00-05:00", + "endTime": "2026-02-08T11:00:00-05:00", + "isDaytime": true, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -16.666666666666668 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 51 + }, + "windSpeed": "19 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 44, + "name": "", + "startTime": "2026-02-08T11:00:00-05:00", + "endTime": "2026-02-08T12:00:00-05:00", + "isDaytime": true, + "temperature": -7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -16.11111111111111 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "windSpeed": "19 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 45, + "name": "", + "startTime": "2026-02-08T12:00:00-05:00", + "endTime": "2026-02-08T13:00:00-05:00", + "isDaytime": true, + "temperature": -7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 3 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "windSpeed": "19 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 46, + "name": "", + "startTime": "2026-02-08T13:00:00-05:00", + "endTime": "2026-02-08T14:00:00-05:00", + "isDaytime": true, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 2 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 47 + }, + "windSpeed": "19 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 47, + "name": "", + "startTime": "2026-02-08T14:00:00-05:00", + "endTime": "2026-02-08T15:00:00-05:00", + "isDaytime": true, + "temperature": -5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 2 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -14.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 47 + }, + "windSpeed": "19 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 48, + "name": "", + "startTime": "2026-02-08T15:00:00-05:00", + "endTime": "2026-02-08T16:00:00-05:00", + "isDaytime": true, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 2 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -14.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 45 + }, + "windSpeed": "17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 49, + "name": "", + "startTime": "2026-02-08T16:00:00-05:00", + "endTime": "2026-02-08T17:00:00-05:00", + "isDaytime": true, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 2 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 47 + }, + "windSpeed": "15 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 50, + "name": "", + "startTime": "2026-02-08T17:00:00-05:00", + "endTime": "2026-02-08T18:00:00-05:00", + "isDaytime": true, + "temperature": -5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 2 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.333333333333334 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 52 + }, + "windSpeed": "13 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 51, + "name": "", + "startTime": "2026-02-08T18:00:00-05:00", + "endTime": "2026-02-08T19:00:00-05:00", + "isDaytime": false, + "temperature": -5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 2 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.333333333333334 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 52 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 52, + "name": "", + "startTime": "2026-02-08T19:00:00-05:00", + "endTime": "2026-02-08T20:00:00-05:00", + "isDaytime": false, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.333333333333334 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 54 + }, + "windSpeed": "7 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 53, + "name": "", + "startTime": "2026-02-08T20:00:00-05:00", + "endTime": "2026-02-08T21:00:00-05:00", + "isDaytime": false, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.333333333333334 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 56 + }, + "windSpeed": "7 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 54, + "name": "", + "startTime": "2026-02-08T21:00:00-05:00", + "endTime": "2026-02-08T22:00:00-05:00", + "isDaytime": false, + "temperature": -7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 59 + }, + "windSpeed": "7 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 55, + "name": "", + "startTime": "2026-02-08T22:00:00-05:00", + "endTime": "2026-02-08T23:00:00-05:00", + "isDaytime": false, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 61 + }, + "windSpeed": "7 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 56, + "name": "", + "startTime": "2026-02-08T23:00:00-05:00", + "endTime": "2026-02-09T00:00:00-05:00", + "isDaytime": false, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 61 + }, + "windSpeed": "7 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 57, + "name": "", + "startTime": "2026-02-09T00:00:00-05:00", + "endTime": "2026-02-09T01:00:00-05:00", + "isDaytime": false, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -14.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 61 + }, + "windSpeed": "6 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 58, + "name": "", + "startTime": "2026-02-09T01:00:00-05:00", + "endTime": "2026-02-09T02:00:00-05:00", + "isDaytime": false, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -14.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 61 + }, + "windSpeed": "6 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/few?size=small", + "shortForecast": "Mostly Clear", + "detailedForecast": "" + }, + { + "number": 59, + "name": "", + "startTime": "2026-02-09T02:00:00-05:00", + "endTime": "2026-02-09T03:00:00-05:00", + "isDaytime": false, + "temperature": -9, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -14.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 64 + }, + "windSpeed": "6 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 60, + "name": "", + "startTime": "2026-02-09T03:00:00-05:00", + "endTime": "2026-02-09T04:00:00-05:00", + "isDaytime": false, + "temperature": -10, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 67 + }, + "windSpeed": "6 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 61, + "name": "", + "startTime": "2026-02-09T04:00:00-05:00", + "endTime": "2026-02-09T05:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 70 + }, + "windSpeed": "4 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 62, + "name": "", + "startTime": "2026-02-09T05:00:00-05:00", + "endTime": "2026-02-09T06:00:00-05:00", + "isDaytime": false, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 70 + }, + "windSpeed": "4 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 63, + "name": "", + "startTime": "2026-02-09T06:00:00-05:00", + "endTime": "2026-02-09T07:00:00-05:00", + "isDaytime": true, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 70 + }, + "windSpeed": "4 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 64, + "name": "", + "startTime": "2026-02-09T07:00:00-05:00", + "endTime": "2026-02-09T08:00:00-05:00", + "isDaytime": true, + "temperature": -11, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 70 + }, + "windSpeed": "4 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 65, + "name": "", + "startTime": "2026-02-09T08:00:00-05:00", + "endTime": "2026-02-09T09:00:00-05:00", + "isDaytime": true, + "temperature": -10, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -15 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 67 + }, + "windSpeed": "4 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 66, + "name": "", + "startTime": "2026-02-09T09:00:00-05:00", + "endTime": "2026-02-09T10:00:00-05:00", + "isDaytime": true, + "temperature": -8, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -14.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 59 + }, + "windSpeed": "4 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 67, + "name": "", + "startTime": "2026-02-09T10:00:00-05:00", + "endTime": "2026-02-09T11:00:00-05:00", + "isDaytime": true, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -13.333333333333334 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 56 + }, + "windSpeed": "4 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 68, + "name": "", + "startTime": "2026-02-09T11:00:00-05:00", + "endTime": "2026-02-09T12:00:00-05:00", + "isDaytime": true, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -12.777777777777779 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 52 + }, + "windSpeed": "6 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 69, + "name": "", + "startTime": "2026-02-09T12:00:00-05:00", + "endTime": "2026-02-09T13:00:00-05:00", + "isDaytime": true, + "temperature": -3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -11.666666666666666 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "windSpeed": "7 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 70, + "name": "", + "startTime": "2026-02-09T13:00:00-05:00", + "endTime": "2026-02-09T14:00:00-05:00", + "isDaytime": true, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -11.11111111111111 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 48 + }, + "windSpeed": "7 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 71, + "name": "", + "startTime": "2026-02-09T14:00:00-05:00", + "endTime": "2026-02-09T15:00:00-05:00", + "isDaytime": true, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -10.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "windSpeed": "7 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 72, + "name": "", + "startTime": "2026-02-09T15:00:00-05:00", + "endTime": "2026-02-09T16:00:00-05:00", + "isDaytime": true, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -10 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "windSpeed": "6 km/h", + "windDirection": "SW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 73, + "name": "", + "startTime": "2026-02-09T16:00:00-05:00", + "endTime": "2026-02-09T17:00:00-05:00", + "isDaytime": true, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -10 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 47 + }, + "windSpeed": "6 km/h", + "windDirection": "SW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 74, + "name": "", + "startTime": "2026-02-09T17:00:00-05:00", + "endTime": "2026-02-09T18:00:00-05:00", + "isDaytime": true, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -10 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "windSpeed": "4 km/h", + "windDirection": "SW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 75, + "name": "", + "startTime": "2026-02-09T18:00:00-05:00", + "endTime": "2026-02-09T19:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -9.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 53 + }, + "windSpeed": "2 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 76, + "name": "", + "startTime": "2026-02-09T19:00:00-05:00", + "endTime": "2026-02-09T20:00:00-05:00", + "isDaytime": false, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -9.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 58 + }, + "windSpeed": "2 km/h", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 77, + "name": "", + "startTime": "2026-02-09T20:00:00-05:00", + "endTime": "2026-02-09T21:00:00-05:00", + "isDaytime": false, + "temperature": -3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -9.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 60 + }, + "windSpeed": "2 km/h", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 78, + "name": "", + "startTime": "2026-02-09T21:00:00-05:00", + "endTime": "2026-02-09T22:00:00-05:00", + "isDaytime": false, + "temperature": -3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 65 + }, + "windSpeed": "2 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 79, + "name": "", + "startTime": "2026-02-09T22:00:00-05:00", + "endTime": "2026-02-09T23:00:00-05:00", + "isDaytime": false, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 68 + }, + "windSpeed": "2 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 80, + "name": "", + "startTime": "2026-02-09T23:00:00-05:00", + "endTime": "2026-02-10T00:00:00-05:00", + "isDaytime": false, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 71 + }, + "windSpeed": "2 km/h", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 81, + "name": "", + "startTime": "2026-02-10T00:00:00-05:00", + "endTime": "2026-02-10T01:00:00-05:00", + "isDaytime": false, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 71 + }, + "windSpeed": "2 km/h", + "windDirection": "NE", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 82, + "name": "", + "startTime": "2026-02-10T01:00:00-05:00", + "endTime": "2026-02-10T02:00:00-05:00", + "isDaytime": false, + "temperature": -5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 74 + }, + "windSpeed": "2 km/h", + "windDirection": "N", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 83, + "name": "", + "startTime": "2026-02-10T02:00:00-05:00", + "endTime": "2026-02-10T03:00:00-05:00", + "isDaytime": false, + "temperature": -5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 74 + }, + "windSpeed": "2 km/h", + "windDirection": "N", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 84, + "name": "", + "startTime": "2026-02-10T03:00:00-05:00", + "endTime": "2026-02-10T04:00:00-05:00", + "isDaytime": false, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 77 + }, + "windSpeed": "2 km/h", + "windDirection": "N", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 85, + "name": "", + "startTime": "2026-02-10T04:00:00-05:00", + "endTime": "2026-02-10T05:00:00-05:00", + "isDaytime": false, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 77 + }, + "windSpeed": "2 km/h", + "windDirection": "N", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 86, + "name": "", + "startTime": "2026-02-10T05:00:00-05:00", + "endTime": "2026-02-10T06:00:00-05:00", + "isDaytime": false, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 77 + }, + "windSpeed": "2 km/h", + "windDirection": "N", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 87, + "name": "", + "startTime": "2026-02-10T06:00:00-05:00", + "endTime": "2026-02-10T07:00:00-05:00", + "isDaytime": true, + "temperature": -6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.88888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 77 + }, + "windSpeed": "2 km/h", + "windDirection": "N", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 88, + "name": "", + "startTime": "2026-02-10T07:00:00-05:00", + "endTime": "2026-02-10T08:00:00-05:00", + "isDaytime": true, + "temperature": -5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -8.333333333333334 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 77 + }, + "windSpeed": "2 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 89, + "name": "", + "startTime": "2026-02-10T08:00:00-05:00", + "endTime": "2026-02-10T09:00:00-05:00", + "isDaytime": true, + "temperature": -4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -7.777777777777778 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 74 + }, + "windSpeed": "2 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 90, + "name": "", + "startTime": "2026-02-10T09:00:00-05:00", + "endTime": "2026-02-10T10:00:00-05:00", + "isDaytime": true, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -6.666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 69 + }, + "windSpeed": "2 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 91, + "name": "", + "startTime": "2026-02-10T10:00:00-05:00", + "endTime": "2026-02-10T11:00:00-05:00", + "isDaytime": true, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -5.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 63 + }, + "windSpeed": "4 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 92, + "name": "", + "startTime": "2026-02-10T11:00:00-05:00", + "endTime": "2026-02-10T12:00:00-05:00", + "isDaytime": true, + "temperature": 2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -4.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 61 + }, + "windSpeed": "4 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 93, + "name": "", + "startTime": "2026-02-10T12:00:00-05:00", + "endTime": "2026-02-10T13:00:00-05:00", + "isDaytime": true, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 0 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.888888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 57 + }, + "windSpeed": "6 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 94, + "name": "", + "startTime": "2026-02-10T13:00:00-05:00", + "endTime": "2026-02-10T14:00:00-05:00", + "isDaytime": true, + "temperature": 5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 1 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.3333333333333335 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 55 + }, + "windSpeed": "6 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 95, + "name": "", + "startTime": "2026-02-10T14:00:00-05:00", + "endTime": "2026-02-10T15:00:00-05:00", + "isDaytime": true, + "temperature": 6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 1 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 55 + }, + "windSpeed": "6 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 96, + "name": "", + "startTime": "2026-02-10T15:00:00-05:00", + "endTime": "2026-02-10T16:00:00-05:00", + "isDaytime": true, + "temperature": 6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 1 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 55 + }, + "windSpeed": "6 km/h", + "windDirection": "NE", + "icon": "https://api.weather.gov/icons/land/day/few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 97, + "name": "", + "startTime": "2026-02-10T16:00:00-05:00", + "endTime": "2026-02-10T17:00:00-05:00", + "isDaytime": true, + "temperature": 6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 1 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 55 + }, + "windSpeed": "4 km/h", + "windDirection": "NE", + "icon": "https://api.weather.gov/icons/land/day/few?size=small", + "shortForecast": "Sunny", + "detailedForecast": "" + }, + { + "number": 98, + "name": "", + "startTime": "2026-02-10T17:00:00-05:00", + "endTime": "2026-02-10T18:00:00-05:00", + "isDaytime": true, + "temperature": 6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 1 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 57 + }, + "windSpeed": "4 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/day/sct?size=small", + "shortForecast": "Mostly Sunny", + "detailedForecast": "" + }, + { + "number": 99, + "name": "", + "startTime": "2026-02-10T18:00:00-05:00", + "endTime": "2026-02-10T19:00:00-05:00", + "isDaytime": false, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 1 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 59 + }, + "windSpeed": "4 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/sct?size=small", + "shortForecast": "Partly Cloudy", + "detailedForecast": "" + }, + { + "number": 100, + "name": "", + "startTime": "2026-02-10T19:00:00-05:00", + "endTime": "2026-02-10T20:00:00-05:00", + "isDaytime": false, + "temperature": 3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 6 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 67 + }, + "windSpeed": "4 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 101, + "name": "", + "startTime": "2026-02-10T20:00:00-05:00", + "endTime": "2026-02-10T21:00:00-05:00", + "isDaytime": false, + "temperature": 2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 6 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 69 + }, + "windSpeed": "2 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 102, + "name": "", + "startTime": "2026-02-10T21:00:00-05:00", + "endTime": "2026-02-10T22:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 6 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "4 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 103, + "name": "", + "startTime": "2026-02-10T22:00:00-05:00", + "endTime": "2026-02-10T23:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 6 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "4 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 104, + "name": "", + "startTime": "2026-02-10T23:00:00-05:00", + "endTime": "2026-02-11T00:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 6 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "4 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 105, + "name": "", + "startTime": "2026-02-11T00:00:00-05:00", + "endTime": "2026-02-11T01:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 6 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "2 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 106, + "name": "", + "startTime": "2026-02-11T01:00:00-05:00", + "endTime": "2026-02-11T02:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 18 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "4 km/h", + "windDirection": "E", + "icon": "https://api.weather.gov/icons/land/night/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 107, + "name": "", + "startTime": "2026-02-11T02:00:00-05:00", + "endTime": "2026-02-11T03:00:00-05:00", + "isDaytime": false, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 18 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 81 + }, + "windSpeed": "4 km/h", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/night/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 108, + "name": "", + "startTime": "2026-02-11T03:00:00-05:00", + "endTime": "2026-02-11T04:00:00-05:00", + "isDaytime": false, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 18 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 85 + }, + "windSpeed": "4 km/h", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/night/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 109, + "name": "", + "startTime": "2026-02-11T04:00:00-05:00", + "endTime": "2026-02-11T05:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 18 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 88 + }, + "windSpeed": "4 km/h", + "windDirection": "SE", + "icon": "https://api.weather.gov/icons/land/night/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 110, + "name": "", + "startTime": "2026-02-11T05:00:00-05:00", + "endTime": "2026-02-11T06:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 18 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 88 + }, + "windSpeed": "4 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/night/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 111, + "name": "", + "startTime": "2026-02-11T06:00:00-05:00", + "endTime": "2026-02-11T07:00:00-05:00", + "isDaytime": true, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 18 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 85 + }, + "windSpeed": "4 km/h", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/day/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 112, + "name": "", + "startTime": "2026-02-11T07:00:00-05:00", + "endTime": "2026-02-11T08:00:00-05:00", + "isDaytime": true, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -1.6666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 85 + }, + "windSpeed": "4 km/h", + "windDirection": "SW", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 113, + "name": "", + "startTime": "2026-02-11T08:00:00-05:00", + "endTime": "2026-02-11T09:00:00-05:00", + "isDaytime": true, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -1.1111111111111112 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 85 + }, + "windSpeed": "6 km/h", + "windDirection": "SW", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 114, + "name": "", + "startTime": "2026-02-11T09:00:00-05:00", + "endTime": "2026-02-11T10:00:00-05:00", + "isDaytime": true, + "temperature": 2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 0 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 85 + }, + "windSpeed": "6 km/h", + "windDirection": "SW", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 115, + "name": "", + "startTime": "2026-02-11T10:00:00-05:00", + "endTime": "2026-02-11T11:00:00-05:00", + "isDaytime": true, + "temperature": 3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 0.5555555555555556 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 82 + }, + "windSpeed": "7 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 116, + "name": "", + "startTime": "2026-02-11T11:00:00-05:00", + "endTime": "2026-02-11T12:00:00-05:00", + "isDaytime": true, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 1.1111111111111112 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 79 + }, + "windSpeed": "7 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 117, + "name": "", + "startTime": "2026-02-11T12:00:00-05:00", + "endTime": "2026-02-11T13:00:00-05:00", + "isDaytime": true, + "temperature": 5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 49 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 1.1111111111111112 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 76 + }, + "windSpeed": "9 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 118, + "name": "", + "startTime": "2026-02-11T13:00:00-05:00", + "endTime": "2026-02-11T14:00:00-05:00", + "isDaytime": true, + "temperature": 6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 1.1111111111111112 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 70 + }, + "windSpeed": "9 km/h", + "windDirection": "W", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 119, + "name": "", + "startTime": "2026-02-11T14:00:00-05:00", + "endTime": "2026-02-11T15:00:00-05:00", + "isDaytime": true, + "temperature": 7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 1.1111111111111112 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 68 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 120, + "name": "", + "startTime": "2026-02-11T15:00:00-05:00", + "endTime": "2026-02-11T16:00:00-05:00", + "isDaytime": true, + "temperature": 7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 1.6666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 70 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 121, + "name": "", + "startTime": "2026-02-11T16:00:00-05:00", + "endTime": "2026-02-11T17:00:00-05:00", + "isDaytime": true, + "temperature": 7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 1.6666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 70 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 122, + "name": "", + "startTime": "2026-02-11T17:00:00-05:00", + "endTime": "2026-02-11T18:00:00-05:00", + "isDaytime": true, + "temperature": 7, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 1.6666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 70 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 123, + "name": "", + "startTime": "2026-02-11T18:00:00-05:00", + "endTime": "2026-02-11T19:00:00-05:00", + "isDaytime": false, + "temperature": 6, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 1.1111111111111112 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 73 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,50?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 124, + "name": "", + "startTime": "2026-02-11T19:00:00-05:00", + "endTime": "2026-02-11T20:00:00-05:00", + "isDaytime": false, + "temperature": 5, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 34 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 0.5555555555555556 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 73 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 125, + "name": "", + "startTime": "2026-02-11T20:00:00-05:00", + "endTime": "2026-02-11T21:00:00-05:00", + "isDaytime": false, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 34 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": 0 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 73 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 126, + "name": "", + "startTime": "2026-02-11T21:00:00-05:00", + "endTime": "2026-02-11T22:00:00-05:00", + "isDaytime": false, + "temperature": 3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 34 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -0.5555555555555556 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 76 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 127, + "name": "", + "startTime": "2026-02-11T22:00:00-05:00", + "endTime": "2026-02-11T23:00:00-05:00", + "isDaytime": false, + "temperature": 3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 34 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -1.1111111111111112 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 128, + "name": "", + "startTime": "2026-02-11T23:00:00-05:00", + "endTime": "2026-02-12T00:00:00-05:00", + "isDaytime": false, + "temperature": 2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 34 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -1.6666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 129, + "name": "", + "startTime": "2026-02-12T00:00:00-05:00", + "endTime": "2026-02-12T01:00:00-05:00", + "isDaytime": false, + "temperature": 2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 34 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -1.6666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 130, + "name": "", + "startTime": "2026-02-12T01:00:00-05:00", + "endTime": "2026-02-12T02:00:00-05:00", + "isDaytime": false, + "temperature": 2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 27 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 131, + "name": "", + "startTime": "2026-02-12T02:00:00-05:00", + "endTime": "2026-02-12T03:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 27 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.2222222222222223 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 132, + "name": "", + "startTime": "2026-02-12T03:00:00-05:00", + "endTime": "2026-02-12T04:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 27 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 133, + "name": "", + "startTime": "2026-02-12T04:00:00-05:00", + "endTime": "2026-02-12T05:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 27 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 134, + "name": "", + "startTime": "2026-02-12T05:00:00-05:00", + "endTime": "2026-02-12T06:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 27 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -2.7777777777777777 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 135, + "name": "", + "startTime": "2026-02-12T06:00:00-05:00", + "endTime": "2026-02-12T07:00:00-05:00", + "isDaytime": true, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 27 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.3333333333333335 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,30?size=small", + "shortForecast": "Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 136, + "name": "", + "startTime": "2026-02-12T07:00:00-05:00", + "endTime": "2026-02-12T08:00:00-05:00", + "isDaytime": true, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.3333333333333335 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 78 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 137, + "name": "", + "startTime": "2026-02-12T08:00:00-05:00", + "endTime": "2026-02-12T09:00:00-05:00", + "isDaytime": true, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.3333333333333335 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 75 + }, + "windSpeed": "13 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 138, + "name": "", + "startTime": "2026-02-12T09:00:00-05:00", + "endTime": "2026-02-12T10:00:00-05:00", + "isDaytime": true, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.3333333333333335 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 72 + }, + "windSpeed": "15 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 139, + "name": "", + "startTime": "2026-02-12T10:00:00-05:00", + "endTime": "2026-02-12T11:00:00-05:00", + "isDaytime": true, + "temperature": 2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.3333333333333335 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 67 + }, + "windSpeed": "17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 140, + "name": "", + "startTime": "2026-02-12T11:00:00-05:00", + "endTime": "2026-02-12T12:00:00-05:00", + "isDaytime": true, + "temperature": 3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.3333333333333335 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 64 + }, + "windSpeed": "17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 141, + "name": "", + "startTime": "2026-02-12T12:00:00-05:00", + "endTime": "2026-02-12T13:00:00-05:00", + "isDaytime": true, + "temperature": 3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.888888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 59 + }, + "windSpeed": "17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/rain,20?size=small", + "shortForecast": "Slight Chance Light Rain", + "detailedForecast": "" + }, + { + "number": 142, + "name": "", + "startTime": "2026-02-12T13:00:00-05:00", + "endTime": "2026-02-12T14:00:00-05:00", + "isDaytime": true, + "temperature": 3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 14 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -3.888888888888889 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 59 + }, + "windSpeed": "17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 143, + "name": "", + "startTime": "2026-02-12T14:00:00-05:00", + "endTime": "2026-02-12T15:00:00-05:00", + "isDaytime": true, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 14 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -4.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 54 + }, + "windSpeed": "17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 144, + "name": "", + "startTime": "2026-02-12T15:00:00-05:00", + "endTime": "2026-02-12T16:00:00-05:00", + "isDaytime": true, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 14 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -4.444444444444445 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 52 + }, + "windSpeed": "17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 145, + "name": "", + "startTime": "2026-02-12T16:00:00-05:00", + "endTime": "2026-02-12T17:00:00-05:00", + "isDaytime": true, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 14 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -5 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 50 + }, + "windSpeed": "17 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 146, + "name": "", + "startTime": "2026-02-12T17:00:00-05:00", + "endTime": "2026-02-12T18:00:00-05:00", + "isDaytime": true, + "temperature": 4, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 14 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -5 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 52 + }, + "windSpeed": "15 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/day/bkn?size=small", + "shortForecast": "Partly Sunny", + "detailedForecast": "" + }, + { + "number": 147, + "name": "", + "startTime": "2026-02-12T18:00:00-05:00", + "endTime": "2026-02-12T19:00:00-05:00", + "isDaytime": false, + "temperature": 3, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 14 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -5.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 54 + }, + "windSpeed": "13 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/bkn?size=small", + "shortForecast": "Mostly Cloudy", + "detailedForecast": "" + }, + { + "number": 148, + "name": "", + "startTime": "2026-02-12T19:00:00-05:00", + "endTime": "2026-02-12T20:00:00-05:00", + "isDaytime": false, + "temperature": 2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 16 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -5.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 59 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 149, + "name": "", + "startTime": "2026-02-12T20:00:00-05:00", + "endTime": "2026-02-12T21:00:00-05:00", + "isDaytime": false, + "temperature": 1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 16 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -5.555555555555555 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 61 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 150, + "name": "", + "startTime": "2026-02-12T21:00:00-05:00", + "endTime": "2026-02-12T22:00:00-05:00", + "isDaytime": false, + "temperature": 0, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 16 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -6.111111111111111 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 63 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 151, + "name": "", + "startTime": "2026-02-12T22:00:00-05:00", + "endTime": "2026-02-12T23:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 16 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -6.111111111111111 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 66 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 152, + "name": "", + "startTime": "2026-02-12T23:00:00-05:00", + "endTime": "2026-02-13T00:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 16 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -6.111111111111111 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 69 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 153, + "name": "", + "startTime": "2026-02-13T00:00:00-05:00", + "endTime": "2026-02-13T01:00:00-05:00", + "isDaytime": false, + "temperature": -1, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 16 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -6.666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 66 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 154, + "name": "", + "startTime": "2026-02-13T01:00:00-05:00", + "endTime": "2026-02-13T02:00:00-05:00", + "isDaytime": false, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -6.666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 69 + }, + "windSpeed": "9 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 155, + "name": "", + "startTime": "2026-02-13T02:00:00-05:00", + "endTime": "2026-02-13T03:00:00-05:00", + "isDaytime": false, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -6.666666666666667 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 71 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + }, + { + "number": 156, + "name": "", + "startTime": "2026-02-13T03:00:00-05:00", + "endTime": "2026-02-13T04:00:00-05:00", + "isDaytime": false, + "temperature": -2, + "temperatureUnit": "C", + "temperatureTrend": null, + "probabilityOfPrecipitation": { + "unitCode": "wmoUnit:percent", + "value": 21 + }, + "dewpoint": { + "unitCode": "wmoUnit:degC", + "value": -7.222222222222222 + }, + "relativeHumidity": { + "unitCode": "wmoUnit:percent", + "value": 68 + }, + "windSpeed": "11 km/h", + "windDirection": "NW", + "icon": "https://api.weather.gov/icons/land/night/snow,20?size=small", + "shortForecast": "Slight Chance Light Snow", + "detailedForecast": "" + } + ] + } +} diff --git a/tests/mocks/weather_weathergov_points.json b/tests/mocks/weather_weathergov_points.json new file mode 100644 index 0000000..d137948 --- /dev/null +++ b/tests/mocks/weather_weathergov_points.json @@ -0,0 +1,89 @@ +{ + "@context": [ + "https://geojson.org/geojson-ld/geojson-context.jsonld", + { + "@version": "1.1", + "wx": "https://api.weather.gov/ontology#", + "s": "https://schema.org/", + "geo": "http://www.opengis.net/ont/geosparql#", + "unit": "http://codes.wmo.int/common/unit/", + "@vocab": "https://api.weather.gov/ontology#", + "geometry": { + "@id": "s:GeoCoordinates", + "@type": "geo:wktLiteral" + }, + "city": "s:addressLocality", + "state": "s:addressRegion", + "distance": { + "@id": "s:Distance", + "@type": "s:QuantitativeValue" + }, + "bearing": { + "@type": "s:QuantitativeValue" + }, + "value": { + "@id": "s:value" + }, + "unitCode": { + "@id": "s:unitCode", + "@type": "@id" + }, + "forecastOffice": { + "@type": "@id" + }, + "forecastGridData": { + "@type": "@id" + }, + "publicZone": { + "@type": "@id" + }, + "county": { + "@type": "@id" + } + } + ], + "id": "https://api.weather.gov/points/38.8894,-77.0352", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.0352, 38.8894] + }, + "properties": { + "@id": "https://api.weather.gov/points/38.8894,-77.0352", + "@type": "wx:Point", + "cwa": "LWX", + "type": "land", + "forecastOffice": "https://api.weather.gov/offices/LWX", + "gridId": "LWX", + "gridX": 97, + "gridY": 71, + "forecast": "https://api.weather.gov/gridpoints/LWX/97,71/forecast", + "forecastHourly": "https://api.weather.gov/gridpoints/LWX/97,71/forecast/hourly", + "forecastGridData": "https://api.weather.gov/gridpoints/LWX/97,71", + "observationStations": "https://api.weather.gov/gridpoints/LWX/97,71/stations", + "relativeLocation": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.017229, 38.904103] + }, + "properties": { + "city": "Washington", + "state": "DC", + "distance": { + "unitCode": "wmoUnit:m", + "value": 2256.4628420106 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 223 + } + } + }, + "forecastZone": "https://api.weather.gov/zones/forecast/DCZ001", + "county": "https://api.weather.gov/zones/county/DCC001", + "fireWeatherZone": "https://api.weather.gov/zones/fire/DCZ001", + "timeZone": "America/New_York", + "radarStation": "KLWX" + } +} diff --git a/tests/mocks/weather_weathergov_stations.json b/tests/mocks/weather_weathergov_stations.json new file mode 100644 index 0000000..742524a --- /dev/null +++ b/tests/mocks/weather_weathergov_stations.json @@ -0,0 +1,1793 @@ +{ + "@context": [ + "https://geojson.org/geojson-ld/geojson-context.jsonld", + { + "@version": "1.1", + "wx": "https://api.weather.gov/ontology#", + "s": "https://schema.org/", + "geo": "http://www.opengis.net/ont/geosparql#", + "unit": "http://codes.wmo.int/common/unit/", + "@vocab": "https://api.weather.gov/ontology#", + "geometry": { + "@id": "s:GeoCoordinates", + "@type": "geo:wktLiteral" + }, + "city": "s:addressLocality", + "state": "s:addressRegion", + "distance": { + "@id": "s:Distance", + "@type": "s:QuantitativeValue" + }, + "bearing": { + "@type": "s:QuantitativeValue" + }, + "value": { + "@id": "s:value" + }, + "unitCode": { + "@id": "s:unitCode", + "@type": "@id" + }, + "forecastOffice": { + "@type": "@id" + }, + "forecastGridData": { + "@type": "@id" + }, + "publicZone": { + "@type": "@id" + }, + "county": { + "@type": "@id" + }, + "observationStations": { + "@container": "@list", + "@type": "@id" + } + } + ], + "type": "FeatureCollection", + "features": [ + { + "id": "https://api.weather.gov/stations/KDCA", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.03417, 38.84833] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KDCA", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 3.9624 + }, + "stationIdentifier": "KDCA", + "name": "Washington/Reagan National Airport, DC", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 3043.6748842539 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 140 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ054", + "county": "https://api.weather.gov/zones/county/VAC013", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ054" + } + }, + { + "id": "https://api.weather.gov/stations/KCGS", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.9223, 38.9806] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KCGS", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 14.9352 + }, + "stationIdentifier": "KCGS", + "name": "College Park Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 16980.874107362 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 43 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ013", + "county": "https://api.weather.gov/zones/county/MDC033", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ013" + } + }, + { + "id": "https://api.weather.gov/stations/KADW", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.85, 38.81667] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KADW", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 85.9536 + }, + "stationIdentifier": "KADW", + "name": "Camp Springs / Andrews Air Force Base", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 18837.139622535 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 108 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ013", + "county": "https://api.weather.gov/zones/county/MDC033", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ013" + } + }, + { + "id": "https://api.weather.gov/stations/KDAA", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.18333, 38.71667] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KDAA", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 21.0312 + }, + "stationIdentifier": "KDAA", + "name": "Fort Belvoir", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 20211.268967046 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 212 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ053", + "county": "https://api.weather.gov/zones/county/VAC059", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ053" + } + }, + { + "id": "https://api.weather.gov/stations/KFME", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.76667, 39.08333] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KFME", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 46.0248 + }, + "stationIdentifier": "KFME", + "name": "Fort Meade / Tipton", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 34568.722953871 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 46 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ014", + "county": "https://api.weather.gov/zones/county/MDC003", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ014" + } + }, + { + "id": "https://api.weather.gov/stations/KIAD", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.4475, 38.93472] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KIAD", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 95.0976 + }, + "stationIdentifier": "KIAD", + "name": "Washington/Dulles International Airport, DC", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 34587.939860418 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 282 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ053", + "county": "https://api.weather.gov/zones/county/VAC059", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ053" + } + }, + { + "id": "https://api.weather.gov/stations/KGAI", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.16551, 39.16957] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KGAI", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 150.876 + }, + "stationIdentifier": "KGAI", + "name": "Gaithersburg - Montgomery County Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 34683.691088332 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 344 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ504", + "county": "https://api.weather.gov/zones/county/MDC031", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ504" + } + }, + { + "id": "https://api.weather.gov/stations/KHEF", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.51667, 38.71667] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KHEF", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 59.1312 + }, + "stationIdentifier": "KHEF", + "name": "Manassas, Manassas Regional Airport/Harry P. Davis Field", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 43324.86402354 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 247 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ527", + "county": "https://api.weather.gov/zones/county/VAC683", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ527" + } + }, + { + "id": "https://api.weather.gov/stations/KNYG", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.30129, 38.50326] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KNYG", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 2.1336 + }, + "stationIdentifier": "KNYG", + "name": "Quantico Marine Corps Airfield - Turner Field", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 45906.349012092 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 207 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ527", + "county": "https://api.weather.gov/zones/county/VAC153", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ527" + } + }, + { + "id": "https://api.weather.gov/stations/KBWI", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.68404, 39.17329] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KBWI", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 41.148 + }, + "stationIdentifier": "KBWI", + "name": "Baltimore, Baltimore-Washington International Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 46680.187081868 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 43 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ014", + "county": "https://api.weather.gov/zones/county/MDC003", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ014" + } + }, + { + "id": "https://api.weather.gov/stations/KJYO", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.56667, 39.08333] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KJYO", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 118.872 + }, + "stationIdentifier": "KJYO", + "name": "Leesburg / Godfrey", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 50093.979211268 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 298 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ506", + "county": "https://api.weather.gov/zones/county/VAC107", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ506" + } + }, + { + "id": "https://api.weather.gov/stations/KNAK", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.48907, 38.99125] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KNAK", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 0.9144 + }, + "stationIdentifier": "KNAK", + "name": "Annapolis, United States Naval Academy", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 50940.029746488 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 74 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ014", + "county": "https://api.weather.gov/zones/county/MDC003", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ014" + } + }, + { + "id": "https://api.weather.gov/stations/KDMH", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.61667, 39.28333] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KDMH", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 6.096 + }, + "stationIdentifier": "KDMH", + "name": "Baltimore, Inner Harbor", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 59684.848549395 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 39 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ011", + "county": "https://api.weather.gov/zones/county/MDC510", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ011" + } + }, + { + "id": "https://api.weather.gov/stations/KRMN", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.45528, 38.39806] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KRMN", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 64.9224 + }, + "stationIdentifier": "KRMN", + "name": "Stafford, Stafford Regional Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 62803.929543738 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 213 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ055", + "county": "https://api.weather.gov/zones/county/VAC179", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ055" + } + }, + { + "id": "https://api.weather.gov/stations/KW29", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.33, 38.9767] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KW29", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 14.9352 + }, + "stationIdentifier": "KW29", + "name": "Bay Bridge Field", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 63992.315724071 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 79 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ015", + "county": "https://api.weather.gov/zones/county/MDC035", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ015" + } + }, + { + "id": "https://api.weather.gov/stations/KHWY", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.71501, 38.58765] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KHWY", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 92.0496 + }, + "stationIdentifier": "KHWY", + "name": "Warrenton-Fauquier Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 65127.84173743 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 241 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ502", + "county": "https://api.weather.gov/zones/county/VAC061", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ502" + } + }, + { + "id": "https://api.weather.gov/stations/KFDK", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.36982, 39.41775] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KFDK", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 81.9912 + }, + "stationIdentifier": "KFDK", + "name": "Frederick Municipal Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 66692.582365459 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 336 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ004", + "county": "https://api.weather.gov/zones/county/MDC021", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ004" + } + }, + { + "id": "https://api.weather.gov/stations/KEZF", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.45, 38.26667] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KEZF", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 25.908 + }, + "stationIdentifier": "KEZF", + "name": "Fredericksburg, Shannon Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 75229.907706335 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 207 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ056", + "county": "https://api.weather.gov/zones/county/VAC177", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ056" + } + }, + { + "id": "https://api.weather.gov/stations/KMTN", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.41667, 39.33333] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KMTN", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 7.0104 + }, + "stationIdentifier": "KMTN", + "name": "Baltimore / Martin", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 75581.565023524 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 46 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ011", + "county": "https://api.weather.gov/zones/county/MDC005", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ011" + } + }, + { + "id": "https://api.weather.gov/stations/K2W6", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.5501, 38.3154] + }, + "properties": { + "@id": "https://api.weather.gov/stations/K2W6", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 43.8912 + }, + "stationIdentifier": "K2W6", + "name": "St Marys County Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 75712.983389938 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 144 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ017", + "county": "https://api.weather.gov/zones/county/MDC037", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ017" + } + }, + { + "id": "https://api.weather.gov/stations/KCJR", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.85738, 38.52607] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KCJR", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 89.916 + }, + "stationIdentifier": "KCJR", + "name": "Culpeper Regional Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 79274.931842245 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 241 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ051", + "county": "https://api.weather.gov/zones/county/VAC047", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ051" + } + }, + { + "id": "https://api.weather.gov/stations/KDMW", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.0077, 39.6083] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KDMW", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 240.4872 + }, + "stationIdentifier": "KDMW", + "name": "Carroll County Regional Jack B Poage Field", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 82279.382252508 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 2 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ005", + "county": "https://api.weather.gov/zones/county/MDC013", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ005" + } + }, + { + "id": "https://api.weather.gov/stations/KESN", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.06667, 38.8] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KESN", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 21.9456 + }, + "stationIdentifier": "KESN", + "name": "Easton Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 86100.892604455 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 94 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ019", + "county": "https://api.weather.gov/zones/county/MDC041", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ019" + } + }, + { + "id": "https://api.weather.gov/stations/KNHK", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.41389, 38.27861] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KNHK", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 11.8872 + }, + "stationIdentifier": "KNHK", + "name": "Patuxent River, Naval Air Station", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 86239.928763087 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 139 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ017", + "county": "https://api.weather.gov/zones/county/MDC037", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ017" + } + }, + { + "id": "https://api.weather.gov/stations/KRSP", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.468, 39.645] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KRSP", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 561.1368 + }, + "stationIdentifier": "KRSP", + "name": "Camp David", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 93237.282967055 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 337 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ004", + "county": "https://api.weather.gov/zones/county/MDC021", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ004" + } + }, + { + "id": "https://api.weather.gov/stations/KNUI", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.42, 38.14889] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KNUI", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 6.096 + }, + "stationIdentifier": "KNUI", + "name": "St. Inigoes, Webster Field, Naval Electronic Systems Engineering Activity", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 97399.572233461 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 145 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ017", + "county": "https://api.weather.gov/zones/county/MDC037", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ017" + } + }, + { + "id": "https://api.weather.gov/stations/KMRB", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.975, 39.40372] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KMRB", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 163.9824 + }, + "stationIdentifier": "KMRB", + "name": "Eastern WV Regional Airport/Shepherd Field", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 99011.527250549 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 307 + }, + "forecast": "https://api.weather.gov/zones/forecast/WVZ052", + "county": "https://api.weather.gov/zones/county/WVC003", + "fireWeatherZone": "https://api.weather.gov/zones/fire/WVZ052" + } + }, + { + "id": "https://api.weather.gov/stations/KOKV", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.15, 39.15] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KOKV", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 221.8944 + }, + "stationIdentifier": "KOKV", + "name": "Winchester Regional", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 99483.258804184 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 288 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ028", + "county": "https://api.weather.gov/zones/county/VAC069", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ028" + } + }, + { + "id": "https://api.weather.gov/stations/KAPG", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.16667, 39.46667] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KAPG", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 17.9832 + }, + "stationIdentifier": "KAPG", + "name": "Phillips Army Air Field / Aberdeen", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 101486.28902381 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 48 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ508", + "county": "https://api.weather.gov/zones/county/MDC025", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ508" + } + }, + { + "id": "https://api.weather.gov/stations/KFRR", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.2535, 38.9175] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KFRR", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 216.1032 + }, + "stationIdentifier": "KFRR", + "name": "Front Royal-warren County Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 103711.79430435 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 273 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ030", + "county": "https://api.weather.gov/zones/county/VAC187", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ030" + } + }, + { + "id": "https://api.weather.gov/stations/K0W3", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.20297, 39.5682] + }, + "properties": { + "@id": "https://api.weather.gov/stations/K0W3", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 125.5776 + }, + "stationIdentifier": "K0W3", + "name": "Harford County Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 106997.11216766 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 43 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ508", + "county": "https://api.weather.gov/zones/county/MDC025", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ508" + } + }, + { + "id": "https://api.weather.gov/stations/KHGR", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.73, 39.70583] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KHGR", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 213.9696 + }, + "stationIdentifier": "KHGR", + "name": "Hagerstown, Washington County Regional Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 109586.26730048 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 328 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ003", + "county": "https://api.weather.gov/zones/county/MDC043", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ003" + } + }, + { + "id": "https://api.weather.gov/stations/KOMH", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.04556, 38.24722] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KOMH", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 142.0368 + }, + "stationIdentifier": "KOMH", + "name": "Orange, Orange County Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 110351.40846398 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 231 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ050", + "county": "https://api.weather.gov/zones/county/VAC137", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ050" + } + }, + { + "id": "https://api.weather.gov/stations/K7W4", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.7459, 37.9658] + }, + "properties": { + "@id": "https://api.weather.gov/stations/K7W4", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 106.9848 + }, + "stationIdentifier": "K7W4", + "name": "Lake Anna Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 117039.9799578 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 211 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ510", + "county": "https://api.weather.gov/zones/county/VAC109", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ510" + } + }, + { + "id": "https://api.weather.gov/stations/KTHV", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.87694, 39.91944] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KTHV", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 145.9992 + }, + "stationIdentifier": "KTHV", + "name": "York, York Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 117785.75882145 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 7 + }, + "forecast": "https://api.weather.gov/zones/forecast/PAZ065", + "county": "https://api.weather.gov/zones/county/PAC133", + "fireWeatherZone": "https://api.weather.gov/zones/fire/PAZ065" + } + }, + { + "id": "https://api.weather.gov/stations/KLKU", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.97028, 38.00972] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KLKU", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 149.9616 + }, + "stationIdentifier": "KLKU", + "name": "Louisa, Louisa County Airport/Freeman Field", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 124364.21495608 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 220 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ510", + "county": "https://api.weather.gov/zones/county/VAC109", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ510" + } + }, + { + "id": "https://api.weather.gov/stations/KGVE", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.1658, 38.156] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KGVE", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 138.0744 + }, + "stationIdentifier": "KGVE", + "name": "Gordonsville Municipal Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 124909.61245374 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 230 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ050", + "county": "https://api.weather.gov/zones/county/VAC137", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ050" + } + }, + { + "id": "https://api.weather.gov/stations/KOFP", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.43444, 37.70806] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KOFP", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 61.8744 + }, + "stationIdentifier": "KOFP", + "name": "Ashland, Hanover County Municipal Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 133267.46930022 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 194 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ511", + "county": "https://api.weather.gov/zones/county/VAC085", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ511" + } + }, + { + "id": "https://api.weather.gov/stations/K8W2", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.7081, 38.6557] + }, + "properties": { + "@id": "https://api.weather.gov/stations/K8W2", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 297.18 + }, + "stationIdentifier": "K8W2", + "name": "New Market Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 145135.20416818 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 261 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ027", + "county": "https://api.weather.gov/zones/county/VAC171", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ027" + } + }, + { + "id": "https://api.weather.gov/stations/KCHO", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.45516, 38.13738] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KCHO", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 195.072 + }, + "stationIdentifier": "KCHO", + "name": "Charlottesville-Albemarle Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 146394.21605562 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 236 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ037", + "county": "https://api.weather.gov/zones/county/VAC003", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ037" + } + }, + { + "id": "https://api.weather.gov/stations/KRIC", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-77.32333, 37.51111] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KRIC", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 50.9016 + }, + "stationIdentifier": "KRIC", + "name": "Richmond, Richmond International Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 152812.69388052 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 188 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ516", + "county": "https://api.weather.gov/zones/county/VAC087", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ516" + } + }, + { + "id": "https://api.weather.gov/stations/KILG", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-75.60567, 39.67442] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KILG", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 21.9456 + }, + "stationIdentifier": "KILG", + "name": "Wilmington Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 153674.36438971 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 53 + }, + "forecast": "https://api.weather.gov/zones/forecast/DEZ001", + "county": "https://api.weather.gov/zones/county/DEC003", + "fireWeatherZone": "https://api.weather.gov/zones/fire/DEZ001" + } + }, + { + "id": "https://api.weather.gov/stations/KLNS", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-76.29446, 40.12058] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KLNS", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 121.0056 + }, + "stationIdentifier": "KLNS", + "name": "Lancaster, Lancaster Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 153739.95516367 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 24 + }, + "forecast": "https://api.weather.gov/zones/forecast/PAZ066", + "county": "https://api.weather.gov/zones/county/PAC071", + "fireWeatherZone": "https://api.weather.gov/zones/fire/PAZ066" + } + }, + { + "id": "https://api.weather.gov/stations/KCBE", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.76083, 39.61528] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KCBE", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 235.9152 + }, + "stationIdentifier": "KCBE", + "name": "Cumberland, Greater Cumberland Regional Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 168568.42779476 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 300 + }, + "forecast": "https://api.weather.gov/zones/forecast/WVZ504", + "county": "https://api.weather.gov/zones/county/WVC057", + "fireWeatherZone": "https://api.weather.gov/zones/fire/WVZ504" + } + }, + { + "id": "https://api.weather.gov/stations/KSHD", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.9, 38.26667] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KSHD", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 366.0648 + }, + "stationIdentifier": "KSHD", + "name": "Staunton / Shenandoah", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 173695.8603158 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 247 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ025", + "county": "https://api.weather.gov/zones/county/VAC015", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ025" + } + }, + { + "id": "https://api.weather.gov/stations/KVBW", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.96033, 38.36674] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KVBW", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 355.092 + }, + "stationIdentifier": "KVBW", + "name": "Bridgewater Air Park", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 174565.90990218 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 251 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ026", + "county": "https://api.weather.gov/zones/county/VAC165", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ026" + } + }, + { + "id": "https://api.weather.gov/stations/KMFV", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-75.76667, 37.65] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KMFV", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 14.9352 + }, + "stationIdentifier": "KMFV", + "name": "Melfa / Accomack Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 176261.84200174 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 139 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ099", + "county": "https://api.weather.gov/zones/county/VAC001", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ099" + } + }, + { + "id": "https://api.weather.gov/stations/KW13", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.9444, 38.0769] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KW13", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 437.9976 + }, + "stationIdentifier": "KW13", + "name": "Eagles Nest Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 186456.91815645 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 242 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ025", + "county": "https://api.weather.gov/zones/county/VAC015", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ025" + } + }, + { + "id": "https://api.weather.gov/stations/KFVX", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-78.43333, 37.35] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KFVX", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 124.968 + }, + "stationIdentifier": "KFVX", + "name": "Farmville", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 207471.35283371 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 215 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ061", + "county": "https://api.weather.gov/zones/county/VAC049", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ061" + } + }, + { + "id": "https://api.weather.gov/stations/K2G4", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-79.3394, 39.5803] + }, + "properties": { + "@id": "https://api.weather.gov/stations/K2G4", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 893.9784 + }, + "stationIdentifier": "K2G4", + "name": "Garrett County Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 211917.76730527 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 292 + }, + "forecast": "https://api.weather.gov/zones/forecast/MDZ509", + "county": "https://api.weather.gov/zones/county/MDC023", + "fireWeatherZone": "https://api.weather.gov/zones/fire/MDZ509" + } + }, + { + "id": "https://api.weather.gov/stations/K2G9", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-79.015, 40.0389] + }, + "properties": { + "@id": "https://api.weather.gov/stations/K2G9", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 693.42 + }, + "stationIdentifier": "K2G9", + "name": "Somerset County Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 212550.3935379 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 308 + }, + "forecast": "https://api.weather.gov/zones/forecast/PAZ033", + "county": "https://api.weather.gov/zones/county/PAC111", + "fireWeatherZone": "https://api.weather.gov/zones/fire/PAZ033" + } + }, + { + "id": "https://api.weather.gov/stations/KEKN", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-79.85278, 38.88528] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KEKN", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 605.028 + }, + "stationIdentifier": "KEKN", + "name": "Elkins, Elkins-Randolph County-Jennings Randolph Field", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 242035.43677875 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 271 + }, + "forecast": "https://api.weather.gov/zones/forecast/WVZ525", + "county": "https://api.weather.gov/zones/county/WVC083", + "fireWeatherZone": "https://api.weather.gov/zones/fire/WVZ525" + } + }, + { + "id": "https://api.weather.gov/stations/KLYH", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-79.20667, 37.32083] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KLYH", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 284.988 + }, + "stationIdentifier": "KLYH", + "name": "Lynchburg, Lynchburg Regional Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 255021.94789795 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 228 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ045", + "county": "https://api.weather.gov/zones/county/VAC031", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ045" + } + }, + { + "id": "https://api.weather.gov/stations/KMGW", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-79.92065, 39.64985] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KMGW", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 373.9896 + }, + "stationIdentifier": "KMGW", + "name": "Morgantown Municipal-Hart Field", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 261388.09543822 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 290 + }, + "forecast": "https://api.weather.gov/zones/forecast/WVZ509", + "county": "https://api.weather.gov/zones/county/WVC061", + "fireWeatherZone": "https://api.weather.gov/zones/fire/WVZ509" + } + }, + { + "id": "https://api.weather.gov/stations/KHSP", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-79.83333, 37.95] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KHSP", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 1156.1064 + }, + "stationIdentifier": "KHSP", + "name": "Hot Springs / Ingalls", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 262623.28570565 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 247 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ020", + "county": "https://api.weather.gov/zones/county/VAC017", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ020" + } + }, + { + "id": "https://api.weather.gov/stations/KROA", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-79.97417, 37.31694] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KROA", + "@type": "wx:ObservationStation", + "elevation": { + "unitCode": "wmoUnit:m", + "value": 358.14 + }, + "stationIdentifier": "KROA", + "name": "Roanoke, Roanoke Regional Airport", + "timeZone": "America/New_York", + "distance": { + "unitCode": "wmoUnit:m", + "value": 308160.42662802 + }, + "bearing": { + "unitCode": "wmoUnit:degree_(angle)", + "value": 236 + }, + "forecast": "https://api.weather.gov/zones/forecast/VAZ022", + "county": "https://api.weather.gov/zones/county/VAC770", + "fireWeatherZone": "https://api.weather.gov/zones/fire/VAZ022" + } + } + ], + "observationStations": [ + "https://api.weather.gov/stations/KDCA", + "https://api.weather.gov/stations/KCGS", + "https://api.weather.gov/stations/KADW", + "https://api.weather.gov/stations/KDAA", + "https://api.weather.gov/stations/KFME", + "https://api.weather.gov/stations/KIAD", + "https://api.weather.gov/stations/KGAI", + "https://api.weather.gov/stations/KHEF", + "https://api.weather.gov/stations/KNYG", + "https://api.weather.gov/stations/KBWI", + "https://api.weather.gov/stations/KJYO", + "https://api.weather.gov/stations/KNAK", + "https://api.weather.gov/stations/KDMH", + "https://api.weather.gov/stations/KRMN", + "https://api.weather.gov/stations/KW29", + "https://api.weather.gov/stations/KHWY", + "https://api.weather.gov/stations/KFDK", + "https://api.weather.gov/stations/KEZF", + "https://api.weather.gov/stations/KMTN", + "https://api.weather.gov/stations/K2W6", + "https://api.weather.gov/stations/KCJR", + "https://api.weather.gov/stations/KDMW", + "https://api.weather.gov/stations/KESN", + "https://api.weather.gov/stations/KNHK", + "https://api.weather.gov/stations/KRSP", + "https://api.weather.gov/stations/KNUI", + "https://api.weather.gov/stations/KMRB", + "https://api.weather.gov/stations/KOKV", + "https://api.weather.gov/stations/KAPG", + "https://api.weather.gov/stations/KFRR", + "https://api.weather.gov/stations/K0W3", + "https://api.weather.gov/stations/KHGR", + "https://api.weather.gov/stations/KOMH", + "https://api.weather.gov/stations/K7W4", + "https://api.weather.gov/stations/KTHV", + "https://api.weather.gov/stations/KLKU", + "https://api.weather.gov/stations/KGVE", + "https://api.weather.gov/stations/KOFP", + "https://api.weather.gov/stations/K8W2", + "https://api.weather.gov/stations/KCHO", + "https://api.weather.gov/stations/KRIC", + "https://api.weather.gov/stations/KILG", + "https://api.weather.gov/stations/KLNS", + "https://api.weather.gov/stations/KCBE", + "https://api.weather.gov/stations/KSHD", + "https://api.weather.gov/stations/KVBW", + "https://api.weather.gov/stations/KMFV", + "https://api.weather.gov/stations/KW13", + "https://api.weather.gov/stations/KFVX", + "https://api.weather.gov/stations/K2G4", + "https://api.weather.gov/stations/K2G9", + "https://api.weather.gov/stations/KEKN", + "https://api.weather.gov/stations/KLYH", + "https://api.weather.gov/stations/KMGW", + "https://api.weather.gov/stations/KHSP", + "https://api.weather.gov/stations/KROA" + ], + "pagination": { + "next": "https://api.weather.gov/stations?id%5B0%5D=K0W3&id%5B1%5D=K2G4&id%5B2%5D=K2G9&id%5B3%5D=K2W6&id%5B4%5D=K7W4&id%5B5%5D=K8W2&id%5B6%5D=KADW&id%5B7%5D=KAPG&id%5B8%5D=KBWI&id%5B9%5D=KCBE&id%5B10%5D=KCGS&id%5B11%5D=KCHO&id%5B12%5D=KCJR&id%5B13%5D=KDAA&id%5B14%5D=KDCA&id%5B15%5D=KDMH&id%5B16%5D=KDMW&id%5B17%5D=KEKN&id%5B18%5D=KESN&id%5B19%5D=KEZF&id%5B20%5D=KFDK&id%5B21%5D=KFME&id%5B22%5D=KFRR&id%5B23%5D=KFVX&id%5B24%5D=KGAI&id%5B25%5D=KGVE&id%5B26%5D=KHEF&id%5B27%5D=KHGR&id%5B28%5D=KHSP&id%5B29%5D=KHWY&id%5B30%5D=KIAD&id%5B31%5D=KILG&id%5B32%5D=KJYO&id%5B33%5D=KLKU&id%5B34%5D=KLNS&id%5B35%5D=KLYH&id%5B36%5D=KMFV&id%5B37%5D=KMGW&id%5B38%5D=KMRB&id%5B39%5D=KMTN&id%5B40%5D=KNAK&id%5B41%5D=KNHK&id%5B42%5D=KNUI&id%5B43%5D=KNYG&id%5B44%5D=KOFP&id%5B45%5D=KOKV&id%5B46%5D=KOMH&id%5B47%5D=KRIC&id%5B48%5D=KRMN&id%5B49%5D=KROA&id%5B50%5D=KRSP&id%5B51%5D=KSHD&id%5B52%5D=KTHV&id%5B53%5D=KVBW&id%5B54%5D=KW13&id%5B55%5D=KW29&cursor=eyJzIjo1MDB9" + } +} diff --git a/tests/mocks/weather_yr.json b/tests/mocks/weather_yr.json new file mode 100644 index 0000000..3d7dc66 --- /dev/null +++ b/tests/mocks/weather_yr.json @@ -0,0 +1,707 @@ +{ + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [10.7522, 59.9139, 5] }, + "properties": { + "meta": { + "updated_at": "2026-02-06T20:27:06Z", + "units": { "air_pressure_at_sea_level": "hPa", "air_temperature": "celsius", "cloud_area_fraction": "%", "precipitation_amount": "mm", "relative_humidity": "%", "wind_from_direction": "degrees", "wind_speed": "m/s" } + }, + "timeseries": [ + { + "time": "2026-02-06T21:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1014.6, "air_temperature": -5.8, "cloud_area_fraction": 100.0, "relative_humidity": 66.5, "wind_from_direction": 37.0, "wind_speed": 6.0 } }, + "next_12_hours": { "summary": { "symbol_code": "lightsnow" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 0.5 } }, + "next_6_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 3.5 } } + } + }, + { + "time": "2026-02-06T22:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1014.8, "air_temperature": -5.9, "cloud_area_fraction": 100.0, "relative_humidity": 70.5, "wind_from_direction": 39.0, "wind_speed": 6.4 } }, + "next_12_hours": { "summary": { "symbol_code": "lightsnow" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 0.7 } }, + "next_6_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 3.3 } } + } + }, + { + "time": "2026-02-06T23:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1015.1, "air_temperature": -5.9, "cloud_area_fraction": 100.0, "relative_humidity": 73.3, "wind_from_direction": 41.0, "wind_speed": 6.6 } }, + "next_12_hours": { "summary": { "symbol_code": "lightsnow" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 0.8 } }, + "next_6_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 2.6 } } + } + }, + { + "time": "2026-02-07T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1015.4, "air_temperature": -5.8, "cloud_area_fraction": 100.0, "relative_humidity": 74.6, "wind_from_direction": 40.0, "wind_speed": 6.9 } }, + "next_12_hours": { "summary": { "symbol_code": "lightsnow" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 0.6 } }, + "next_6_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 1.9 } } + } + }, + { + "time": "2026-02-07T01:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1015.5, "air_temperature": -5.7, "cloud_area_fraction": 100.0, "relative_humidity": 75.5, "wind_from_direction": 41.0, "wind_speed": 6.9 } }, + "next_12_hours": { "summary": { "symbol_code": "lightsnow" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 0.5 } }, + "next_6_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 1.4 } } + } + }, + { + "time": "2026-02-07T02:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1015.7, "air_temperature": -5.5, "cloud_area_fraction": 100.0, "relative_humidity": 76.2, "wind_from_direction": 38.0, "wind_speed": 5.6 } }, + "next_12_hours": { "summary": { "symbol_code": "lightsnow" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "snow" }, "details": { "precipitation_amount": 0.3 } }, + "next_6_hours": { "summary": { "symbol_code": "lightsnow" }, "details": { "precipitation_amount": 0.9 } } + } + }, + { + "time": "2026-02-07T03:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1015.7, "air_temperature": -5.3, "cloud_area_fraction": 100.0, "relative_humidity": 76.6, "wind_from_direction": 37.0, "wind_speed": 5.2 } }, + "next_12_hours": { "summary": { "symbol_code": "lightsnow" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "lightsnow" }, "details": { "precipitation_amount": 0.2 } }, + "next_6_hours": { "summary": { "symbol_code": "lightsnow" }, "details": { "precipitation_amount": 0.6 } } + } + }, + { + "time": "2026-02-07T04:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1015.7, "air_temperature": -5.2, "cloud_area_fraction": 100.0, "relative_humidity": 76.1, "wind_from_direction": 36.0, "wind_speed": 4.8 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "lightsnow" }, "details": { "precipitation_amount": 0.2 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T05:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1015.9, "air_temperature": -5.1, "cloud_area_fraction": 100.0, "relative_humidity": 75.6, "wind_from_direction": 35.0, "wind_speed": 4.4 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1016.5, "air_temperature": -5.0, "cloud_area_fraction": 100.0, "relative_humidity": 74.7, "wind_from_direction": 33.0, "wind_speed": 4.0 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T07:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1017.2, "air_temperature": -4.9, "cloud_area_fraction": 100.0, "relative_humidity": 73.7, "wind_from_direction": 35.0, "wind_speed": 4.3 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T08:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1017.9, "air_temperature": -4.7, "cloud_area_fraction": 99.8, "relative_humidity": 71.7, "wind_from_direction": 38.0, "wind_speed": 4.6 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T09:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1018.5, "air_temperature": -4.5, "cloud_area_fraction": 99.8, "relative_humidity": 70.2, "wind_from_direction": 43.0, "wind_speed": 5.5 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T10:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1019.0, "air_temperature": -4.1, "cloud_area_fraction": 100.0, "relative_humidity": 69.5, "wind_from_direction": 45.0, "wind_speed": 5.7 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T11:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1019.2, "air_temperature": -3.7, "cloud_area_fraction": 99.9, "relative_humidity": 68.7, "wind_from_direction": 45.0, "wind_speed": 5.7 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1019.2, "air_temperature": -3.1, "cloud_area_fraction": 93.4, "relative_humidity": 63.4, "wind_from_direction": 43.0, "wind_speed": 5.8 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T13:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1019.3, "air_temperature": -2.8, "cloud_area_fraction": 83.1, "relative_humidity": 59.5, "wind_from_direction": 46.0, "wind_speed": 6.1 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T14:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1019.5, "air_temperature": -2.7, "cloud_area_fraction": 79.7, "relative_humidity": 57.7, "wind_from_direction": 43.0, "wind_speed": 5.9 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "fair_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T15:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1019.8, "air_temperature": -2.9, "cloud_area_fraction": 70.8, "relative_humidity": 56.6, "wind_from_direction": 40.0, "wind_speed": 5.6 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "fair_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T16:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1020.3, "air_temperature": -3.6, "cloud_area_fraction": 55.6, "relative_humidity": 55.7, "wind_from_direction": 42.0, "wind_speed": 5.5 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "fair_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T17:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1020.8, "air_temperature": -4.3, "cloud_area_fraction": 43.1, "relative_humidity": 54.0, "wind_from_direction": 43.0, "wind_speed": 5.4 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "fair_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1021.5, "air_temperature": -4.8, "cloud_area_fraction": 27.4, "relative_humidity": 52.3, "wind_from_direction": 42.0, "wind_speed": 5.2 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "fair_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T19:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1022.1, "air_temperature": -5.2, "cloud_area_fraction": 19.3, "relative_humidity": 53.2, "wind_from_direction": 43.0, "wind_speed": 5.4 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "fair_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T20:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1022.7, "air_temperature": -5.5, "cloud_area_fraction": 10.2, "relative_humidity": 55.0, "wind_from_direction": 43.0, "wind_speed": 5.7 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "clearsky_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T21:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1023.5, "air_temperature": -5.6, "cloud_area_fraction": 6.8, "relative_humidity": 61.3, "wind_from_direction": 43.0, "wind_speed": 5.6 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "clearsky_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T22:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1024.2, "air_temperature": -5.9, "cloud_area_fraction": 38.5, "relative_humidity": 71.4, "wind_from_direction": 38.0, "wind_speed": 4.7 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-07T23:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1024.7, "air_temperature": -6.2, "cloud_area_fraction": 75.2, "relative_humidity": 77.8, "wind_from_direction": 36.0, "wind_speed": 4.0 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.2, "air_temperature": -6.4, "cloud_area_fraction": 79.6, "relative_humidity": 79.8, "wind_from_direction": 36.0, "wind_speed": 3.2 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T01:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.5, "air_temperature": -6.5, "cloud_area_fraction": 77.6, "relative_humidity": 80.0, "wind_from_direction": 34.0, "wind_speed": 3.1 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T02:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.5, "air_temperature": -6.5, "cloud_area_fraction": 71.4, "relative_humidity": 79.7, "wind_from_direction": 32.0, "wind_speed": 3.4 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T03:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.3, "air_temperature": -6.7, "cloud_area_fraction": 63.1, "relative_humidity": 79.9, "wind_from_direction": 32.0, "wind_speed": 3.3 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T04:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.3, "air_temperature": -7.1, "cloud_area_fraction": 62.1, "relative_humidity": 80.4, "wind_from_direction": 33.0, "wind_speed": 3.1 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T05:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.2, "air_temperature": -7.5, "cloud_area_fraction": 65.0, "relative_humidity": 82.2, "wind_from_direction": 45.0, "wind_speed": 2.4 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.5, "air_temperature": -7.7, "cloud_area_fraction": 77.7, "relative_humidity": 82.7, "wind_from_direction": 48.0, "wind_speed": 2.2 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T07:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.8, "air_temperature": -7.8, "cloud_area_fraction": 84.5, "relative_humidity": 82.2, "wind_from_direction": 48.0, "wind_speed": 2.6 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T08:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1026.2, "air_temperature": -7.6, "cloud_area_fraction": 82.8, "relative_humidity": 80.9, "wind_from_direction": 48.0, "wind_speed": 3.0 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T09:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1026.4, "air_temperature": -6.9, "cloud_area_fraction": 77.9, "relative_humidity": 78.9, "wind_from_direction": 46.0, "wind_speed": 3.3 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T10:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1026.3, "air_temperature": -6.2, "cloud_area_fraction": 82.3, "relative_humidity": 77.0, "wind_from_direction": 43.0, "wind_speed": 3.5 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T11:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1026.3, "air_temperature": -5.5, "cloud_area_fraction": 93.0, "relative_humidity": 76.6, "wind_from_direction": 49.0, "wind_speed": 3.2 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1026.1, "air_temperature": -5.1, "cloud_area_fraction": 98.9, "relative_humidity": 76.2, "wind_from_direction": 47.0, "wind_speed": 2.6 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T13:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.7, "air_temperature": -4.8, "cloud_area_fraction": 99.4, "relative_humidity": 76.2, "wind_from_direction": 50.0, "wind_speed": 2.3 } }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T14:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.4, "air_temperature": -4.8, "cloud_area_fraction": 95.5, "relative_humidity": 76.3, "wind_from_direction": 56.0, "wind_speed": 2.5 } }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T15:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1025.1, "air_temperature": -5.4, "cloud_area_fraction": 84.9, "relative_humidity": 77.2, "wind_from_direction": 56.0, "wind_speed": 2.6 } }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T16:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1024.8, "air_temperature": -6.1, "cloud_area_fraction": 57.9, "relative_humidity": 78.9, "wind_from_direction": 48.0, "wind_speed": 2.7 } }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T17:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1024.5, "air_temperature": -6.5, "cloud_area_fraction": 50.7, "relative_humidity": 81.3, "wind_from_direction": 38.0, "wind_speed": 2.5 } }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1024.3, "air_temperature": -6.9, "cloud_area_fraction": 72.7, "relative_humidity": 82.2, "wind_from_direction": 38.0, "wind_speed": 2.5 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_1_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T19:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1024.3, "air_temperature": -6.9, "cloud_area_fraction": 89.8, "relative_humidity": 81.9, "wind_from_direction": 44.0, "wind_speed": 1.9 } }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T20:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1024.2, "air_temperature": -7.0, "cloud_area_fraction": 96.6, "relative_humidity": 81.3, "wind_from_direction": 39.0, "wind_speed": 2.3 } }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T21:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1024.1, "air_temperature": -6.7, "cloud_area_fraction": 97.2, "relative_humidity": 79.9, "wind_from_direction": 40.0, "wind_speed": 2.8 } }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T22:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1023.8, "air_temperature": -6.7, "cloud_area_fraction": 97.6, "relative_humidity": 80.3, "wind_from_direction": 50.0, "wind_speed": 2.6 } }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-08T23:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1023.4, "air_temperature": -6.7, "cloud_area_fraction": 93.5, "relative_humidity": 80.7, "wind_from_direction": 53.0, "wind_speed": 2.3 } }, + "next_1_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-09T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1023.1, "air_temperature": -7.1, "cloud_area_fraction": 80.0, "relative_humidity": 81.2, "wind_from_direction": 60.0, "wind_speed": 2.3 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-09T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1019.1, "air_temperature": -4.4, "cloud_area_fraction": 99.2, "relative_humidity": 85.9, "wind_from_direction": 339.8, "wind_speed": 1.1 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-09T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1017.8, "air_temperature": -4.3, "cloud_area_fraction": 100.0, "relative_humidity": 72.3, "wind_from_direction": 285.3, "wind_speed": 0.7 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-09T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1014.7, "air_temperature": -6.8, "cloud_area_fraction": 95.7, "relative_humidity": 82.1, "wind_from_direction": 346.8, "wind_speed": 0.6 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-10T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1012.9, "air_temperature": -8.8, "cloud_area_fraction": 97.7, "relative_humidity": 83.2, "wind_from_direction": 15.8, "wind_speed": 1.0 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-10T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1009.9, "air_temperature": -5.8, "cloud_area_fraction": 93.7, "relative_humidity": 82.2, "wind_from_direction": 22.4, "wind_speed": 1.0 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-10T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1007.5, "air_temperature": -3.5, "cloud_area_fraction": 100.0, "relative_humidity": 71.4, "wind_from_direction": 202.3, "wind_speed": 0.9 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-10T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1004.3, "air_temperature": -3.0, "cloud_area_fraction": 100.0, "relative_humidity": 81.9, "wind_from_direction": 22.3, "wind_speed": 1.0 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-11T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1002.5, "air_temperature": -2.3, "cloud_area_fraction": 100.0, "relative_humidity": 85.0, "wind_from_direction": 28.5, "wind_speed": 1.0 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-11T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1000.9, "air_temperature": -3.2, "cloud_area_fraction": 100.0, "relative_humidity": 85.5, "wind_from_direction": 28.1, "wind_speed": 1.6 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-11T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 999.8, "air_temperature": -2.0, "cloud_area_fraction": 100.0, "relative_humidity": 74.9, "wind_from_direction": 56.3, "wind_speed": 2.2 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-11T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 998.8, "air_temperature": -2.4, "cloud_area_fraction": 82.0, "relative_humidity": 77.8, "wind_from_direction": 29.5, "wind_speed": 2.2 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-12T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 998.3, "air_temperature": -2.9, "cloud_area_fraction": 100.0, "relative_humidity": 83.4, "wind_from_direction": 33.1, "wind_speed": 2.5 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-12T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 998.4, "air_temperature": -3.9, "cloud_area_fraction": 100.0, "relative_humidity": 83.0, "wind_from_direction": 24.1, "wind_speed": 2.5 } }, + "next_12_hours": { "summary": { "symbol_code": "cloudy" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-12T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 998.9, "air_temperature": -3.3, "cloud_area_fraction": 99.6, "relative_humidity": 73.0, "wind_from_direction": 54.4, "wind_speed": 2.6 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-12T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 999.9, "air_temperature": -4.3, "cloud_area_fraction": 98.0, "relative_humidity": 81.3, "wind_from_direction": 24.0, "wind_speed": 2.2 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-13T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1001.9, "air_temperature": -4.6, "cloud_area_fraction": 39.8, "relative_humidity": 80.6, "wind_from_direction": 23.4, "wind_speed": 2.0 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-13T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1004.1, "air_temperature": -7.4, "cloud_area_fraction": 36.3, "relative_humidity": 81.8, "wind_from_direction": 21.9, "wind_speed": 1.9 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-13T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1005.7, "air_temperature": -5.8, "cloud_area_fraction": 100.0, "relative_humidity": 73.2, "wind_from_direction": 33.1, "wind_speed": 1.5 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-13T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1004.7, "air_temperature": -5.0, "cloud_area_fraction": 0.0, "relative_humidity": 76.6, "wind_from_direction": 20.2, "wind_speed": 1.7 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-14T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1007.8, "air_temperature": -7.8, "cloud_area_fraction": 6.2, "relative_humidity": 78.8, "wind_from_direction": 23.1, "wind_speed": 1.7 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "fair_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-14T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1007.4, "air_temperature": -11.8, "cloud_area_fraction": 21.9, "relative_humidity": 79.9, "wind_from_direction": 21.8, "wind_speed": 1.7 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "fair_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-14T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1007.5, "air_temperature": -6.3, "cloud_area_fraction": 100.0, "relative_humidity": 70.5, "wind_from_direction": 25.3, "wind_speed": 1.2 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-14T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1008.0, "air_temperature": -5.5, "cloud_area_fraction": 100.0, "relative_humidity": 76.6, "wind_from_direction": 22.4, "wind_speed": 1.2 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_night" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-15T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1009.5, "air_temperature": -6.4, "cloud_area_fraction": 25.4, "relative_humidity": 76.8, "wind_from_direction": 18.6, "wind_speed": 1.5 } }, + "next_12_hours": { "summary": { "symbol_code": "fair_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "fair_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-15T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1012.1, "air_temperature": -11.2, "cloud_area_fraction": 16.8, "relative_humidity": 79.5, "wind_from_direction": 17.5, "wind_speed": 1.6 } }, + "next_12_hours": { "summary": { "symbol_code": "clearsky_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "fair_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-15T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1013.1, "air_temperature": -5.3, "cloud_area_fraction": 2.7, "relative_humidity": 59.4, "wind_from_direction": 197.5, "wind_speed": 1.2 } }, + "next_12_hours": { "summary": { "symbol_code": "clearsky_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "clearsky_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-15T18:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1015.2, "air_temperature": -7.4, "cloud_area_fraction": 2.3, "relative_humidity": 74.9, "wind_from_direction": 22.8, "wind_speed": 1.4 } }, + "next_12_hours": { "summary": { "symbol_code": "fair_night" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "clearsky_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-16T00:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1017.9, "air_temperature": -9.3, "cloud_area_fraction": 2.3, "relative_humidity": 78.8, "wind_from_direction": 22.1, "wind_speed": 1.5 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "clearsky_night" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-16T06:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1017.5, "air_temperature": -8.6, "cloud_area_fraction": 100.0, "relative_humidity": 82.1, "wind_from_direction": 17.7, "wind_speed": 1.4 } }, + "next_12_hours": { "summary": { "symbol_code": "partlycloudy_day" }, "details": {} }, + "next_6_hours": { "summary": { "symbol_code": "cloudy" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-16T12:00:00Z", + "data": { + "instant": { "details": { "air_pressure_at_sea_level": 1012.1, "air_temperature": -3.0, "cloud_area_fraction": 3.9, "relative_humidity": 62.3, "wind_from_direction": 30.4, "wind_speed": 1.4 } }, + "next_6_hours": { "summary": { "symbol_code": "fair_day" }, "details": { "precipitation_amount": 0.0 } } + } + }, + { + "time": "2026-02-16T18:00:00Z", + "data": { "instant": { "details": { "air_pressure_at_sea_level": 1017.1, "air_temperature": -5.9, "cloud_area_fraction": 100.0, "relative_humidity": 82.0, "wind_from_direction": 26.6, "wind_speed": 1.9 } } } + } + ] + } +} diff --git a/tests/mocks/whole_day_moved_over_dst_change_berlin.ics b/tests/mocks/whole_day_moved_over_dst_change_berlin.ics new file mode 100644 index 0000000..7335ccf --- /dev/null +++ b/tests/mocks/whole_day_moved_over_dst_change_berlin.ics @@ -0,0 +1,28 @@ +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 diff --git a/tests/unit/classes/deprecated_spec.js b/tests/unit/classes/deprecated_spec.js new file mode 100644 index 0000000..b1e6aa6 --- /dev/null +++ b/tests/unit/classes/deprecated_spec.js @@ -0,0 +1,15 @@ +const deprecated = require("../../../js/deprecated"); + +describe("Deprecated", () => { + it("should be an object", () => { + expect(typeof deprecated).toBe("object"); + }); + + it("should contain clock array with deprecated options as strings", () => { + expect(Array.isArray(["deprecated.clock"])).toBe(true); + for (let option of deprecated.configs) { + expect(typeof option).toBe("string"); + } + expect(deprecated.clock).toEqual(expect.arrayContaining(["secondsColor"])); + }); +}); diff --git a/tests/unit/classes/module_spec.js b/tests/unit/classes/module_spec.js new file mode 100644 index 0000000..8f62c39 --- /dev/null +++ b/tests/unit/classes/module_spec.js @@ -0,0 +1,299 @@ +const path = require("node:path"); +const { pathToFileURL } = require("node:url"); + +describe("File js/module (cloneObject)", () => { + describe("Test function cloneObject", () => { + let clone; + let Module; + let originalWindow; + let originalLog; + let originalConfig; + let originalMM; + let originalTranslator; + let originalNunjucks; + + beforeAll(async () => { + originalWindow = global.window; + originalLog = global.Log; + originalConfig = global.config; + originalMM = global.MM; + originalTranslator = global.Translator; + originalNunjucks = global.nunjucks; + + global.window = { mmVersion: "2.0.0" }; + global.Log = { log: () => {}, info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; + global.config = { language: "en" }; + global.MM = { + hideModule: () => {}, + showModule: () => {}, + sendNotification: () => {}, + updateDom: () => {} + }; + global.Translator = { + load: () => Promise.resolve(), + translate: () => "" + }; + global.nunjucks = { + Environment () { + this.addFilter = () => {}; + this.renderString = () => ""; + this.render = (_template, _data, callback) => callback(null, ""); + }, + WebLoader () {}, + runtime: { + markSafe: (str) => str + } + }; + + const modulePath = pathToFileURL(path.join(__dirname, "..", "..", "..", "js", "module.js")).href; + const loaded = await import(`${modulePath}?test=${Date.now()}`); + + clone = loaded.cloneObject; + Module = loaded.Module; + }); + + afterAll(() => { + global.window = originalWindow; + global.Log = originalLog; + global.config = originalConfig; + global.MM = originalMM; + global.Translator = originalTranslator; + global.nunjucks = originalNunjucks; + }); + + it("should clone object", () => { + const expected = { name: "Rodrigo", web: "https://rodrigoramirez.com", project: "MagicMirror" }; + const obj = clone(expected); + expect(obj).toEqual(expected); + expect(expected === obj).toBe(false); + }); + + it("should clone array", () => { + const expected = [1, null, undefined, "TEST"]; + const obj = clone(expected); + expect(obj).toEqual(expected); + expect(expected === obj).toBe(false); + }); + + it("should clone number", () => { + let expected = 1; + let obj = clone(expected); + expect(obj).toBe(expected); + + expected = 1.23; + obj = clone(expected); + expect(obj).toBe(expected); + }); + + it("should clone string", () => { + const expected = "Perfect stranger"; + const obj = clone(expected); + expect(obj).toBe(expected); + }); + + it("should clone regex", () => { + const expected = /.*Magic/; + const obj = clone(expected); + expect(obj).toEqual(expected); + expect(expected === obj).toBe(false); + }); + + it("should clone date", () => { + const expected = new Date("2026-05-11T20:00:00.000Z"); + const obj = clone(expected); + expect(obj).toEqual(expected); + expect(expected === obj).toBe(false); + }); + + it("should return URL by reference", () => { + const expected = new URL("https://magicmirror.builders/path?q=1"); + const obj = clone(expected); + expect(obj).toBe(expected); + }); + + it("should return map by reference", () => { + const mapValue = { nested: [1, 2, 3] }; + const expected = new Map([["module", mapValue]]); + const obj = clone(expected); + expect(obj).toBe(expected); + }); + + it("should return set by reference", () => { + const setValue = { nested: true }; + const expected = new Set([setValue]); + const obj = clone(expected); + expect(obj).toBe(expected); + }); + + it("should return class instances by reference", () => { + class ModuleDefaults { + constructor () { + this.enabled = true; + } + } + + const expected = new ModuleDefaults(); + const obj = clone(expected); + expect(obj).toBe(expected); + }); + + it("should clone undefined", () => { + const expected = undefined; + const obj = clone(expected); + expect(obj).toBe(expected); + }); + + it("should clone null", () => { + const expected = null; + const obj = clone(expected); + expect(obj).toBe(expected); + }); + + it("should clone nested object", () => { + const expected = { + name: "fewieden", + link: "https://github.com/fewieden", + versions: ["2.0", "2.1", "2.2"], + answerForAllQuestions: 42, + properties: { + items: [{ foo: "bar" }, { lorem: "ipsum" }], + invalid: undefined, + nothing: null + } + }; + const obj = clone(expected); + expect(obj).toEqual(expected); + expect(expected === obj).toBe(false); + expect(expected.versions === obj.versions).toBe(false); + expect(expected.properties === obj.properties).toBe(false); + expect(expected.properties.items === obj.properties.items).toBe(false); + expect(expected.properties.items[0] === obj.properties.items[0]).toBe(false); + expect(expected.properties.items[1] === obj.properties.items[1]).toBe(false); + }); + + describe("Test Module.create", () => { + let info; + + beforeEach(() => { + info = global.Log.info; + }); + + afterEach(() => { + global.Log.info = info; + Module.definitions = {}; + }); + + it("should create module instance with dynamic class name", () => { + const moduleName = "MMM-TestModule"; + Module.register(moduleName, { + defaults: {} + }); + + const moduleInstance = Module.create(moduleName); + + expect(moduleInstance.constructor.name).toBe(moduleName); + }); + + it("should use fallback class name for empty module name", () => { + const moduleName = ""; + Module.register(moduleName, { + defaults: {} + }); + + const moduleInstance = Module.create(moduleName); + + expect(moduleInstance.constructor.name).toBe("AnonymousModule"); + }); + + it("should not throw when init is not a function", () => { + const moduleName = "MMM-TestModuleNoInitFunction"; + Module.register(moduleName, { + init: null, + defaults: {} + }); + + expect(() => Module.create(moduleName)).not.toThrow(); + }); + + it("should support lifecycle super call pattern", () => { + const moduleName = "MMM-TestSuperCall"; + let loggedMessage; + + global.Log.info = (message) => { + loggedMessage = message; + }; + + Module.register(moduleName, { + defaults: {}, + start () { + this.didStart = true; + Module.prototype.start.call(this); + } + }); + + const moduleInstance = Module.create(moduleName); + moduleInstance.name = moduleName; + moduleInstance.start(); + + expect(moduleInstance.didStart).toBe(true); + expect(loggedMessage).toBe(`Starting module: ${moduleName}`); + }); + + it("should set config when defaults are undefined", () => { + const moduleName = "MMM-TestNoDefaults"; + Module.register(moduleName, {}); + + const moduleInstance = Module.create(moduleName); + + moduleInstance.setConfig({ foo: "bar" }, false); + expect(moduleInstance.config).toEqual({ foo: "bar" }); + + moduleInstance.setConfig({ nested: { value: 1 } }, true); + expect(moduleInstance.config).toEqual({ nested: { value: 1 } }); + }); + + it("should initialize lifecycle fields in setData", () => { + const moduleName = "MMM-TestSetData"; + Module.register(moduleName, { + defaults: { fromDefaults: true } + }); + + const moduleInstance = Module.create(moduleName); + moduleInstance.setData({ + name: moduleName, + identifier: "module_1", + config: { fromConfig: true }, + configDeepMerge: false + }); + + expect(moduleInstance.name).toBe(moduleName); + expect(moduleInstance.identifier).toBe("module_1"); + expect(moduleInstance.hidden).toBe(false); + expect(moduleInstance.hasAnimateIn).toBe(false); + expect(moduleInstance.hasAnimateOut).toBe(false); + expect(moduleInstance.config).toEqual({ fromDefaults: true, fromConfig: true }); + }); + + it("should not share defaults object across module instances", () => { + const moduleName = "MMM-TestDefaultsIsolation"; + Module.register(moduleName, { + defaults: { + nested: { value: 1 }, + list: [1] + } + }); + + const firstModuleInstance = Module.create(moduleName); + const secondModuleInstance = Module.create(moduleName); + + firstModuleInstance.defaults.nested.value = 42; + firstModuleInstance.defaults.list.push(2); + + expect(secondModuleInstance.defaults).toEqual({ + nested: { value: 1 }, + list: [1] + }); + }); + }); + }); +}); diff --git a/tests/unit/classes/systeminformation_spec.js b/tests/unit/classes/systeminformation_spec.js new file mode 100644 index 0000000..d9528c5 --- /dev/null +++ b/tests/unit/classes/systeminformation_spec.js @@ -0,0 +1,7 @@ +const SystemInformation = require("../../../js/systeminformation"); + +describe("SystemInformation", () => { + it("should output system information", async () => { + await expect(SystemInformation()).resolves.toContain("platform: linux"); + }); +}); diff --git a/tests/unit/classes/translator_spec.js b/tests/unit/classes/translator_spec.js new file mode 100644 index 0000000..2f4de65 --- /dev/null +++ b/tests/unit/classes/translator_spec.js @@ -0,0 +1,230 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const helmet = require("helmet"); +const { JSDOM } = require("jsdom"); +const express = require("express"); + +/** + * Helper function to create a fresh Translator instance with DOM environment. + * @returns {object} Object containing window and Translator + */ +function createTranslationTestEnvironment () { + const translatorJs = fs.readFileSync(path.join(__dirname, "..", "..", "..", "js", "translator.js"), "utf-8"); + const dom = new JSDOM("", { url: "http://localhost:3001", runScripts: "outside-only" }); + + dom.window.Log = { log: vi.fn(), error: vi.fn() }; + dom.window.fetch = fetch; + dom.window.eval(translatorJs); + + return { window: dom.window, Translator: dom.window.Translator }; +} + +describe("Translator", () => { + let server; + const sockets = new Set(); + const translationTestData = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "..", "tests", "mocks", "translation_test.json"), "utf8")); + + 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, "..", "..", "..", "tests", "mocks"))); + + server = app.listen(3001); + + server.on("connection", (socket) => { + sockets.add(socket); + }); + }); + + afterAll(async () => { + for (const socket of sockets) { + socket.destroy(); + sockets.delete(socket); + } + + await server.close(); + }); + + describe("translate", () => { + const translations = { + "MMM-Module": { + Hello: "Hallo", + "Hello {username}": "Hallo {username}" + } + }; + + const coreTranslations = { + Hello: "XXX", + "Hello {username}": "XXX", + FOO: "Foo", + "BAR {something}": "Bar {something}" + }; + + const translationsFallback = { + "MMM-Module": { + Hello: "XXX", + "Hello {username}": "XXX", + FOO: "XXX", + "BAR {something}": "XXX", + "A key": "A translation" + } + }; + + const coreTranslationsFallback = { + FOO: "XXX", + "BAR {something}": "XXX", + Hello: "XXX", + "Hello {username}": "XXX", + "A key": "XXX", + Fallback: "core fallback" + }; + + /** + * @param {object} Translator the global Translator object + */ + const setTranslations = (Translator) => { + Translator.translations = translations; + Translator.coreTranslations = coreTranslations; + Translator.translationsFallback = translationsFallback; + Translator.coreTranslationsFallback = coreTranslationsFallback; + }; + + it("should return custom module translation", () => { + const { Translator } = createTranslationTestEnvironment(); + setTranslations(Translator); + + let translation = Translator.translate({ name: "MMM-Module" }, "Hello"); + expect(translation).toBe("Hallo"); + + translation = Translator.translate({ name: "MMM-Module" }, "Hello {username}", { username: "fewieden" }); + expect(translation).toBe("Hallo fewieden"); + }); + + it("should return core translation", () => { + const { Translator } = createTranslationTestEnvironment(); + setTranslations(Translator); + let translation = Translator.translate({ name: "MMM-Module" }, "FOO"); + expect(translation).toBe("Foo"); + translation = Translator.translate({ name: "MMM-Module" }, "BAR {something}", { something: "Lorem Ipsum" }); + expect(translation).toBe("Bar Lorem Ipsum"); + }); + + it("should return custom module translation fallback", () => { + const { Translator } = createTranslationTestEnvironment(); + setTranslations(Translator); + const translation = Translator.translate({ name: "MMM-Module" }, "A key"); + expect(translation).toBe("A translation"); + }); + + it("should return core translation fallback", () => { + const { Translator } = createTranslationTestEnvironment(); + setTranslations(Translator); + const translation = Translator.translate({ name: "MMM-Module" }, "Fallback"); + expect(translation).toBe("core fallback"); + }); + + it("should return translation with placeholder for missing variables", () => { + const { Translator } = createTranslationTestEnvironment(); + setTranslations(Translator); + const translation = Translator.translate({ name: "MMM-Module" }, "Hello {username}"); + expect(translation).toBe("Hallo {username}"); + }); + + it("should return key if no translation was found", () => { + const { Translator } = createTranslationTestEnvironment(); + setTranslations(Translator); + const translation = Translator.translate({ name: "MMM-Module" }, "MISSING"); + expect(translation).toBe("MISSING"); + }); + }); + + describe("load", () => { + const mmm = { + name: "TranslationTest", + file (file) { + return `http://localhost:3001/translations/${file}`; + } + }; + + it("should load translations", async () => { + const { Translator } = createTranslationTestEnvironment(); + const file = "translation_test.json"; + + await Translator.load(mmm, file, false); + const json = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "..", "tests", "mocks", file), "utf8")); + expect(Translator.translations[mmm.name]).toEqual(json); + }); + + it("should load translation fallbacks", async () => { + const { Translator } = createTranslationTestEnvironment(); + const file = "translation_test.json"; + + await Translator.load(mmm, file, true); + const json = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "..", "tests", "mocks", file), "utf8")); + expect(Translator.translationsFallback[mmm.name]).toEqual(json); + }); + + it("should not load translations, if module fallback exists", async () => { + const { Translator } = createTranslationTestEnvironment(); + const file = "translation_test.json"; + + Translator.translationsFallback[mmm.name] = { + Hello: "Hallo" + }; + + await Translator.load(mmm, file, false); + expect(Translator.translations[mmm.name]).toBeUndefined(); + expect(Translator.translationsFallback[mmm.name]).toEqual({ + Hello: "Hallo" + }); + }); + }); + + describe("loadCoreTranslations", () => { + it("should load core translations and fallback", async () => { + const { window, Translator } = createTranslationTestEnvironment(); + window.translations = { en: "http://localhost:3001/translations/translation_test.json" }; + await Translator.loadCoreTranslations("en"); + + const en = translationTestData; + + expect(Translator.coreTranslations).toEqual(en); + expect(Translator.coreTranslationsFallback).toEqual(en); + }); + + it("should load core fallback if language cannot be found", async () => { + const { window, Translator } = createTranslationTestEnvironment(); + window.translations = { en: "http://localhost:3001/translations/translation_test.json" }; + await Translator.loadCoreTranslations("MISSINGLANG"); + + const en = translationTestData; + + expect(Translator.coreTranslations).toEqual({}); + expect(Translator.coreTranslationsFallback).toEqual(en); + }); + }); + + describe("loadCoreTranslationsFallback", () => { + it("should load core translations fallback", async () => { + const { window, Translator } = createTranslationTestEnvironment(); + window.translations = { en: "http://localhost:3001/translations/translation_test.json" }; + await Translator.loadCoreTranslationsFallback(); + + const en = translationTestData; + + expect(Translator.coreTranslationsFallback).toEqual(en); + }); + + it("should load core fallback if language cannot be found", async () => { + const { window, Translator } = createTranslationTestEnvironment(); + window.translations = {}; + await Translator.loadCoreTranslations(); + + expect(Translator.coreTranslationsFallback).toEqual({}); + }); + }); +}); diff --git a/tests/unit/classes/utils_spec.js b/tests/unit/classes/utils_spec.js new file mode 100644 index 0000000..ee90e88 --- /dev/null +++ b/tests/unit/classes/utils_spec.js @@ -0,0 +1,83 @@ +const fs = require("node:fs"); + +const Log = require("../../../js/logger"); +const { checkConfigFile, ConfigError } = require("../../../js/utils"); + +const createConfigObject = (modules) => ({ + configFilename: "config.js", + configContentFull: "module.exports = { modules: [] };", + fullConf: { modules } +}); + +const runCheck = (modules) => { + checkConfigFile(createConfigObject(modules)); +}; + +const expectExitForModules = (modules) => { + vi.spyOn(process, "exit").mockImplementation(() => { + throw new ConfigError(""); + }); + + expect(() => { + runCheck(modules); + }).toThrow(ConfigError); +}; + +describe("utils", () => { + let originalReadFileSync; + + beforeEach(() => { + originalReadFileSync = fs.readFileSync; + + vi.spyOn(fs, "readFileSync").mockImplementation((fileName, ...args) => { + if (fileName === "index.html") { + return "
\n
"; + } + + return originalReadFileSync.call(fs, fileName, ...args); + }); + + vi.spyOn(fs, "writeFileSync").mockImplementation(() => {}); + vi.spyOn(Log, "info").mockImplementation(() => {}); + vi.spyOn(Log, "warn").mockImplementation(() => {}); + vi.spyOn(Log, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("accepts valid module entries", () => { + expect(() => { + runCheck([ + { module: "clock", position: "top_bar" }, + { module: "newsfeed" } + ]); + }).not.toThrow(); + expect(Log.error).not.toHaveBeenCalled(); + }); + + it("exits when modules is not an array", () => { + expectExitForModules("not-an-array"); + expect(Log.error).toHaveBeenCalledWith("This module configuration contains errors:\nmodules must be an array"); + }); + + it("exits when module field is missing or not a string", () => { + expectExitForModules([{ module: 123, position: "top_bar" }]); + expect(Log.error).toHaveBeenCalled(); + expect(Log.error.mock.calls[0][0]).toContain("module: must be a string"); + }); + + it("warns for unknown positions without exiting", () => { + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new ConfigError(""); + }); + + expect(() => { + runCheck([{ module: "clock", position: "made_up_region" }]); + }).not.toThrow(); + expect(exitSpy).not.toHaveBeenCalled(); + expect(Log.warn).toHaveBeenCalled(); + expect(Log.warn.mock.calls[0][0]).toContain("uses unknown position"); + }); +}); diff --git a/tests/unit/functions/__snapshots__/updatenotification_spec.js.snap b/tests/unit/functions/__snapshots__/updatenotification_spec.js.snap new file mode 100644 index 0000000..b700ce5 --- /dev/null +++ b/tests/unit/functions/__snapshots__/updatenotification_spec.js.snap @@ -0,0 +1,100 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Updatenotification > MagicMirror on develop > returns status information 1`] = ` +{ + "behind": 5, + "current": "develop", + "hash": "332e429a41f1a2339afd4f0ae96dd125da6beada", + "isBehindInStatus": false, + "module": "MagicMirror", + "tracking": "origin/develop", +} +`; + +exports[`Updatenotification > MagicMirror on develop > returns status information early if isBehindInStatus 1`] = ` +{ + "behind": 5, + "current": "develop", + "hash": "332e429a41f1a2339afd4f0ae96dd125da6beada", + "isBehindInStatus": true, + "module": "MagicMirror", + "tracking": "origin/develop", +} +`; + +exports[`Updatenotification > MagicMirror on master (empty taglist) > returns status information 1`] = ` +{ + "behind": 5, + "current": "master", + "hash": "332e429a41f1a2339afd4f0ae96dd125da6beada", + "isBehindInStatus": false, + "module": "MagicMirror", + "tracking": "origin/master", +} +`; + +exports[`Updatenotification > MagicMirror on master (empty taglist) > returns status information early if isBehindInStatus 1`] = ` +{ + "behind": 5, + "current": "master", + "hash": "332e429a41f1a2339afd4f0ae96dd125da6beada", + "isBehindInStatus": true, + "module": "MagicMirror", + "tracking": "origin/master", +} +`; + +exports[`Updatenotification > MagicMirror on master with match in taglist > returns status information 1`] = ` +{ + "behind": 5, + "current": "master", + "hash": "332e429a41f1a2339afd4f0ae96dd125da6beada", + "isBehindInStatus": false, + "module": "MagicMirror", + "tracking": "origin/master", +} +`; + +exports[`Updatenotification > MagicMirror on master with match in taglist > returns status information early if isBehindInStatus 1`] = ` +{ + "behind": 5, + "current": "master", + "hash": "332e429a41f1a2339afd4f0ae96dd125da6beada", + "isBehindInStatus": true, + "module": "MagicMirror", + "tracking": "origin/master", +} +`; + +exports[`Updatenotification > MagicMirror on master without match in taglist > returns status information 1`] = ` +{ + "behind": 0, + "current": "master", + "hash": "332e429a41f1a2339afd4f0ae96dd125da6beada", + "isBehindInStatus": false, + "module": "MagicMirror", + "tracking": "origin/master", +} +`; + +exports[`Updatenotification > MagicMirror on master without match in taglist > returns status information early if isBehindInStatus 1`] = ` +{ + "behind": 0, + "current": "master", + "hash": "332e429a41f1a2339afd4f0ae96dd125da6beada", + "isBehindInStatus": true, + "module": "MagicMirror", + "tracking": "origin/master", +} +`; + +exports[`Updatenotification > custom module > returns status information without hash 1`] = ` +{ + "behind": 7, + "current": "master", + "hash": "", + "isBehindInStatus": false, + "module": "MMM-Fuel", + "tracking": "origin/master", +} +`; diff --git a/tests/unit/functions/cmp_versions_spec.js b/tests/unit/functions/cmp_versions_spec.js new file mode 100644 index 0000000..a0cc43b --- /dev/null +++ b/tests/unit/functions/cmp_versions_spec.js @@ -0,0 +1,71 @@ +const path = require("node:path"); +const { pathToFileURL } = require("node:url"); + +describe("Test function cmpVersions in js/module.js", () => { + let cmp; + let originalWindow; + let originalLog; + let originalConfig; + let originalMM; + let originalTranslator; + let originalNunjucks; + + beforeAll(async () => { + originalWindow = global.window; + originalLog = global.Log; + originalConfig = global.config; + originalMM = global.MM; + originalTranslator = global.Translator; + originalNunjucks = global.nunjucks; + + global.window = { mmVersion: "2.0.0" }; + global.Log = { log: () => {}, info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; + global.config = { language: "en" }; + global.MM = { + hideModule: () => {}, + showModule: () => {}, + sendNotification: () => {}, + updateDom: () => {} + }; + global.Translator = { + load: () => Promise.resolve(), + translate: () => "" + }; + global.nunjucks = { + Environment () { + this.addFilter = () => {}; + this.renderString = () => ""; + this.render = (_template, _data, callback) => callback(null, ""); + }, + WebLoader () {}, + runtime: { + markSafe: (str) => str + } + }; + + const modulePath = pathToFileURL(path.join(__dirname, "..", "..", "..", "js", "module.js")).href; + const loaded = await import(`${modulePath}?test=${Date.now()}`); + cmp = loaded.cmpVersions; + }); + + afterAll(() => { + global.window = originalWindow; + global.Log = originalLog; + global.config = originalConfig; + global.MM = originalMM; + global.Translator = originalTranslator; + global.nunjucks = originalNunjucks; + }); + + it("should return -1 when comparing 2.1 to 2.2", () => { + expect(cmp("2.1", "2.2")).toBe(-1); + }); + + it("should be return 0 when comparing 2.2 to 2.2", () => { + expect(cmp("2.2", "2.2")).toBe(0); + }); + + it("should be return 1 when comparing 1.1 to 1.0", () => { + expect(cmp("1.1", "1.0")).toBe(1); + }); +}); diff --git a/tests/unit/functions/electron_helper_spec.js b/tests/unit/functions/electron_helper_spec.js new file mode 100644 index 0000000..ff2c092 --- /dev/null +++ b/tests/unit/functions/electron_helper_spec.js @@ -0,0 +1,72 @@ +const Log = require("logger"); +const { applyElectronSwitches } = require("../../../js/electron_helper"); + +describe("electron switches", () => { + let commandLine; + + beforeEach(() => { + commandLine = { + appendSwitch: vi.fn() + }; + vi.spyOn(Log, "error").mockImplementation(() => {}); + }); + + it("does nothing when electronSwitches is undefined", () => { + applyElectronSwitches(commandLine, undefined); + + expect(commandLine.appendSwitch).not.toHaveBeenCalled(); + expect(Log.error).not.toHaveBeenCalled(); + }); + + it("applies string entries as switches without values", () => { + applyElectronSwitches(commandLine, ["no-sandbox", "disable-http-cache"]); + + expect(commandLine.appendSwitch).toHaveBeenCalledTimes(2); + expect(commandLine.appendSwitch).toHaveBeenNthCalledWith(1, "no-sandbox"); + expect(commandLine.appendSwitch).toHaveBeenNthCalledWith(2, "disable-http-cache"); + expect(Log.error).not.toHaveBeenCalled(); + }); + + it("applies object entries as switches with values", () => { + applyElectronSwitches(commandLine, [ + { "js-flags": "--max-old-space-size=8192" }, + { "password-store": "basic" } + ]); + + expect(commandLine.appendSwitch).toHaveBeenCalledTimes(2); + expect(commandLine.appendSwitch).toHaveBeenNthCalledWith(1, "js-flags", "--max-old-space-size=8192"); + expect(commandLine.appendSwitch).toHaveBeenNthCalledWith(2, "password-store", "basic"); + expect(Log.error).not.toHaveBeenCalled(); + }); + + it("allows one object entry to define multiple switches with values", () => { + applyElectronSwitches(commandLine, [ + "no-sandbox", + { + "js-flags": "--max-old-space-size=8192", + "password-store": "basic" + } + ]); + + expect(commandLine.appendSwitch).toHaveBeenCalledTimes(3); + expect(commandLine.appendSwitch).toHaveBeenNthCalledWith(1, "no-sandbox"); + expect(commandLine.appendSwitch).toHaveBeenNthCalledWith(2, "js-flags", "--max-old-space-size=8192"); + expect(commandLine.appendSwitch).toHaveBeenNthCalledWith(3, "password-store", "basic"); + expect(Log.error).not.toHaveBeenCalled(); + }); + + it("logs an error for invalid entries", () => { + applyElectronSwitches(commandLine, ["no-sandbox", ["js-flags", "--max-old-space-size=8192"], null]); + + expect(commandLine.appendSwitch).toHaveBeenCalledTimes(1); + expect(commandLine.appendSwitch).toHaveBeenCalledWith("no-sandbox"); + expect(Log.error).toHaveBeenCalledTimes(2); + }); + + it("logs an error when electronSwitches is not an array", () => { + applyElectronSwitches(commandLine, { "js-flags": "--max-old-space-size=8192" }); + + expect(commandLine.appendSwitch).not.toHaveBeenCalled(); + expect(Log.error).toHaveBeenCalledWith(expect.stringContaining("electronSwitches must be an array of strings or objects")); + }); +}); diff --git a/tests/unit/functions/http_fetcher_spec.js b/tests/unit/functions/http_fetcher_spec.js new file mode 100644 index 0000000..5162fb7 --- /dev/null +++ b/tests/unit/functions/http_fetcher_spec.js @@ -0,0 +1,579 @@ +const { http, HttpResponse } = require("msw"); +const { setupServer } = require("msw/node"); +const HTTPFetcher = require("#http_fetcher"); + +const TEST_URL = "http://test.example.com/data"; +let server; +let fetcher; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "error" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); + if (fetcher) { + fetcher.clearTimer(); + fetcher = null; + } +}); + +describe("HTTPFetcher", () => { + + describe("Basic fetch operations", () => { + it("should emit response event on successful fetch", async () => { + const responseData = "test data"; + server.use( + http.get(TEST_URL, () => { + return HttpResponse.text(responseData); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 }); + + const responsePromise = new Promise((resolve) => { + fetcher.on("response", (response) => { + resolve(response); + }); + }); + + fetcher.startPeriodicFetch(); + const response = await responsePromise; + + expect(response.ok).toBe(true); + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).toBe(responseData); + }); + + it("should emit error event on network failure", async () => { + server.use( + http.get(TEST_URL, () => { + return HttpResponse.error(); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 }); + + const errorPromise = new Promise((resolve) => { + fetcher.on("error", (errorInfo) => { + resolve(errorInfo); + }); + }); + + fetcher.startPeriodicFetch(); + const errorInfo = await errorPromise; + + expect(errorInfo).toHaveProperty("errorType", "NETWORK_ERROR"); + expect(errorInfo).toHaveProperty("translationKey", "MODULE_ERROR_NO_CONNECTION"); + expect(errorInfo).toHaveProperty("url", TEST_URL); + }); + + it("should emit error event on timeout", async () => { + server.use( + http.get(TEST_URL, async () => { + // Simulate a slow server that never responds + await new Promise((resolve) => setTimeout(resolve, 60000)); + return HttpResponse.text("too late"); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000, timeout: 100 }); + + const errorPromise = new Promise((resolve) => { + fetcher.on("error", (errorInfo) => { + resolve(errorInfo); + }); + }); + + fetcher.startPeriodicFetch(); + const errorInfo = await errorPromise; + + expect(errorInfo.errorType).toBe("NETWORK_ERROR"); + expect(errorInfo.message).toContain("timeout"); + expect(errorInfo.message).toContain("100ms"); + }); + }); + + describe("HTTPFetcher - HTTP status code handling", () => { + describe("304 Not Modified", () => { + it("should emit response event for 304 and not emit error", async () => { + server.use( + http.get(TEST_URL, () => { + return new HttpResponse(null, { status: 304 }); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 }); + fetcher.serverErrorCount = 2; + fetcher.networkErrorCount = 3; + + const responsePromise = new Promise((resolve) => { + fetcher.on("response", resolve); + }); + const errorSpy = vi.fn(); + fetcher.on("error", errorSpy); + + fetcher.startPeriodicFetch(); + const response = await responsePromise; + + expect(response.status).toBe(304); + expect(errorSpy).not.toHaveBeenCalled(); + expect(fetcher.serverErrorCount).toBe(0); + expect(fetcher.networkErrorCount).toBe(0); + }); + }); + + describe("401/403 errors (Auth failures)", () => { + it("should emit error with AUTH_FAILURE for 401", async () => { + server.use( + http.get(TEST_URL, () => { + return new HttpResponse(null, { status: 401 }); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 }); + + const errorPromise = new Promise((resolve) => { + fetcher.on("error", (errorInfo) => { + resolve(errorInfo); + }); + }); + + fetcher.startPeriodicFetch(); + const errorInfo = await errorPromise; + + expect(errorInfo.status).toBe(401); + expect(errorInfo.errorType).toBe("AUTH_FAILURE"); + expect(errorInfo.translationKey).toBe("MODULE_ERROR_UNAUTHORIZED"); + }); + + it("should emit error with AUTH_FAILURE for 403", async () => { + server.use( + http.get(TEST_URL, () => { + return new HttpResponse(null, { status: 403 }); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 }); + + const errorPromise = new Promise((resolve) => { + fetcher.on("error", (errorInfo) => { + resolve(errorInfo); + }); + }); + + fetcher.startPeriodicFetch(); + const errorInfo = await errorPromise; + + expect(errorInfo.status).toBe(403); + expect(errorInfo.errorType).toBe("AUTH_FAILURE"); + }); + }); + + describe("429 errors (Rate limiting)", () => { + it("should emit error with RATE_LIMITED for 429", async () => { + server.use( + http.get(TEST_URL, () => { + return new HttpResponse(null, { + status: 429, + headers: { "Retry-After": "120" } + }); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 }); + + const errorPromise = new Promise((resolve) => { + fetcher.on("error", (errorInfo) => { + resolve(errorInfo); + }); + }); + + fetcher.startPeriodicFetch(); + const errorInfo = await errorPromise; + + expect(errorInfo.status).toBe(429); + expect(errorInfo.errorType).toBe("RATE_LIMITED"); + expect(errorInfo.retryAfter).toBeGreaterThan(0); + }); + + it("should parse Retry-After header in seconds", async () => { + server.use( + http.get(TEST_URL, () => { + return new HttpResponse(null, { + status: 429, + headers: { "Retry-After": "300" } + }); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 }); + + const errorPromise = new Promise((resolve) => { + fetcher.on("error", (errorInfo) => { + resolve(errorInfo); + }); + }); + + fetcher.startPeriodicFetch(); + const errorInfo = await errorPromise; + + // 300 seconds = 300000 ms + expect(errorInfo.retryAfter).toBe(300000); + }); + }); + + describe("5xx errors (Server errors)", () => { + it("should emit error with SERVER_ERROR for 500", async () => { + server.use( + http.get(TEST_URL, () => { + return new HttpResponse(null, { status: 500 }); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 }); + + const errorPromise = new Promise((resolve) => { + fetcher.on("error", (errorInfo) => { + resolve(errorInfo); + }); + }); + + fetcher.startPeriodicFetch(); + const errorInfo = await errorPromise; + + expect(errorInfo.status).toBe(500); + expect(errorInfo.errorType).toBe("SERVER_ERROR"); + }); + + it("should emit error with SERVER_ERROR for 503", async () => { + server.use( + http.get(TEST_URL, () => { + return new HttpResponse(null, { status: 503 }); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 }); + + const errorPromise = new Promise((resolve) => { + fetcher.on("error", (errorInfo) => { + resolve(errorInfo); + }); + }); + + fetcher.startPeriodicFetch(); + const errorInfo = await errorPromise; + + expect(errorInfo.status).toBe(503); + expect(errorInfo.errorType).toBe("SERVER_ERROR"); + }); + }); + + describe("4xx errors (Client errors)", () => { + it("should emit error with CLIENT_ERROR for 404", async () => { + server.use( + http.get(TEST_URL, () => { + return new HttpResponse(null, { status: 404 }); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 }); + + const errorPromise = new Promise((resolve) => { + fetcher.on("error", (errorInfo) => { + resolve(errorInfo); + }); + }); + + fetcher.startPeriodicFetch(); + const errorInfo = await errorPromise; + + expect(errorInfo.status).toBe(404); + expect(errorInfo.errorType).toBe("CLIENT_ERROR"); + }); + }); + }); +}); + +describe("HTTPFetcher - Authentication", () => { + it("should include Basic auth header when configured", async () => { + let receivedHeaders = null; + + server.use( + http.get(TEST_URL, ({ request }) => { + receivedHeaders = Object.fromEntries(request.headers); + return HttpResponse.text("ok"); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { + reloadInterval: 60000, + auth: { + method: "basic", + user: "testuser", + pass: "testpass" + } + }); + + const responsePromise = new Promise((resolve) => { + fetcher.on("response", resolve); + }); + + fetcher.startPeriodicFetch(); + await responsePromise; + + const expectedAuth = `Basic ${Buffer.from("testuser:testpass").toString("base64")}`; + expect(receivedHeaders.authorization).toBe(expectedAuth); + }); + + it("should include Bearer auth header when configured", async () => { + let receivedHeaders = null; + + server.use( + http.get(TEST_URL, ({ request }) => { + receivedHeaders = Object.fromEntries(request.headers); + return HttpResponse.text("ok"); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { + reloadInterval: 60000, + auth: { + method: "bearer", + pass: "my-token-123" + } + }); + + const responsePromise = new Promise((resolve) => { + fetcher.on("response", resolve); + }); + + fetcher.startPeriodicFetch(); + await responsePromise; + + expect(receivedHeaders.authorization).toBe("Bearer my-token-123"); + }); +}); + +describe("Custom headers", () => { + it("should include custom headers in request", async () => { + let receivedHeaders = null; + + server.use( + http.get(TEST_URL, ({ request }) => { + receivedHeaders = Object.fromEntries(request.headers); + return HttpResponse.text("ok"); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { + reloadInterval: 60000, + headers: { + "X-Custom-Header": "custom-value", + Accept: "application/json" + } + }); + + const responsePromise = new Promise((resolve) => { + fetcher.on("response", resolve); + }); + + fetcher.startPeriodicFetch(); + await responsePromise; + + expect(receivedHeaders["x-custom-header"]).toBe("custom-value"); + expect(receivedHeaders.accept).toBe("application/json"); + }); +}); + +describe("Timer management", () => { + it("should not set timer in test mode", async () => { + server.use( + http.get(TEST_URL, () => { + return HttpResponse.text("ok"); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 100 }); + + const responsePromise = new Promise((resolve) => { + fetcher.on("response", resolve); + }); + + fetcher.startPeriodicFetch(); + await responsePromise; + + // Timer should NOT be set in test mode (mmTestMode=true) + expect(fetcher.reloadTimer).toBeNull(); + }); + + it("should clear timer when clearTimer is called", () => { + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 100 }); + + // Manually set a timer to test clearing + fetcher.reloadTimer = setTimeout(() => {}, 10000); + expect(fetcher.reloadTimer).not.toBeNull(); + + fetcher.clearTimer(); + + expect(fetcher.reloadTimer).toBeNull(); + }); +}); + +describe("fetch() method", () => { + it("should emit response event when called", async () => { + const responseData = "direct fetch data"; + server.use( + http.get(TEST_URL, () => { + return HttpResponse.text(responseData); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 }); + + const responsePromise = new Promise((resolve) => { + fetcher.on("response", resolve); + }); + + await fetcher.fetch(); + const response = await responsePromise; + + expect(response.ok).toBe(true); + const text = await response.text(); + expect(text).toBe(responseData); + }); + + it("should emit error event on network error", async () => { + server.use( + http.get(TEST_URL, () => { + return HttpResponse.error(); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 60000 }); + + const errorPromise = new Promise((resolve) => { + fetcher.on("error", resolve); + }); + + await fetcher.fetch(); + const errorInfo = await errorPromise; + + expect(errorInfo.errorType).toBe("NETWORK_ERROR"); + }); +}); + +describe("selfSignedCert dispatcher", () => { + const { Agent } = require("undici"); + + it("should set rejectUnauthorized=false when selfSignedCert is true", () => { + fetcher = new HTTPFetcher(TEST_URL, { + reloadInterval: 60000, + selfSignedCert: true + }); + + const options = fetcher.getRequestOptions(); + + expect(options.dispatcher).toBeInstanceOf(Agent); + const agentOptionsSymbol = Object.getOwnPropertySymbols(options.dispatcher).find((s) => s.description === "options"); + const dispatcherOptions = options.dispatcher[agentOptionsSymbol]; + expect(dispatcherOptions.connect.rejectUnauthorized).toBe(false); + }); + + it("should not set a dispatcher when selfSignedCert is false", () => { + fetcher = new HTTPFetcher(TEST_URL, { + reloadInterval: 60000, + selfSignedCert: false + }); + + const options = fetcher.getRequestOptions(); + + expect(options.dispatcher).toBeUndefined(); + }); +}); + +describe("Retry exhaustion fallback", () => { + it("should fall back to reloadInterval after network retries exhausted", async () => { + server.use( + http.get(TEST_URL, () => { + return HttpResponse.error(); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 300000, maxRetries: 3 }); + + const errors = []; + fetcher.on("error", (errorInfo) => errors.push(errorInfo)); + + // Trigger maxRetries + 1 fetches to reach exhaustion + for (let i = 0; i < 4; i++) { + await fetcher.fetch(); + } + + // First retries should use backoff (< reloadInterval) + expect(errors[0].retryAfter).toBe(15000); + expect(errors[1].retryAfter).toBe(30000); + // Third retry hits maxRetries, should fall back to reloadInterval + expect(errors[2].retryAfter).toBe(300000); + // Subsequent errors stay at reloadInterval + expect(errors[3].retryAfter).toBe(300000); + }); + + it("should fall back to reloadInterval after server error retries exhausted", async () => { + server.use( + http.get(TEST_URL, () => { + return new HttpResponse(null, { status: 503 }); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 300000, maxRetries: 3 }); + + const errors = []; + fetcher.on("error", (errorInfo) => errors.push(errorInfo)); + + for (let i = 0; i < 4; i++) { + await fetcher.fetch(); + } + + // First retries should use backoff (< reloadInterval) + expect(errors[0].retryAfter).toBe(15000); + expect(errors[1].retryAfter).toBe(30000); + // Third retry hits maxRetries, should fall back to reloadInterval + expect(errors[2].retryAfter).toBe(300000); + // Subsequent errors stay at reloadInterval + expect(errors[3].retryAfter).toBe(300000); + }); + + it("should reset network error count on success", async () => { + let requestCount = 0; + server.use( + http.get(TEST_URL, () => { + requestCount++; + if (requestCount <= 2) return HttpResponse.error(); + return HttpResponse.text("ok"); + }) + ); + + fetcher = new HTTPFetcher(TEST_URL, { reloadInterval: 300000, maxRetries: 3 }); + + const errors = []; + fetcher.on("error", (errorInfo) => errors.push(errorInfo)); + + // Two failures with backoff + await fetcher.fetch(); + await fetcher.fetch(); + expect(errors).toHaveLength(2); + expect(errors[0].retryAfter).toBe(15000); + expect(errors[1].retryAfter).toBe(30000); + + // Success resets counter + await fetcher.fetch(); + expect(fetcher.networkErrorCount).toBe(0); + }); +}); diff --git a/tests/unit/functions/ip_access_control_spec.js b/tests/unit/functions/ip_access_control_spec.js new file mode 100644 index 0000000..fa6e1a6 --- /dev/null +++ b/tests/unit/functions/ip_access_control_spec.js @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; + +import { ipAccessControl, socketIpAccessControl } from "../../../js/ip_access_control"; + +/** + * Creates a minimal Express-like response mock used by the middleware tests. + * @returns {{ header: ReturnType, status: ReturnType, send: ReturnType }} Mock response object. + */ +function createResponseMock () { + return { + header: vi.fn(), + status: vi.fn(function () { + return this; + }), + send: vi.fn() + }; +} + +describe("ip_access_control", () => { + describe("ipAccessControl", () => { + it("trusts first X-Forwarded-For entry when direct peer is loopback", () => { + const middleware = ipAccessControl(["203.0.113.10"]); + const req = { + socket: { remoteAddress: "127.0.0.1" }, + headers: { "x-forwarded-for": "203.0.113.10, 10.0.0.2" } + }; + const res = createResponseMock(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it("ignores X-Forwarded-For when direct peer is not loopback", () => { + const middleware = ipAccessControl(["203.0.113.10"]); + const req = { + socket: { remoteAddress: "198.51.100.7" }, + headers: { "x-forwarded-for": "203.0.113.10" } + }; + const res = createResponseMock(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + }); + + describe("socketIpAccessControl", () => { + it("accepts socket handshake using forwarded client IP when direct peer is loopback", () => { + const allowRequest = socketIpAccessControl(["203.0.113.10"]); + const req = { + socket: { remoteAddress: "::1" }, + headers: { "x-forwarded-for": "203.0.113.10, 10.0.0.2" } + }; + const callback = vi.fn(); + + allowRequest(req, callback); + + expect(callback).toHaveBeenCalledWith(null, true); + }); + + it("rejects socket handshake when only forwarded IP matches whitelist", () => { + const allowRequest = socketIpAccessControl(["203.0.113.10"]); + const req = { + socket: { remoteAddress: "198.51.100.7" }, + headers: { "x-forwarded-for": "203.0.113.10" } + }; + const callback = vi.fn(); + + allowRequest(req, callback); + + expect(callback).toHaveBeenCalledWith("This device is not allowed to access your mirror.", false); + }); + }); +}); diff --git a/tests/unit/functions/server_functions_spec.js b/tests/unit/functions/server_functions_spec.js new file mode 100644 index 0000000..cd43eaf --- /dev/null +++ b/tests/unit/functions/server_functions_spec.js @@ -0,0 +1,331 @@ +// Tests use vi.spyOn on shared module objects (dns, undici). +// vi.spyOn modifies the object property directly on the cached module instance, so it +// is intercepted by server_functions.js regardless of the Module.prototype.require override +// in vitest-setup.js. restoreAllMocks:true auto-restores spies, but may reuse the same +// spy instance — mockClear() is called explicitly in beforeEach to reset call history. +const dns = require("node:dns"); +const undici = require("undici"); +const { cors, getUserAgent, replaceSecretPlaceholder } = require("#server_functions"); + +describe("server_functions tests", () => { + describe("The replaceSecretPlaceholder method with cors=allowWhitelist", () => { + beforeEach(() => { + global.config = { cors: "allowWhitelist" }; + }); + + it("Calls string without secret placeholder", () => { + const teststring = "test string without secret placeholder"; + const result = replaceSecretPlaceholder(teststring); + expect(result).toBe(teststring); + }); + + it("Calls string with 2 secret placeholders", () => { + const teststring = "test string with secret1=**SECRET_ONE** and secret2=**SECRET_TWO**"; + process.env.SECRET_ONE = "secret1"; + process.env.SECRET_TWO = "secret2"; + const resultstring = `test string with secret1=${process.env.SECRET_ONE} and secret2=${process.env.SECRET_TWO}`; + const result = replaceSecretPlaceholder(teststring); + expect(result).toBe(resultstring); + }); + }); + + describe("The replaceSecretPlaceholder method with cors=allowAll", () => { + beforeEach(() => { + global.config = { cors: "allowAll" }; + }); + + it("Calls string without secret placeholder", () => { + const teststring = "test string without secret placeholder"; + const result = replaceSecretPlaceholder(teststring); + expect(result).toBe(teststring); + }); + + it("Calls string with 2 secret placeholders", () => { + const teststring = "test string with secret1=**SECRET_ONE** and secret2=**SECRET_TWO**"; + const result = replaceSecretPlaceholder(teststring); + expect(result).toBe(teststring); + }); + }); + + describe("The replaceSecretPlaceholder method with an allowedSecrets set", () => { + beforeEach(() => { + global.config = { cors: "allowWhitelist" }; + process.env.SECRET_ALLOWED = "allowed-value"; + process.env.SECRET_DENIED = "denied-value"; + }); + + it("Restores only allowed secrets and keeps denied placeholders untouched", () => { + const teststring = "allowed=**SECRET_ALLOWED** denied=**SECRET_DENIED**"; + const result = replaceSecretPlaceholder(teststring, new Set(["SECRET_ALLOWED"])); + expect(result).toBe("allowed=allowed-value denied=**SECRET_DENIED**"); + expect(result).not.toContain("denied-value"); + }); + + it("Does not restore any placeholder when the set is empty", () => { + const teststring = "value=**SECRET_ALLOWED**"; + const result = replaceSecretPlaceholder(teststring, new Set()); + expect(result).toBe(teststring); + }); + + it("Falls back to the placeholder if the allowed secret doesn't exist in environment", () => { + const teststring = "value=**SECRET_MISSING**"; + const result = replaceSecretPlaceholder(teststring, new Set(["SECRET_MISSING"])); + expect(result).toBe(teststring); + }); + }); + + describe("The cors method", () => { + let fetchSpy; + let fetchResponseHeadersGet; + let fetchResponseArrayBuffer; + let corsResponse; + let request; + + beforeEach(() => { + global.config = { cors: "allowAll" }; + fetchResponseHeadersGet = vi.fn(() => {}); + fetchResponseArrayBuffer = vi.fn(() => {}); + + // Mock DNS to return a public IP (SSRF check must pass for these tests) + vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "93.184.216.34", family: 4 }); + + // vi.spyOn may return the same spy instance across tests when restoreAllMocks + // restores-but-reuses; mockClear() explicitly resets call history each time. + fetchSpy = vi.spyOn(undici, "fetch"); + fetchSpy.mockClear(); + fetchSpy.mockImplementation(() => Promise.resolve({ + headers: { get: fetchResponseHeadersGet }, + arrayBuffer: fetchResponseArrayBuffer, + ok: true + })); + + corsResponse = { + set: vi.fn(() => {}), + send: vi.fn(() => {}), + status: vi.fn(function (code) { + this.statusCode = code; + return this; + }), + json: vi.fn(() => {}) + }; + + request = { + url: "/cors?url=http://www.test.com" + }; + }); + + it("Calls correct URL once", async () => { + const urlToCall = "http://www.test.com/path?param1=value1"; + request.url = `/cors?url=${urlToCall}`; + + await cors(request, corsResponse); + + expect(fetchSpy.mock.calls).toHaveLength(1); + expect(fetchSpy.mock.calls[0][0]).toBe(urlToCall); + }); + + it("Forwards Content-Type if json", async () => { + fetchResponseHeadersGet.mockImplementation(() => "json"); + + await cors(request, corsResponse); + + expect(fetchResponseHeadersGet.mock.calls).toHaveLength(1); + expect(fetchResponseHeadersGet.mock.calls[0][0]).toBe("Content-Type"); + + expect(corsResponse.set.mock.calls).toHaveLength(1); + expect(corsResponse.set.mock.calls[0][0]).toBe("Content-Type"); + expect(corsResponse.set.mock.calls[0][1]).toBe("json"); + }); + + it("Forwards Content-Type if xml", async () => { + fetchResponseHeadersGet.mockImplementation(() => "xml"); + + await cors(request, corsResponse); + + expect(fetchResponseHeadersGet.mock.calls).toHaveLength(1); + expect(fetchResponseHeadersGet.mock.calls[0][0]).toBe("Content-Type"); + + expect(corsResponse.set.mock.calls).toHaveLength(1); + expect(corsResponse.set.mock.calls[0][0]).toBe("Content-Type"); + expect(corsResponse.set.mock.calls[0][1]).toBe("xml"); + }); + + it("Sends correct data from response", async () => { + const responseData = "some data"; + const encoder = new TextEncoder(); + const arrayBuffer = encoder.encode(responseData).buffer; + fetchResponseArrayBuffer.mockImplementation(() => arrayBuffer); + + let sentData; + corsResponse.send = vi.fn((input) => { + sentData = input; + }); + + await cors(request, corsResponse); + + expect(fetchResponseArrayBuffer.mock.calls).toHaveLength(1); + expect(sentData).toEqual(Buffer.from(arrayBuffer)); + }); + + it("Sends error data from response", async () => { + const error = new Error("error data"); + fetchResponseArrayBuffer.mockImplementation(() => { + throw error; + }); + + await cors(request, corsResponse); + + expect(fetchResponseArrayBuffer.mock.calls).toHaveLength(1); + expect(corsResponse.status).toHaveBeenCalledWith(500); + expect(corsResponse.json).toHaveBeenCalledWith({ error: error.message }); + }); + + it("Fetches with user agent by default", async () => { + await cors(request, corsResponse); + + expect(fetchSpy.mock.calls).toHaveLength(1); + expect(fetchSpy.mock.calls[0][1]).toHaveProperty("headers"); + expect(fetchSpy.mock.calls[0][1].headers).toHaveProperty("User-Agent"); + }); + + it("Fetches with specified headers", async () => { + const headersParam = "sendheaders=header1:value1,header2:value2"; + const urlParam = "http://www.test.com/path?param1=value1"; + request.url = `/cors?${headersParam}&url=${urlParam}`; + + await cors(request, corsResponse); + + expect(fetchSpy.mock.calls).toHaveLength(1); + expect(fetchSpy.mock.calls[0][1]).toHaveProperty("headers"); + expect(fetchSpy.mock.calls[0][1].headers).toHaveProperty("header1", "value1"); + expect(fetchSpy.mock.calls[0][1].headers).toHaveProperty("header2", "value2"); + }); + + it("Sends specified headers", async () => { + fetchResponseHeadersGet.mockImplementation((input) => input.replace("header", "value")); + + const expectedheaders = "expectedheaders=header1,header2"; + const urlParam = "http://www.test.com/path?param1=value1"; + request.url = `/cors?${expectedheaders}&url=${urlParam}`; + + await cors(request, corsResponse); + + expect(fetchSpy.mock.calls).toHaveLength(1); + expect(fetchSpy.mock.calls[0][1]).toHaveProperty("headers"); + expect(corsResponse.set.mock.calls).toHaveLength(3); + expect(corsResponse.set.mock.calls[0][0]).toBe("Content-Type"); + expect(corsResponse.set.mock.calls[1][0]).toBe("header1"); + expect(corsResponse.set.mock.calls[1][1]).toBe("value1"); + expect(corsResponse.set.mock.calls[2][0]).toBe("header2"); + expect(corsResponse.set.mock.calls[2][1]).toBe("value2"); + }); + + it("Gets User-Agent from configuration", () => { + const previousConfig = global.config; + global.config = {}; + let userAgent; + + userAgent = getUserAgent(); + expect(userAgent).toContain("Mozilla/5.0 (Node.js "); + + global.config.userAgent = "Mozilla/5.0 (Foo)"; + userAgent = getUserAgent(); + expect(userAgent).toBe("Mozilla/5.0 (Foo)"); + + global.config.userAgent = () => "Mozilla/5.0 (Bar)"; + userAgent = getUserAgent(); + expect(userAgent).toBe("Mozilla/5.0 (Bar)"); + + global.config = previousConfig; + }); + }); + + describe("The cors method blocks SSRF (DNS rebinding safe)", () => { + let response; + + beforeEach(() => { + response = { + set: vi.fn(), + send: vi.fn(), + status: vi.fn(function () { return this; }), + json: vi.fn() + }; + }); + + it("Blocks localhost hostname without DNS", async () => { + await cors({ url: "/cors?url=http://localhost/path" }, response); + expect(response.status).toHaveBeenCalledWith(403); + expect(response.json).toHaveBeenCalledWith({ error: "Forbidden: private or reserved addresses are not allowed" }); + }); + + it("Blocks non-http protocols", async () => { + await cors({ url: "/cors?url=ftp://example.com/file" }, response); + expect(response.status).toHaveBeenCalledWith(403); + }); + + it("Blocks invalid URLs", async () => { + await cors({ url: "/cors?url=not_a_valid_url" }, response); + expect(response.status).toHaveBeenCalledWith(403); + }); + + it("Blocks loopback addresses (127.0.0.1)", async () => { + vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "127.0.0.1", family: 4 }); + await cors({ url: "/cors?url=http://example.com/" }, response); + expect(response.status).toHaveBeenCalledWith(403); + }); + + it("Blocks RFC 1918 private addresses (192.168.x.x)", async () => { + vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "192.168.1.1", family: 4 }); + await cors({ url: "/cors?url=http://example.com/" }, response); + expect(response.status).toHaveBeenCalledWith(403); + }); + + it("Blocks link-local / cloud metadata addresses (169.254.169.254)", async () => { + vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "169.254.169.254", family: 4 }); + await cors({ url: "/cors?url=http://example.com/" }, response); + expect(response.status).toHaveBeenCalledWith(403); + }); + + it("Allows public unicast addresses", async () => { + vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "93.184.216.34", family: 4 }); + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + headers: { get: vi.fn() }, + arrayBuffer: vi.fn(() => new ArrayBuffer(0)) + }); + await cors({ url: "/cors?url=http://example.com/" }, response); + expect(response.status).not.toHaveBeenCalledWith(403); + }); + }); + + describe("cors method with allowWhitelist", () => { + let response; + + beforeEach(() => { + response = { + set: vi.fn(), + send: vi.fn(), + status: vi.fn(function () { return this; }), + json: vi.fn() + }; + vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "93.184.216.34", family: 4 }); + vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + headers: { get: vi.fn() }, + arrayBuffer: vi.fn(() => new ArrayBuffer(0)) + }); + }); + + it("Blocks domains not in whitelist", async () => { + global.config = { cors: "allowWhitelist", corsDomainWhitelist: [] }; + await cors({ url: "/cors?url=http://example.com/api" }, response); + expect(response.status).toHaveBeenCalledWith(403); + }); + + it("Allows domains in whitelist", async () => { + global.config = { cors: "allowWhitelist", corsDomainWhitelist: ["example.com"] }; + await cors({ url: "/cors?url=http://example.com/api" }, response); + expect(response.status).not.toHaveBeenCalledWith(403); + }); + }); +}); diff --git a/tests/unit/functions/update_helper_spec.js b/tests/unit/functions/update_helper_spec.js new file mode 100644 index 0000000..7b7635f --- /dev/null +++ b/tests/unit/functions/update_helper_spec.js @@ -0,0 +1,119 @@ +import { mkdtempSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("UpdateHelper", () => { + const originalEnv = { ...process.env }; + const tempRoots = []; + + beforeEach(() => { + vi.resetModules(); + process.env = { ...originalEnv }; + global.version = "test"; + global.root_path = process.cwd(); + }); + + afterEach(() => { + process.env = originalEnv; + vi.useRealTimers(); + vi.restoreAllMocks(); + for (const tempRoot of tempRoots) { + rmSync(tempRoot, { recursive: true, force: true }); + } + tempRoots.length = 0; + }); + + /** + * Creates a temporary MagicMirror-like root with a module directory. + * @param {string} moduleName - Name of the module directory to create. + * @returns {{ root: string, modulePath: string }} Created paths. + */ + function createTempModuleRoot (moduleName) { + const root = mkdtempSync(join(tmpdir(), "mm-updater-")); + const modulePath = join(root, "modules", moduleName); + mkdirSync(modulePath, { recursive: true }); + tempRoots.push(root); + return { root, modulePath }; + } + + /** + * Creates a fresh UpdateHelper instance for testing. + * @param {object} config - Optional config overrides. + * @returns {Promise} Resolved UpdateHelper instance. + */ + async function createUpdater (config = {}) { + const updateHelperModule = await import("../../../defaultmodules/updatenotification/update_helper"); + const UpdateHelper = updateHelperModule.default || updateHelperModule; + return new UpdateHelper({ updates: [], updateTimeout: 1000, updateAutorestart: false, ...config }); + } + + it("marks update as requiring manual restart when autoRestart is disabled", async () => { + const moduleName = "MMM-Test"; + const { root } = createTempModuleRoot(moduleName); + global.root_path = root; + + const updater = await createUpdater({ updateAutorestart: false }); + const result = await updater.updateProcess({ + name: moduleName, + updateCommand: `"${process.execPath}" -p 1` + }); + + expect(result.error).toBe(false); + expect(result.updated).toBe(true); + expect(result.needRestart).toBe(true); + }); + + it("schedules node restart when autoRestart is enabled", async () => { + vi.useFakeTimers(); + + const moduleName = "MMM-Test"; + const { root } = createTempModuleRoot(moduleName); + global.root_path = root; + + const updater = await createUpdater({ updateAutorestart: true }); + const nodeRestartSpy = vi.spyOn(updater, "nodeRestart").mockImplementation(() => {}); + + const result = await updater.updateProcess({ + name: moduleName, + updateCommand: `"${process.execPath}" -p 1` + }); + + expect(result.error).toBe(false); + expect(result.updated).toBe(true); + expect(result.needRestart).toBe(false); + expect(nodeRestartSpy).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(3000); + expect(nodeRestartSpy).toHaveBeenCalledTimes(1); + }); + + describe("nodeRestart", () => { + it("exits without spawning when running under PM2", async () => { + process.env.pm_id = "0"; + + const updater = await createUpdater(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {}); + const spawnSpy = vi.spyOn(updater, "_spawnDetachedSelf").mockImplementation(() => {}); + + updater.nodeRestart(); + + expect(exitSpy).toHaveBeenCalledWith(0); + expect(spawnSpy).not.toHaveBeenCalled(); + }); + + it("spawns a detached child process when not running under PM2", async () => { + delete process.env.pm_id; + + const updater = await createUpdater(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {}); + const spawnSpy = vi.spyOn(updater, "_spawnDetachedSelf").mockImplementation(() => {}); + + updater.nodeRestart(); + + expect(spawnSpy).toHaveBeenCalledOnce(); + expect(exitSpy).toHaveBeenCalledOnce(); + expect(exitSpy).not.toHaveBeenCalledWith(0); + }); + }); +}); diff --git a/tests/unit/functions/updatenotification_spec.js b/tests/unit/functions/updatenotification_spec.js new file mode 100644 index 0000000..f72e7cc --- /dev/null +++ b/tests/unit/functions/updatenotification_spec.js @@ -0,0 +1,367 @@ +import { vi, describe, beforeEach, afterEach, it, expect } from "vitest"; + +/** + * Creates a fresh GitHelper instance with isolated mocks for each test run. + * @param {{ current: import("vitest").Mock | null }} fsStatSyncMockRef reference to the mocked fs.statSync. + * @param {{ current: { error: import("vitest").Mock; info: import("vitest").Mock } | null }} loggerMockRef reference to logger stubs. + * @param {{ current: import("vitest").MockInstance | null }} execGitSpyRef reference to the execGit spy. + * @returns {Promise} resolved GitHelper instance. + */ +async function createGitHelper (fsStatSyncMockRef, loggerMockRef, execGitSpyRef) { + vi.resetModules(); + + fsStatSyncMockRef.current = vi.fn(); + loggerMockRef.current = { error: vi.fn(), info: vi.fn() }; + + vi.doMock("node:fs", () => ({ + statSync: fsStatSyncMockRef.current + })); + + vi.doMock("logger", () => loggerMockRef.current); + + const defaults = await import("../../../js/defaults"); + const gitHelperModule = await import(`../../../${defaults.defaultModulesDir}/updatenotification/git_helper.js`); + const GitHelper = gitHelperModule.default || gitHelperModule; + const instance = new GitHelper(); + execGitSpyRef.current = vi.spyOn(instance, "execGit"); + instance.__loggerMock = loggerMockRef.current; + return instance; +} + +describe("Updatenotification", () => { + const fsStatSyncMockRef = { current: null }; + const loggerMockRef = { current: null }; + const execGitSpyRef = { current: null }; + let gitHelper; + + let gitRemoteOut; + let gitRevParseOut; + let gitStatusOut; + let gitFetchOut; + let gitRevListCountOut; + let gitRevListOut; + let gitFetchErr; + let gitTagListOut; + + const getExecutedCommands = () => execGitSpyRef.current.mock.calls.map((call) => call.slice(1).join(" ")); + + beforeEach(async () => { + gitHelper = await createGitHelper(fsStatSyncMockRef, loggerMockRef, execGitSpyRef); + + fsStatSyncMockRef.current.mockReturnValue({ isDirectory: () => true }); + + gitRemoteOut = ""; + gitRevParseOut = ""; + gitStatusOut = ""; + gitFetchOut = ""; + gitRevListCountOut = ""; + gitRevListOut = ""; + gitFetchErr = ""; + gitTagListOut = ""; + + execGitSpyRef.current.mockImplementation((_folder, ...args) => { + const command = args.join(" "); + + if (command === "remote -v") { + return Promise.resolve({ stdout: gitRemoteOut, stderr: "" }); + } + + if (command === "rev-parse HEAD") { + return Promise.resolve({ stdout: gitRevParseOut, stderr: "" }); + } + + if (command === "status -sb") { + return Promise.resolve({ stdout: gitStatusOut, stderr: "" }); + } + + if (command === "fetch -n --dry-run") { + return Promise.resolve({ stdout: gitFetchOut, stderr: gitFetchErr }); + } + + if (command.startsWith("rev-list --ancestry-path --count ")) { + return Promise.resolve({ stdout: gitRevListCountOut, stderr: "" }); + } + + if (command.startsWith("rev-list --ancestry-path ")) { + return Promise.resolve({ stdout: gitRevListOut, stderr: "" }); + } + + if (command === "ls-remote -q --tags --refs") { + return Promise.resolve({ stdout: gitTagListOut, stderr: "" }); + } + + return Promise.resolve({ stdout: "", stderr: "" }); + }); + + if (gitHelper.execGit !== execGitSpyRef.current) { + throw new Error("execGit spy not applied"); + } + }); + + afterEach(() => { + gitHelper.gitRepos = []; + vi.resetAllMocks(); + }); + + describe("getRefDiffFromFetchDryRun", () => { + it("extracts only commit range from matching fetch line", () => { + const fetchOutput = "From github.com:MagicMirrorOrg/MagicMirror\n60e0377..332e429 develop -> origin/develop\n"; + + expect(gitHelper.getRefDiffFromFetchDryRun(fetchOutput, "develop")).toBe("60e0377..332e429"); + }); + + it("matches branch name exactly", () => { + const fetchOutput = "From github.com:MagicMirrorOrg/MagicMirror\n1111111..2222222 main-feature -> origin/main-feature\n3333333..4444444 main -> origin/main\n"; + + expect(gitHelper.getRefDiffFromFetchDryRun(fetchOutput, "main")).toBe("3333333..4444444"); + }); + + it("returns fallback range when matching line is missing", () => { + const fetchOutput = "From github.com:MagicMirrorOrg/MagicMirror\n"; + + expect(gitHelper.getRefDiffFromFetchDryRun(fetchOutput, "develop")).toBe("develop..origin/develop"); + }); + }); + + describe("MagicMirror on develop", () => { + const moduleName = "MagicMirror"; + + beforeEach(() => { + gitRemoteOut = "origin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (fetch)\norigin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (push)\n"; + gitRevParseOut = "332e429a41f1a2339afd4f0ae96dd125da6beada"; + gitStatusOut = "## develop...origin/develop\n M tests/unit/functions/updatenotification_spec.js\n"; + gitFetchErr = "From github.com:MagicMirrorOrg/MagicMirror\n60e0377..332e429 develop -> origin/develop\n"; + gitRevListCountOut = "5"; + + gitHelper.gitRepos = [{ module: moduleName, folder: "mock-path" }]; + }); + + it("returns status information", async () => { + const repos = await gitHelper.getRepos(); + expect(repos[0]).toMatchSnapshot(); + expect(getExecutedCommands()).toMatchInlineSnapshot(` + [ + "rev-parse HEAD", + "status -sb", + "fetch -n --dry-run", + "rev-list --ancestry-path --count 60e0377..332e429", + ] + `); + }); + + it("returns status information early if isBehindInStatus", async () => { + gitStatusOut = "## develop...origin/develop [behind 5]"; + + const repos = await gitHelper.getRepos(); + expect(repos[0]).toMatchSnapshot(); + expect(getExecutedCommands()).toMatchInlineSnapshot(` + [ + "rev-parse HEAD", + "status -sb", + ] + `); + }); + + it("excludes repo if status can't be retrieved", async () => { + const errorMessage = "Failed to retrieve status"; + execGitSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage))); + + expect(gitHelper.gitRepos).toHaveLength(1); + const repos = await gitHelper.getRepos(); + expect(repos).toHaveLength(0); + expect(execGitSpyRef.current.mock.calls.length).toBeGreaterThan(0); + }); + }); + + describe("MagicMirror on master (empty taglist)", () => { + const moduleName = "MagicMirror"; + + beforeEach(() => { + gitRemoteOut = "origin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (fetch)\norigin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (push)\n"; + gitRevParseOut = "332e429a41f1a2339afd4f0ae96dd125da6beada"; + gitStatusOut = "## master...origin/master\n M tests/unit/functions/updatenotification_spec.js\n"; + gitFetchErr = "From github.com:MagicMirrorOrg/MagicMirror\n60e0377..332e429 master -> origin/master\n"; + gitRevListCountOut = "5"; + + gitHelper.gitRepos = [{ module: moduleName, folder: "mock-path" }]; + }); + + it("returns status information", async () => { + const repos = await gitHelper.getRepos(); + expect(repos[0]).toMatchSnapshot(); + expect(getExecutedCommands()).toMatchInlineSnapshot(` + [ + "rev-parse HEAD", + "status -sb", + "fetch -n --dry-run", + "rev-list --ancestry-path --count 60e0377..332e429", + "ls-remote -q --tags --refs", + "rev-list --ancestry-path 60e0377..332e429", + ] + `); + }); + + it("returns status information early if isBehindInStatus", async () => { + gitStatusOut = "## master...origin/master [behind 5]"; + + const repos = await gitHelper.getRepos(); + expect(repos[0]).toMatchSnapshot(); + expect(getExecutedCommands()).toMatchInlineSnapshot(` + [ + "rev-parse HEAD", + "status -sb", + "fetch -n --dry-run", + "rev-list --ancestry-path --count 60e0377..332e429", + "ls-remote -q --tags --refs", + "rev-list --ancestry-path 60e0377..332e429", + ] + `); + }); + + it("excludes repo if status can't be retrieved", async () => { + const errorMessage = "Failed to retrieve status"; + execGitSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage))); + + const repos = await gitHelper.getRepos(); + expect(repos).toHaveLength(0); + }); + }); + + describe("MagicMirror on master with match in taglist", () => { + const moduleName = "MagicMirror"; + + beforeEach(() => { + gitRemoteOut = "origin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (fetch)\norigin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (push)\n"; + gitRevParseOut = "332e429a41f1a2339afd4f0ae96dd125da6beada"; + gitStatusOut = "## master...origin/master\n M tests/unit/functions/updatenotification_spec.js\n"; + gitFetchErr = "From github.com:MagicMirrorOrg/MagicMirror\n60e0377..332e429 master -> origin/master\n"; + gitRevListCountOut = "5"; + gitTagListOut = "332e429a41f1a2339afd4f0ae96dd125da6beada\ttag\n"; + gitRevListOut = "332e429a41f1a2339afd4f0ae96dd125da6beada\n"; + + gitHelper.gitRepos = [{ module: moduleName, folder: "mock-path" }]; + }); + + it("returns status information", async () => { + const repos = await gitHelper.getRepos(); + expect(repos[0]).toMatchSnapshot(); + expect(getExecutedCommands()).toMatchInlineSnapshot(` + [ + "rev-parse HEAD", + "status -sb", + "fetch -n --dry-run", + "rev-list --ancestry-path --count 60e0377..332e429", + "ls-remote -q --tags --refs", + "rev-list --ancestry-path 60e0377..332e429", + ] + `); + }); + + it("returns status information early if isBehindInStatus", async () => { + gitStatusOut = "## master...origin/master [behind 5]"; + + const repos = await gitHelper.getRepos(); + expect(repos[0]).toMatchSnapshot(); + expect(getExecutedCommands()).toMatchInlineSnapshot(` + [ + "rev-parse HEAD", + "status -sb", + "fetch -n --dry-run", + "rev-list --ancestry-path --count 60e0377..332e429", + "ls-remote -q --tags --refs", + "rev-list --ancestry-path 60e0377..332e429", + ] + `); + }); + + it("excludes repo if status can't be retrieved", async () => { + const errorMessage = "Failed to retrieve status"; + execGitSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage))); + + const repos = await gitHelper.getRepos(); + expect(repos).toHaveLength(0); + }); + }); + + describe("MagicMirror on master without match in taglist", () => { + const moduleName = "MagicMirror"; + + beforeEach(() => { + gitRemoteOut = "origin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (fetch)\norigin\tgit@github.com:MagicMirrorOrg/MagicMirror.git (push)\n"; + gitRevParseOut = "332e429a41f1a2339afd4f0ae96dd125da6beada"; + gitStatusOut = "## master...origin/master\n M tests/unit/functions/updatenotification_spec.js\n"; + gitFetchErr = "From github.com:MagicMirrorOrg/MagicMirror\n60e0377..332e429 master -> origin/master\n"; + gitRevListCountOut = "5"; + gitTagListOut = "xxxe429a41f1a2339afd4f0ae96dd125da6beada\ttag\n"; + gitRevListOut = "332e429a41f1a2339afd4f0ae96dd125da6beada\n"; + + gitHelper.gitRepos = [{ module: moduleName, folder: "mock-path" }]; + }); + + it("returns status information", async () => { + const repos = await gitHelper.getRepos(); + expect(repos[0]).toMatchSnapshot(); + expect(getExecutedCommands()).toMatchInlineSnapshot(` + [ + "rev-parse HEAD", + "status -sb", + "fetch -n --dry-run", + "rev-list --ancestry-path --count 60e0377..332e429", + "ls-remote -q --tags --refs", + "rev-list --ancestry-path 60e0377..332e429", + ] + `); + }); + + it("returns status information early if isBehindInStatus", async () => { + gitStatusOut = "## master...origin/master [behind 5]"; + + const repos = await gitHelper.getRepos(); + expect(repos[0]).toMatchSnapshot(); + expect(getExecutedCommands()).toMatchInlineSnapshot(` + [ + "rev-parse HEAD", + "status -sb", + "fetch -n --dry-run", + "rev-list --ancestry-path --count 60e0377..332e429", + "ls-remote -q --tags --refs", + "rev-list --ancestry-path 60e0377..332e429", + ] + `); + }); + + it("excludes repo if status can't be retrieved", async () => { + const errorMessage = "Failed to retrieve status"; + execGitSpyRef.current.mockImplementationOnce(() => Promise.reject(new Error(errorMessage))); + + const repos = await gitHelper.getRepos(); + expect(repos).toHaveLength(0); + }); + }); + + describe("custom module", () => { + const moduleName = "MMM-Fuel"; + + beforeEach(() => { + gitRemoteOut = `origin\thttps://github.com/fewieden/${moduleName}.git (fetch)\norigin\thttps://github.com/fewieden/${moduleName}.git (push)\n`; + gitRevParseOut = "9d8310163da94441073a93cead711ba43e8888d0"; + gitStatusOut = "## master...origin/master"; + gitFetchErr = `From https://github.com/fewieden/${moduleName}\n19f7faf..9d83101 master -> origin/master`; + gitRevListCountOut = "7"; + + gitHelper.gitRepos = [{ module: moduleName, folder: "mock-path" }]; + }); + + it("returns status information without hash", async () => { + const repos = await gitHelper.getRepos(); + expect(repos[0]).toMatchSnapshot(); + expect(getExecutedCommands()).toMatchInlineSnapshot(` + [ + "status -sb", + "fetch -n --dry-run", + "rev-list --ancestry-path --count 19f7faf..9d83101", + ] + `); + }); + }); +}); diff --git a/tests/unit/global_vars/defaults_modules_spec.js b/tests/unit/global_vars/defaults_modules_spec.js new file mode 100644 index 0000000..993dcb6 --- /dev/null +++ b/tests/unit/global_vars/defaults_modules_spec.js @@ -0,0 +1,15 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const defaults = require("../../../js/defaults"); + +const root_path = path.join(__dirname, "../../.."); + +describe("Default modules set in defaultmodules/defaultmodules.js", () => { + const expectedDefaultModules = require(`${root_path}/${defaults.defaultModulesDir}/defaultmodules`); + + for (const defaultModule of expectedDefaultModules) { + it(`contains a folder for defaultmodules/${defaultModule}"`, () => { + expect(fs.existsSync(path.join(root_path, "defaultmodules", defaultModule))).toBe(true); + }); + } +}); diff --git a/tests/unit/global_vars/root_path_spec.js b/tests/unit/global_vars/root_path_spec.js new file mode 100644 index 0000000..dcba2bf --- /dev/null +++ b/tests/unit/global_vars/root_path_spec.js @@ -0,0 +1,28 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +const root_path = path.join(__dirname, "../../.."); +const version = require(`${root_path}/package.json`).version; + +describe("'global.root_path' set in js/app.js", () => { + const expectedSubPaths = ["modules", "serveronly", "js", "js/app.js", "js/main.js", "js/electron.js", "config"]; + + expectedSubPaths.forEach((subpath) => { + it(`contains a file/folder "${subpath}"`, () => { + expect(fs.existsSync(path.join(root_path, subpath))).toBe(true); + }); + }); + + it("should not modify global.root_path for testing", () => { + expect(global.root_path).toBeUndefined(); + }); + + it("should not modify global.version for testing", () => { + expect(global.version).toBeUndefined(); + }); + + it("should expect the global.version equals package.json file", () => { + const versionPackage = JSON.parse(fs.readFileSync("package.json", "utf8")).version; + expect(version).toBe(versionPackage); + }); +}); diff --git a/tests/unit/modules/default/calendar/calendar_fetcher_spec.js b/tests/unit/modules/default/calendar/calendar_fetcher_spec.js new file mode 100644 index 0000000..e094db2 --- /dev/null +++ b/tests/unit/modules/default/calendar/calendar_fetcher_spec.js @@ -0,0 +1,130 @@ +global.moment = require("moment-timezone"); + +const ical = require("node-ical"); +const moment = require("moment-timezone"); +const defaults = require("../../../../../js/defaults"); + +const CalendarFetcherUtils = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarfetcherutils`); + +const CalendarFetcher = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarfetcher`); + +const makeFetcher = (options = {}) => new CalendarFetcher( + options.url ?? "http://test.example.com/cal.ics", + options.reloadInterval ?? 60000, + options.excludedEvents ?? [], + options.maximumEntries ?? 10, + options.maximumNumberOfDays ?? 365, + options.auth ?? null, + options.includePastEvents ?? false, + options.selfSignedCert ?? false +); + +// Triggers a fetch and resolves once the fetcher finishes (success or error). +// On error, resolves with the errorInfo object so tests can inspect it. +const emitResponse = (fetcher, response) => new Promise((resolve) => { + fetcher.onReceive(resolve); + fetcher.onError((_, errorInfo) => resolve(errorInfo)); + fetcher.httpFetcher.emit("response", response); +}); + +const futureEventICS = () => { + const start = moment().add(1, "hour"); + const end = moment().add(2, "hours"); + return [ + "BEGIN:VCALENDAR", + "BEGIN:VEVENT", + `DTSTART:${start.utc().format("YYYYMMDDTHHmmss")}Z`, + `DTEND:${end.utc().format("YYYYMMDDTHHmmss")}Z`, + "UID:future-1@test", + "SUMMARY:Future Event", + "END:VEVENT", + "END:VCALENDAR" + ].join("\r\n"); +}; + +describe("CalendarFetcher", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("304 handling", () => { + it("keeps previously fetched events when a 304 Not Modified response arrives", async () => { + const fetcher = makeFetcher(); + + await emitResponse(fetcher, new Response(futureEventICS(), { status: 200 })); + expect(fetcher.events).toHaveLength(1); + + // 304 Not Modified has an empty body: events must be preserved + await emitResponse(fetcher, new Response(null, { status: 304 })); + expect(fetcher.events).toHaveLength(1); + }); + }); + + describe("error handling", () => { + it("forwards HTTP fetch errors to onError callback", () => { + const fetcher = makeFetcher(); + const onError = vi.fn(); + const errorInfo = { errorType: "NETWORK_ERROR", message: "boom" }; + + fetcher.onError(onError); + fetcher.httpFetcher.emit("error", errorInfo); + + expect(onError).toHaveBeenCalledWith(fetcher, errorInfo); + }); + + it("keeps existing events and reports PARSE_ERROR when parsing fails", async () => { + const fetcher = makeFetcher(); + + await emitResponse(fetcher, new Response(futureEventICS(), { status: 200 })); + expect(fetcher.events).toHaveLength(1); + + vi.spyOn(ical.async, "parseICS").mockRejectedValueOnce(new Error("invalid ics")); + const error = await emitResponse(fetcher, new Response("BROKEN", { status: 200 })); + + expect(fetcher.events).toHaveLength(1); + expect(error).toMatchObject({ + errorType: "PARSE_ERROR", + translationKey: "MODULE_ERROR_UNSPECIFIED", + url: "http://test.example.com/cal.ics" + }); + }); + }); + + describe("delegation and refetch", () => { + it("delegates fetchCalendar to HTTPFetcher.startPeriodicFetch", () => { + const fetcher = makeFetcher(); + const startSpy = vi.spyOn(fetcher.httpFetcher, "startPeriodicFetch"); + + fetcher.fetchCalendar(); + + expect(startSpy).toHaveBeenCalledTimes(1); + }); + + it("shouldRefetch respects reload interval boundaries", () => { + const fetcher = makeFetcher(); + + expect(fetcher.shouldRefetch()).toBe(true); + + fetcher.lastFetch = Date.now() - 59999; + expect(fetcher.shouldRefetch()).toBe(false); + + fetcher.lastFetch = Date.now() - 60000; + expect(fetcher.shouldRefetch()).toBe(true); + }); + + it("passes configured filter options to CalendarFetcherUtils.filterEvents", async () => { + const excludedEvents = ["Do not show me"]; + const filterSpy = vi.spyOn(CalendarFetcherUtils, "filterEvents"); + const fetcher = makeFetcher({ excludedEvents, maximumEntries: 7, maximumNumberOfDays: 30, includePastEvents: true }); + + await emitResponse(fetcher, new Response(futureEventICS(), { status: 200 })); + + expect(filterSpy).toHaveBeenCalledWith(expect.any(Object), { + excludedEvents, + includePastEvents: true, + maximumEntries: 7, + maximumNumberOfDays: 30 + }); + }); + }); +}); diff --git a/tests/unit/modules/default/calendar/calendar_fetcher_utils_bad_rrule.js b/tests/unit/modules/default/calendar/calendar_fetcher_utils_bad_rrule.js new file mode 100644 index 0000000..6b4a8dc --- /dev/null +++ b/tests/unit/modules/default/calendar/calendar_fetcher_utils_bad_rrule.js @@ -0,0 +1,32 @@ +global.moment = require("moment-timezone"); + +const defaults = require("../../../js/defaults"); + +const CalendarFetcherUtils = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarfetcherutils`); + +describe("Calendar fetcher utils test", () => { + const defaultConfig = { + excludedEvents: [] + }; + + describe("filterEvents", () => { + it("no events, not crash", () => { + const base = moment().startOf("day").add(12, "hours"); + const minusOneHour = base.clone().subtract(1, "hours").toDate(); + const minusTwoHours = base.clone().subtract(2, "hours").toDate(); + const plusOneHour = base.clone().add(1, "hours").toDate(); + const plusTwoHours = base.clone().add(2, "hours").toDate(); + + const filteredEvents = CalendarFetcherUtils.filterEvents( + { + pastEvent: { type: "VEVENT", start: minusTwoHours, end: minusOneHour, summary: "pastEvent" }, + ongoingEvent: { type: "VEVENT", start: minusOneHour, end: plusOneHour, summary: "ongoingEvent" }, + upcomingEvent: { type: "VEVENT", start: plusOneHour, end: plusTwoHours, summary: "upcomingEvent" } + }, + defaultConfig + ); + + expect(filteredEvents).toHaveLength(0); + }); + }); +}); diff --git a/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js b/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js new file mode 100644 index 0000000..8282807 --- /dev/null +++ b/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js @@ -0,0 +1,523 @@ +global.moment = require("moment-timezone"); + +const ical = require("node-ical"); +const moment = require("moment-timezone"); +const defaults = require("../../../../../js/defaults"); + +const CalendarFetcherUtils = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarfetcherutils`); + +describe("Calendar fetcher utils test", () => { + const defaultConfig = { + excludedEvents: [], + includePastEvents: false, + maximumEntries: 10, + maximumNumberOfDays: 367 + }; + + describe("filterEvents", () => { + it("should return only ongoing and upcoming non full day events", () => { + const minusOneHour = moment().subtract(1, "hours").toDate(); + const minusTwoHours = moment().subtract(2, "hours").toDate(); + const plusOneHour = moment().add(1, "hours").toDate(); + const plusTwoHours = moment().add(2, "hours").toDate(); + + const filteredEvents = CalendarFetcherUtils.filterEvents( + { + pastEvent: { type: "VEVENT", start: minusTwoHours, end: minusOneHour, summary: "pastEvent" }, + ongoingEvent: { type: "VEVENT", start: minusOneHour, end: plusOneHour, summary: "ongoingEvent" }, + upcomingEvent: { type: "VEVENT", start: plusOneHour, end: plusTwoHours, summary: "upcomingEvent" } + }, + defaultConfig + ); + + expect(filteredEvents).toHaveLength(2); + expect(filteredEvents[0].title).toBe("ongoingEvent"); + expect(filteredEvents[1].title).toBe("upcomingEvent"); + }); + + it("should return only ongoing and upcoming full day events", () => { + const yesterday = moment().subtract(1, "days").startOf("day").toDate(); + const today = moment().startOf("day").toDate(); + const tomorrow = moment().add(1, "days").startOf("day").toDate(); + const dayAfterTomorrow = moment().add(2, "days").startOf("day").toDate(); + // Mark as DATE-only (full-day) events per ICS convention + yesterday.dateOnly = true; + today.dateOnly = true; + tomorrow.dateOnly = true; + dayAfterTomorrow.dateOnly = true; + + // ICS convention: DTEND for a full-day event is the exclusive next day + const filteredEvents = CalendarFetcherUtils.filterEvents( + { + pastEvent: { type: "VEVENT", start: yesterday, end: today, summary: "pastEvent" }, + ongoingEvent: { type: "VEVENT", start: today, end: tomorrow, summary: "ongoingEvent" }, + upcomingEvent: { type: "VEVENT", start: tomorrow, end: dayAfterTomorrow, summary: "upcomingEvent" } + }, + defaultConfig + ); + + expect(filteredEvents).toHaveLength(2); + expect(filteredEvents[0].title).toBe("ongoingEvent"); + expect(filteredEvents[1].title).toBe("upcomingEvent"); + }); + + it("should hide excluded event with 'until' when far away and show it when close", () => { + // An event ending in 10 days with until='3 days' should be hidden now + const farStart = moment().add(9, "days").toDate(); + const farEnd = moment().add(10, "days").toDate(); + // An event ending in 1 day with until='3 days' should be shown (within 3 days of end) + const closeStart = moment().add(1, "hours").toDate(); + const closeEnd = moment().add(1, "days").toDate(); + + const config = { + ...defaultConfig, + excludedEvents: [{ filterBy: "Payment", until: "3 days" }] + }; + + const filteredEvents = CalendarFetcherUtils.filterEvents( + { + farPayment: { type: "VEVENT", start: farStart, end: farEnd, summary: "Payment due" }, + closePayment: { type: "VEVENT", start: closeStart, end: closeEnd, summary: "Payment reminder" }, + normalEvent: { type: "VEVENT", start: closeStart, end: closeEnd, summary: "Normal event" } + }, + config + ); + + // farPayment should be hidden (now < endDate - 3 days) + // closePayment should show (now >= endDate - 3 days) + // normalEvent should show (not matched by filter) + const titles = filteredEvents.map((e) => e.title); + expect(titles).not.toContain("Payment due"); + expect(titles).toContain("Payment reminder"); + expect(titles).toContain("Normal event"); + }); + + it("should fully exclude event when excludedEvents has no 'until'", () => { + const start = moment().add(1, "hours").toDate(); + const end = moment().add(2, "hours").toDate(); + + const config = { + ...defaultConfig, + excludedEvents: ["Hidden"] + }; + + const filteredEvents = CalendarFetcherUtils.filterEvents( + { + hidden: { type: "VEVENT", start, end, summary: "Hidden event" }, + visible: { type: "VEVENT", start, end, summary: "Visible event" } + }, + config + ); + + expect(filteredEvents).toHaveLength(1); + expect(filteredEvents[0].title).toBe("Visible event"); + }); + + it("should return the correct times when recurring events pass through daylight saving time", () => { + const data = ical.parseICS(`BEGIN:VEVENT +DTSTART;TZID=Europe/Amsterdam:20250311T090000 +DTEND;TZID=Europe/Amsterdam:20250311T091500 +RRULE:FREQ=WEEKLY;BYDAY=FR,MO,TH,TU,WE,SA,SU +DTSTAMP:20250531T091103Z +ORGANIZER;CN=test:mailto:test@test.com +UID:67e65a1d-b889-4451-8cab-5518cecb9c66 +CREATED:20230111T114612Z +DESCRIPTION:Test +LAST-MODIFIED:20250528T071312Z +SEQUENCE:1 +STATUS:CONFIRMED +SUMMARY:Test +TRANSP:OPAQUE +END:VEVENT`); + + const filteredEvents = CalendarFetcherUtils.filterEvents(data, defaultConfig); + + const januaryFirst = filteredEvents.filter((event) => moment(event.startDate, "x").format("MM-DD") === "01-01"); + const julyFirst = filteredEvents.filter((event) => moment(event.startDate, "x").format("MM-DD") === "07-01"); + + let januaryMoment = moment(`${moment(januaryFirst[0].startDate, "x").format("YYYY")}-01-01T09:00:00`) + .tz("Europe/Amsterdam", true) // Convert to Europe/Amsterdam timezone (see event ical) but keep 9 o'clock + .tz(moment.tz.guess()); // Convert to guessed timezone as that is used in the filterEvents + + let julyMoment = moment(`${moment(julyFirst[0].startDate, "x").format("YYYY")}-07-01T09:00:00`) + .tz("Europe/Amsterdam", true) // Convert to Europe/Amsterdam timezone (see event ical) but keep 9 o'clock + .tz(moment.tz.guess()); // Convert to guessed timezone as that is used in the filterEvents + + expect(januaryFirst[0].startDate).toEqual(januaryMoment.format("x")); + expect(julyFirst[0].startDate).toEqual(julyMoment.format("x")); + }); + + it("should return the correct moments based on the timezone given", () => { + const data = ical.parseICS(`BEGIN:VEVENT +DTSTART;TZID=Europe/Amsterdam:20250311T090000 +DTEND;TZID=Europe/Amsterdam:20250311T091500 +RRULE:FREQ=WEEKLY;BYDAY=FR,MO,TH,TU,WE,SA,SU +DTSTAMP:20250531T091103Z +UID:67e65a1d-b889-4451-8cab-5518cecb9c66 +SUMMARY:Test +END:VEVENT`); + + const instances = CalendarFetcherUtils.expandRecurringEvent(data["67e65a1d-b889-4451-8cab-5518cecb9c66"], moment(), moment().add(365, "days")); + + const januaryFirst = instances.filter((i) => i.startMoment.format("MM-DD") === "01-01"); + const julyFirst = instances.filter((i) => i.startMoment.format("MM-DD") === "07-01"); + + // The underlying timestamps must represent 09:00 Amsterdam time, regardless of local timezone + expect(januaryFirst[0].startMoment.clone().tz("Europe/Amsterdam").toISOString(true)).toContain("09:00:00.000+01:00"); + expect(julyFirst[0].startMoment.clone().tz("Europe/Amsterdam").toISOString(true)).toContain("09:00:00.000+02:00"); + }); + + it("should return correct day-of-week for full-day recurring events across DST transitions", () => { + // Test case for GitHub issue #3976: recurring full-day events showing on wrong day + // This happens when DST transitions change the UTC offset between occurrences + const data = ical.parseICS(`BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART;VALUE=DATE:20251027 +DTEND;VALUE=DATE:20251028 +RRULE:FREQ=WEEKLY;WKST=SU;COUNT=3 +DTSTAMP:20260103T123138Z +UID:dst-test@google.com +SUMMARY:Weekly Monday Event +END:VEVENT +END:VCALENDAR`); + + // Simulate calendar with timezone (e.g., from X-WR-TIMEZONE or user config) + // This is how MagicMirror handles full-day events from calendars with timezones + data["dst-test@google.com"].start.tz = "America/Chicago"; + + const pastMoment = moment("2025-10-01"); + const futureMoment = moment("2025-11-30"); + const instances = CalendarFetcherUtils.expandRecurringEvent(data["dst-test@google.com"], pastMoment, futureMoment); + const startMoments = instances.map((i) => i.startMoment); + + // All occurrences should be on Monday (day() === 1) at midnight + // Oct 27, 2025 - Before DST ends + // Nov 3, 2025 - After DST ends (this was showing as Sunday before the fix) + // Nov 10, 2025 - After DST ends + expect(startMoments).toHaveLength(3); + expect(startMoments[0].day()).toBe(1); // Monday + expect(startMoments[0].format("YYYY-MM-DD")).toBe("2025-10-27"); + expect(startMoments[0].hour()).toBe(0); // Midnight + expect(startMoments[1].day()).toBe(1); // Monday (not Sunday!) + expect(startMoments[1].format("YYYY-MM-DD")).toBe("2025-11-03"); + expect(startMoments[1].hour()).toBe(0); // Midnight + expect(startMoments[2].day()).toBe(1); // Monday + expect(startMoments[2].format("YYYY-MM-DD")).toBe("2025-11-10"); + expect(startMoments[2].hour()).toBe(0); // Midnight + }); + + it("should show Facebook birthday events in the current year, not in the birth year", () => { + // Facebook birthday calendars use DTSTART with the actual birth year (e.g. 1990), + // which previously caused rrule.js to return the wrong year occurrence. + // With rrule-temporal this works correctly without any special-casing. + const data = ical.parseICS(`BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART;VALUE=DATE:19900215 +RRULE:FREQ=YEARLY +DTSTAMP:20260101T000000Z +UID:birthday_123456789@facebook.com +SUMMARY:Jane Doe's Birthday +END:VEVENT +END:VCALENDAR`); + + const thisYear = moment().year(); + + const filteredEvents = CalendarFetcherUtils.filterEvents(data, { + ...defaultConfig, + maximumNumberOfDays: 366 + }); + + const birthdayEvents = filteredEvents.filter((e) => e.title === "Jane Doe's Birthday"); + expect(birthdayEvents.length).toBeGreaterThanOrEqual(1); + + // The event must expand to a recent year — NOT to the birth year 1990. + // It should be the current or next year depending on whether Feb 15 has already passed. + const startYear = moment(birthdayEvents[0].startDate, "x").year(); + expect(startYear).toBeGreaterThanOrEqual(thisYear); + expect(startYear).toBeLessThanOrEqual(thisYear + 1); + }); + + it("should produce a correctly shaped event object with all required fields", () => { + const start = moment().add(1, "day").startOf("hour").toDate(); + const end = moment().add(1, "day").startOf("hour").add(1, "hour") + .toDate(); + + const filteredEvents = CalendarFetcherUtils.filterEvents( + { + event1: { + type: "VEVENT", + start, + end, + summary: "Team Meeting", + description: "Agenda TBD", + location: "Room 42", + geo: { lat: 52.52, lon: 13.4 }, + class: "PUBLIC", + uid: "shaped-event@test" + } + }, + defaultConfig + ); + + expect(filteredEvents).toHaveLength(1); + const ev = filteredEvents[0]; + expect(ev.title).toBe("Team Meeting"); + expect(ev.startDate).toBe(moment(start).format("x")); + expect(ev.endDate).toBe(moment(end).format("x")); + expect(ev.fullDayEvent).toBe(false); + expect(ev.recurringEvent).toBe(false); + expect(ev.class).toBe("PUBLIC"); + expect(ev.firstYear).toBe(moment(start).year()); + expect(ev.location).toBe("Room 42"); + expect(ev.geo).toEqual({ lat: 52.52, lon: 13.4 }); + expect(ev.description).toBe("Agenda TBD"); + }); + + it("should return correct firstYear for a full-day event on January 1st", () => { + // node-ical creates DATE-only events with the local Date constructor: new Date(year, month, day). + // getFullYear() on a locally-constructed date always returns the correct calendar year + // regardless of the server's UTC offset — guard against regressions that switch to getUTCFullYear(). + const data = ical.parseICS(`BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART;VALUE=DATE:19900101 +DTEND;VALUE=DATE:19900102 +RRULE:FREQ=YEARLY +UID:newyear-birthday@test +SUMMARY:New Year Baby +END:VEVENT +END:VCALENDAR`); + + const filteredEvents = CalendarFetcherUtils.filterEvents(data, { + ...defaultConfig, + maximumNumberOfDays: 366 + }); + + const birthday = filteredEvents.find((e) => e.title === "New Year Baby"); + expect(birthday).toBeDefined(); + expect(birthday.firstYear).toBe(1990); + }); + }); + + describe("calculateFilterWindow", () => { + it("ends maximumNumberOfDays after today's midnight", () => { + const [, end] = CalendarFetcherUtils.calculateFilterWindow({ includePastEvents: false, maximumNumberOfDays: 30 }); + + expect(end).toEqual(moment().startOf("day").add(30, "days").toDate()); + }); + + it("starts now when includePastEvents is false", () => { + const before = Date.now(); + const [start] = CalendarFetcherUtils.calculateFilterWindow({ includePastEvents: false, maximumNumberOfDays: 30 }); + const after = Date.now(); + + expect(start.getTime()).toBeGreaterThanOrEqual(before); + expect(start.getTime()).toBeLessThanOrEqual(after); + }); + + it("starts maximumNumberOfDays before today's midnight when includePastEvents is true", () => { + const [start] = CalendarFetcherUtils.calculateFilterWindow({ includePastEvents: true, maximumNumberOfDays: 30 }); + + expect(start).toEqual(moment().startOf("day").subtract(30, "days").toDate()); + }); + }); + + describe("expandRecurringEvent", () => { + it("should extend end to end-of-day when event has no DTEND", () => { + // node-ical sets end === start when DTEND is absent; our code extends to endOf("day") + const data = ical.parseICS(`BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART:20260222T100000Z +UID:no-end-test@test +SUMMARY:No End Event +END:VEVENT +END:VCALENDAR`); + + const instances = CalendarFetcherUtils.expandRecurringEvent(data["no-end-test@test"], moment("2026-02-20"), moment("2026-02-24")); + + expect(instances).toHaveLength(1); + expect(instances[0].endMoment.format("HH:mm:ss")).toBe("23:59:59"); + }); + + it("should apply RECURRENCE-ID overrides (moved single occurrence)", () => { + // A weekly event on Mondays at 10:00, but the second occurrence is moved to Tuesday 14:00 + const data = ical.parseICS(`BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART;TZID=Europe/Berlin:20260302T100000 +DTEND;TZID=Europe/Berlin:20260302T110000 +RRULE:FREQ=WEEKLY;COUNT=3 +UID:recurrence-override@test +SUMMARY:Weekly Standup +END:VEVENT +BEGIN:VEVENT +DTSTART;TZID=Europe/Berlin:20260310T140000 +DTEND;TZID=Europe/Berlin:20260310T150000 +RECURRENCE-ID;TZID=Europe/Berlin:20260309T100000 +UID:recurrence-override@test +SUMMARY:Moved Standup +END:VEVENT +END:VCALENDAR`); + + const instances = CalendarFetcherUtils.expandRecurringEvent( + data["recurrence-override@test"], + moment("2026-03-01"), + moment("2026-03-31") + ); + + expect(instances).toHaveLength(3); + + // First occurrence: Monday March 2, 10:00 (unchanged) + expect(instances[0].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD HH:mm")).toBe("2026-03-02 10:00"); + expect(CalendarFetcherUtils.getTitleFromEvent(instances[0].event)).toBe("Weekly Standup"); + + // Second occurrence: moved to Tuesday March 10, 14:00 + expect(instances[1].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD HH:mm")).toBe("2026-03-10 14:00"); + expect(CalendarFetcherUtils.getTitleFromEvent(instances[1].event)).toBe("Moved Standup"); + + // Third occurrence: Monday March 16, 10:00 (unchanged) + expect(instances[2].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD HH:mm")).toBe("2026-03-16 10:00"); + }); + + it("should handle events with DURATION instead of DTEND", () => { + // RFC 5545 allows DURATION as alternative to DTEND + const data = ical.parseICS(`BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART:20260315T090000Z +DURATION:PT1H30M +UID:duration-test@test +SUMMARY:Duration Event +END:VEVENT +END:VCALENDAR`); + + const instances = CalendarFetcherUtils.expandRecurringEvent( + data["duration-test@test"], + moment("2026-03-14"), + moment("2026-03-16") + ); + + expect(instances).toHaveLength(1); + // End should be 90 minutes after start + const durationMinutes = instances[0].endMoment.diff(instances[0].startMoment, "minutes"); + expect(durationMinutes).toBe(90); + }); + + it("should handle recurring events with DURATION instead of DTEND", () => { + const data = ical.parseICS(`BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART;TZID=Europe/Berlin:20260301T080000 +DURATION:PT45M +RRULE:FREQ=DAILY;COUNT=3 +UID:recurring-duration@test +SUMMARY:Daily Scrum +END:VEVENT +END:VCALENDAR`); + + const instances = CalendarFetcherUtils.expandRecurringEvent( + data["recurring-duration@test"], + moment("2026-02-28"), + moment("2026-03-05") + ); + + expect(instances).toHaveLength(3); + for (const inst of instances) { + const durationMinutes = inst.endMoment.diff(inst.startMoment, "minutes"); + expect(durationMinutes).toBe(45); + } + expect(instances[0].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD")).toBe("2026-03-01"); + expect(instances[1].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD")).toBe("2026-03-02"); + expect(instances[2].startMoment.clone().tz("Europe/Berlin").format("YYYY-MM-DD")).toBe("2026-03-03"); + }); + }); + + describe("filterEvents error handling", () => { + it("should skip a broken event but still return other valid events", () => { + const start = moment().add(1, "hours").toDate(); + const end = moment().add(2, "hours").toDate(); + + vi.spyOn(ical, "expandRecurringEvent").mockImplementationOnce(() => { + throw new TypeError("invalid rrule"); + }); + + const result = CalendarFetcherUtils.filterEvents( + { + brokenEvent: { type: "VEVENT", start, end, summary: "Broken" }, + goodEvent: { type: "VEVENT", start, end, summary: "Good" } + }, + defaultConfig + ); + + expect(result).toHaveLength(1); + expect(result[0].title).toBe("Good"); + }); + + it("should let expandRecurringEvent throw through directly", () => { + vi.spyOn(ical, "expandRecurringEvent").mockImplementationOnce(() => { + throw new TypeError("invalid rrule"); + }); + + const event = { type: "VEVENT", start: new Date(), end: new Date(), summary: "Broken Event" }; + expect(() => CalendarFetcherUtils.expandRecurringEvent(event, moment(), moment().add(1, "days"))).toThrow("invalid rrule"); + }); + }); + + describe("unwrapParameterValue", () => { + it("should return the val of a ParameterValue object", () => { + expect(CalendarFetcherUtils.unwrapParameterValue({ val: "Text", params: { LANGUAGE: "de" } })).toBe("Text"); + }); + + it("should return a plain string unchanged", () => { + expect(CalendarFetcherUtils.unwrapParameterValue("plain")).toBe("plain"); + }); + + it("should return falsy values unchanged", () => { + expect(CalendarFetcherUtils.unwrapParameterValue(undefined)).toBeUndefined(); + expect(CalendarFetcherUtils.unwrapParameterValue(false)).toBe(false); + }); + }); + + describe("getTitleFromEvent", () => { + it("should return summary string directly", () => { + expect(CalendarFetcherUtils.getTitleFromEvent({ summary: "My Event" })).toBe("My Event"); + }); + + it("should unwrap ParameterValue summary", () => { + expect(CalendarFetcherUtils.getTitleFromEvent({ summary: { val: "My Event", params: {} } })).toBe("My Event"); + }); + + it("should fall back to description string", () => { + expect(CalendarFetcherUtils.getTitleFromEvent({ description: "Desc" })).toBe("Desc"); + }); + + it("should unwrap ParameterValue description as fallback title", () => { + expect(CalendarFetcherUtils.getTitleFromEvent({ description: { val: "Desc", params: { LANGUAGE: "de" } } })).toBe("Desc"); + }); + + it("should return 'Event' when neither summary nor description is present", () => { + expect(CalendarFetcherUtils.getTitleFromEvent({})).toBe("Event"); + }); + }); + + describe("filterEvents with ParameterValue properties", () => { + it("should handle DESCRIPTION;LANGUAGE=de and LOCATION;LANGUAGE=de without [object Object]", () => { + const start = moment().add(1, "hours").toDate(); + const end = moment().add(2, "hours").toDate(); + + const filteredEvents = CalendarFetcherUtils.filterEvents( + { + event1: { + type: "VEVENT", + start, + end, + summary: "Test", + description: { val: "Beschreibung", params: { LANGUAGE: "de" } }, + location: { val: "Berlin", params: { LANGUAGE: "de" } } + } + }, + defaultConfig + ); + + expect(filteredEvents).toHaveLength(1); + expect(filteredEvents[0].description).toBe("Beschreibung"); + expect(filteredEvents[0].location).toBe("Berlin"); + }); + }); +}); diff --git a/tests/unit/modules/default/calendar/calendar_utils_spec.js b/tests/unit/modules/default/calendar/calendar_utils_spec.js new file mode 100644 index 0000000..77d358d --- /dev/null +++ b/tests/unit/modules/default/calendar/calendar_utils_spec.js @@ -0,0 +1,155 @@ +global.moment = require("moment"); + +const defaults = require("../../../../../js/defaults"); + +const CalendarUtils = require(`../../../../../${defaults.defaultModulesDir}/calendar/calendarutils`); + +describe("Calendar utils tests", () => { + describe("capFirst", () => { + const words = { + rodrigo: "Rodrigo", + "123m": "123m", + "magic mirror": "Magic mirror", + ",a": ",a", + ñandú: "Ñandú" + }; + + Object.keys(words).forEach((word) => { + it(`for '${word}' should return '${words[word]}'`, () => { + expect(CalendarUtils.capFirst(word)).toBe(words[word]); + }); + }); + + it("should not capitalize other letters", () => { + expect(CalendarUtils.capFirst("event")).not.toBe("EVent"); + }); + }); + + describe("getLocaleSpecification", () => { + it("should return a valid moment.LocaleSpecification for a 12-hour format", () => { + expect(CalendarUtils.getLocaleSpecification(12)).toEqual({ longDateFormat: { LT: "h:mm A" } }); + }); + + it("should return a valid moment.LocaleSpecification for a 24-hour format", () => { + expect(CalendarUtils.getLocaleSpecification(24)).toEqual({ longDateFormat: { LT: "HH:mm" } }); + }); + + it("should return the current system locale when called without timeFormat number", () => { + expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: moment.localeData().longDateFormat("LT") } }); + }); + + it("should return a 12-hour longDateFormat when using the 'en' locale", () => { + const localeBackup = moment.locale(); + moment.locale("en"); + expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "h:mm A" } }); + moment.locale(localeBackup); + }); + + it("should return a 12-hour longDateFormat when using the 'au' locale", () => { + const localeBackup = moment.locale(); + moment.locale("au"); + expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "h:mm A" } }); + moment.locale(localeBackup); + }); + + it("should return a 12-hour longDateFormat when using the 'eg' locale", () => { + const localeBackup = moment.locale(); + moment.locale("eg"); + expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "h:mm A" } }); + moment.locale(localeBackup); + }); + + it("should return a 24-hour longDateFormat when using the 'nl' locale", () => { + const localeBackup = moment.locale(); + moment.locale("nl"); + expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "HH:mm" } }); + moment.locale(localeBackup); + }); + + it("should return a 24-hour longDateFormat when using the 'fr' locale", () => { + const localeBackup = moment.locale(); + moment.locale("fr"); + expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "HH:mm" } }); + moment.locale(localeBackup); + }); + + it("should return a 24-hour longDateFormat when using the 'uk' locale", () => { + const localeBackup = moment.locale(); + moment.locale("uk"); + expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "HH:mm" } }); + moment.locale(localeBackup); + }); + }); + + describe("shorten", () => { + it("should not shorten if short enough", () => { + expect(CalendarUtils.shorten("Event 1", 10, false, 1)).toBe("Event 1"); + }); + + it("should shorten into one line", () => { + expect(CalendarUtils.shorten("Example event at 12 o clock", 10, true, 1)).toBe("Example …"); + }); + + it("should shorten into three lines", () => { + expect(CalendarUtils.shorten("Example event at 12 o clock", 10, true, 3)).toBe("Example
event at 12 o
clock"); + }); + + it("should not shorten into three lines if wrap is false", () => { + expect(CalendarUtils.shorten("Example event at 12 o clock", 10, false, 3)).toBe("Example ev…"); + }); + + const strings = { + " String with whitespace at the beginning that needs trimming": { length: 16, return: "String with whit…" }, + "long string that needs shortening": { length: 16, return: "long string that…" }, + "short string": { length: 16, return: "short string" }, + "long string with no maxLength defined": { return: "long string with no maxLength defined" } + }; + + Object.keys(strings).forEach((string) => { + it(`for '${string}' should return '${strings[string].return}'`, () => { + expect(CalendarUtils.shorten(string, strings[string].length)).toBe(strings[string].return); + }); + }); + + it("should return an empty string if shorten is called with a non-string", () => { + expect(CalendarUtils.shorten(100)).toBe(""); + }); + + it("should not shorten the string if shorten is called with a non-number maxLength", () => { + expect(CalendarUtils.shorten("This is a test string", "This is not a number")).toBe("This is a test string"); + }); + + it("should wrap the string instead of shorten it if shorten is called with wrapEvents = true (with maxLength defined as 20)", () => { + expect(CalendarUtils.shorten("This is a wrapEvent test. Should wrap the string instead of shorten it if called with wrapEvent = true", 20, true)).toBe( + "This is a
wrapEvent test. Should wrap
the string instead of
shorten it if called with
wrapEvent = true" + ); + }); + + it("should wrap the string instead of shorten it if shorten is called with wrapEvents = true (without maxLength defined, default 25)", () => { + expect(CalendarUtils.shorten("This is a wrapEvent test. Should wrap the string instead of shorten it if called with wrapEvent = true", undefined, true)).toBe( + "This is a wrapEvent
test. Should wrap the string
instead of shorten it if called
with wrapEvent = true" + ); + }); + + it("should wrap and shorten the string in the second line if called with wrapEvents = true and maxTitleLines = 2", () => { + expect(CalendarUtils.shorten("This is a wrapEvent and maxTitleLines test. Should wrap and shorten the string in the second line if called with wrapEvents = true and maxTitleLines = 2", undefined, true, 2)).toBe( + "This is a wrapEvent and
maxTitleLines test. Should wrap and …" + ); + }); + }); + + describe("titleTransform and shorten combined", () => { + it("should replace the birthday and wrap nicely", () => { + const transformedTitle = CalendarUtils.titleTransform("Michael Teeuw's birthday", [{ search: "'s birthday", replace: "" }]); + expect(CalendarUtils.shorten(transformedTitle, 10, true, 2)).toBe("Michael
Teeuw"); + }); + }); + + describe("titleTransform with yearmatchgroup", () => { + it("should replace the birthday and wrap nicely", () => { + const transformedTitle = CalendarUtils.titleTransform("Luciella '2000", [{ search: "^([^']*) '(\\d{4})$", replace: "$1 ($2.)", yearmatchgroup: 2 }]); + const expectedResult = `Luciella (${new Date().getFullYear() - 2000}.)`; + expect(transformedTitle).toBe(expectedResult); + }); + }); +}); diff --git a/tests/unit/modules/default/compliments/compliments_spec.js b/tests/unit/modules/default/compliments/compliments_spec.js new file mode 100644 index 0000000..c1b76fc --- /dev/null +++ b/tests/unit/modules/default/compliments/compliments_spec.js @@ -0,0 +1,153 @@ +describe("Compliments module", () => { + let complimentsModule; + + beforeEach(() => { + // Mock global dependencies + global.Module = { + register: vi.fn((name, moduleDefinition) => { + complimentsModule = moduleDefinition; + }) + }; + global.Log = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn() + }; + global.Cron = vi.fn(); + + // Load the module + const defaults = require("../../../../../js/defaults"); + require(`../../../../../${defaults.defaultModulesDir}/compliments/compliments`); + + // Setup module instance + complimentsModule.config = { ...complimentsModule.defaults }; + complimentsModule.name = "compliments"; + complimentsModule.file = vi.fn((path) => `http://localhost:8080/${defaults.defaultModulesDir}/compliments/${path}`); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("loadComplimentFile", () => { + describe("HTTP error handling", () => { + it("should return null and log error on HTTP 404", async () => { + complimentsModule.config.remoteFile = "http://example.com/missing.json"; + + global.fetch = vi.fn(() => Promise.resolve({ + ok: false, + status: 404, + statusText: "Not Found" + })); + + const result = await complimentsModule.loadComplimentFile(); + + expect(result).toBeNull(); + expect(Log.error).toHaveBeenCalledWith("[compliments] HTTP error: 404 Not Found"); + }); + + it("should return null and log error on HTTP 500", async () => { + complimentsModule.config.remoteFile = "http://example.com/error.json"; + + global.fetch = vi.fn(() => Promise.resolve({ + ok: false, + status: 500, + statusText: "Internal Server Error" + })); + + const result = await complimentsModule.loadComplimentFile(); + + expect(result).toBeNull(); + expect(Log.error).toHaveBeenCalledWith("[compliments] HTTP error: 500 Internal Server Error"); + }); + + it("should return content on successful HTTP 200", async () => { + complimentsModule.config.remoteFile = "http://example.com/compliments.json"; + const expectedContent = "{\"anytime\":[\"Test compliment\"]}"; + + global.fetch = vi.fn(() => Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve(expectedContent) + })); + + const result = await complimentsModule.loadComplimentFile(); + + expect(result).toBe(expectedContent); + expect(Log.error).not.toHaveBeenCalled(); + }); + }); + + describe("Cache-busting with query parameters", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-01-01T12:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should add cache-busting parameter to URL without query params", async () => { + complimentsModule.config.remoteFile = "http://example.com/compliments.json"; + complimentsModule.config.remoteFileRefreshInterval = 60000; + + global.fetch = vi.fn(() => Promise.resolve({ + ok: true, + text: () => Promise.resolve("{}") + })); + + await complimentsModule.loadComplimentFile(); + + expect(fetch).toHaveBeenCalledWith(expect.stringContaining("?dummy=1735732800000")); + }); + + it("should add cache-busting parameter to URL with existing query params", async () => { + complimentsModule.config.remoteFile = "http://example.com/compliments.json?lang=en"; + complimentsModule.config.remoteFileRefreshInterval = 60000; + + global.fetch = vi.fn(() => Promise.resolve({ + ok: true, + text: () => Promise.resolve("{}") + })); + + await complimentsModule.loadComplimentFile(); + + const calledUrl = fetch.mock.calls[0][0]; + expect(calledUrl).toContain("lang=en"); + expect(calledUrl).toContain("&dummy=1735732800000"); + expect(calledUrl).not.toContain("?dummy="); + }); + + it("should not add cache-busting parameter when remoteFileRefreshInterval is 0", async () => { + complimentsModule.config.remoteFile = "http://example.com/compliments.json"; + complimentsModule.config.remoteFileRefreshInterval = 0; + + global.fetch = vi.fn(() => Promise.resolve({ + ok: true, + text: () => Promise.resolve("{}") + })); + + await complimentsModule.loadComplimentFile(); + + expect(fetch).toHaveBeenCalledWith("http://example.com/compliments.json"); + }); + + it("should not add cache-busting parameter to local files", async () => { + complimentsModule.config.remoteFile = "compliments.json"; + complimentsModule.config.remoteFileRefreshInterval = 60000; + + global.fetch = vi.fn(() => Promise.resolve({ + ok: true, + text: () => Promise.resolve("{}") + })); + + await complimentsModule.loadComplimentFile(); + + const calledUrl = fetch.mock.calls[0][0]; + expect(calledUrl).toBe("http://localhost:8080/defaultmodules/compliments/compliments.json"); + expect(calledUrl).not.toContain("dummy="); + }); + }); + }); +}); diff --git a/tests/unit/modules/default/newsfeed/newsfeedfetcher_spec.js b/tests/unit/modules/default/newsfeed/newsfeedfetcher_spec.js new file mode 100644 index 0000000..7d19584 --- /dev/null +++ b/tests/unit/modules/default/newsfeed/newsfeedfetcher_spec.js @@ -0,0 +1,83 @@ +const defaults = require("../../../../../js/defaults"); + +const NewsfeedFetcher = require(`../../../../../${defaults.defaultModulesDir}/newsfeed/newsfeedfetcher`); + +// The full safe list users may opt into; most tests run with it enabled. +const ALL_TAGS = ["b", "strong", "i", "em", "u", "br", "code", "s", "sub", "sup"]; +const sanitize = (html, allowedTags = ALL_TAGS) => NewsfeedFetcher.sanitizeBasicHtml(html, allowedTags); + +describe("NewsfeedFetcher.sanitizeBasicHtml", () => { + it("keeps real basic formatting tags", () => { + expect(sanitize("a b c d e")) + .toBe("a b c d e"); + }); + + it("keeps the additional safe tags (code, s, sub, sup)", () => { + expect(sanitize("x y z w")) + .toBe("x y z w"); + }); + + it("renders entity-encoded formatting tags (e.g. The Atlantic feed)", () => { + // Feeds like theatlantic.com ship emphasis as escaped entities + expect(sanitize("the <em>Atlantic</em> ocean")).toBe("the Atlantic ocean"); + }); + + it("handles emphasis inside titles regardless of how the parser delivers it", () => { + // The Atlantic uses in titles, e.g. "That's Enough, Euphoria" + const expected = "That’s Enough, Euphoria"; + expect(sanitize("That’s Enough, Euphoria")).toBe(expected); + expect(sanitize("That’s Enough, <em>Euphoria</em>")).toBe(expected); + }); + + it("strips attributes from allowed tags", () => { + const result = sanitize("bold"); + expect(result).toBe("bold"); + expect(result).not.toContain("onclick"); + expect(result).not.toContain("class"); + }); + + it("neutralizes script tags", () => { + expect(sanitize("hello")).not.toContain(" { + const result = sanitize("link

title

"); + expect(result).not.toContain("onerror"); + expect(result).not.toContain("href"); + expect(result).not.toContain("

"); + expect(result).toContain("link"); + expect(result.toLowerCase()).toContain("title"); + }); + + it("escapes bare HTML special characters in plain text", () => { + expect(sanitize("Fish & Chips for < 5")).toBe("Fish & Chips for < 5"); + }); + + it("only keeps tags present in the supplied allowlist", () => { + // Allow just : a safe-but-not-allowed must become plain text. + const result = sanitize("kept dropped", ["em"]); + expect(result).toBe("kept dropped"); + expect(result).not.toContain(""); + }); + + it("escapes everything when the allowlist is empty", () => { + expect(sanitize("hi & bye", [])).toBe("hi & bye"); + }); + + it("renders
as a single self-closing tag when allowed", () => { + const result = sanitize("a
b", ["br"]); + expect(result).toContain("
"); + expect(result).not.toContain("

"); + expect(result).not.toContain("<br>"); + }); + + it("collapses
to a space when not allowed", () => { + const result = sanitize("a
b", ["em"]); + expect(result).not.toContain("
"); + expect(result).toBe("a b"); + }); +}); diff --git a/tests/unit/modules/default/utils_spec.js b/tests/unit/modules/default/utils_spec.js new file mode 100644 index 0000000..e5d0163 --- /dev/null +++ b/tests/unit/modules/default/utils_spec.js @@ -0,0 +1,54 @@ +global.moment = require("moment-timezone"); +const defaults = require("../../../../js/defaults"); + +const { formatTime } = require(`../../../../${defaults.defaultModulesDir}/utils`); + +describe("Default modules utils tests", () => { + describe("formatTime", () => { + const time = new Date(); + + beforeEach(() => { + time.setHours(13, 13); + }); + + it("should convert correctly according to the config", () => { + expect( + formatTime( + { + timeFormat: 24 + }, + time + ) + ).toBe("13:13"); + expect( + formatTime( + { + showPeriod: true, + showPeriodUpper: true, + timeFormat: 12 + }, + time + ) + ).toBe("1:13 PM"); + expect( + formatTime( + { + showPeriod: true, + showPeriodUpper: false, + timeFormat: 12 + }, + time + ) + ).toBe("1:13 pm"); + expect( + formatTime( + { + showPeriod: false, + timeFormat: 12 + }, + time + ) + ).toBe("1:13"); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/node_helper_spec.js b/tests/unit/modules/default/weather/node_helper_spec.js new file mode 100644 index 0000000..2f18e96 --- /dev/null +++ b/tests/unit/modules/default/weather/node_helper_spec.js @@ -0,0 +1,116 @@ +import Module from "node:module"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +/** + * Creates a fresh weather node helper instance with isolated mocks. + * @returns {Promise} The mocked weather node helper. + */ +async function loadWeatherNodeHelper () { + vi.resetModules(); + + const loggerMock = { + log: vi.fn(), + warn: vi.fn(), + error: vi.fn() + }; + const originalRequire = Module.prototype.require; + + Module.prototype.require = function (id) { + if (id === "node_helper") { + return { + create: vi.fn((definition) => definition) + }; + } + + if (id === "logger") { + return loggerMock; + } + + return originalRequire.apply(this, arguments); + }; + + let helper; + try { + const helperModule = await import("../../../../../defaultmodules/weather/node_helper"); + helper = helperModule.default || helperModule; + } finally { + Module.prototype.require = originalRequire; + } + + helper.providers = {}; + helper.lastData = {}; + helper.sendSocketNotification = vi.fn(); + + return helper; +} + +afterEach(() => { + vi.resetAllMocks(); + vi.resetModules(); +}); + +describe("weather node_helper reconnect handling", () => { + it("re-sends cached weather data when a client reconnects", async () => { + const helper = await loadWeatherNodeHelper(); + const instanceId = "weather-current"; + const cachedPayload = { + instanceId, + type: "current", + data: { temperature: 8.5 } + }; + + helper.providers[instanceId] = { locationName: "Munich, BY" }; + helper.lastData[instanceId] = cachedPayload; + + await helper.initWeatherProvider({ + weatherProvider: "openmeteo", + instanceId, + type: "current" + }); + + expect(helper.sendSocketNotification).toHaveBeenNthCalledWith(1, "WEATHER_INITIALIZED", { + instanceId, + locationName: "Munich, BY" + }); + expect(helper.sendSocketNotification).toHaveBeenNthCalledWith(2, "WEATHER_DATA", cachedPayload); + expect(helper.sendSocketNotification).toHaveBeenCalledTimes(2); + }); + + it("does not send WEATHER_DATA on reconnect when no cached payload exists", async () => { + const helper = await loadWeatherNodeHelper(); + const instanceId = "weather-current"; + + helper.providers[instanceId] = { locationName: "Munich, BY" }; + + await helper.initWeatherProvider({ + weatherProvider: "openmeteo", + instanceId, + type: "current" + }); + + expect(helper.sendSocketNotification).toHaveBeenCalledWith("WEATHER_INITIALIZED", { + instanceId, + locationName: "Munich, BY" + }); + expect(helper.sendSocketNotification).toHaveBeenCalledTimes(1); + }); + + it("cleans up provider and cached data when stopping an instance", async () => { + const helper = await loadWeatherNodeHelper(); + const instanceId = "weather-current"; + const stop = vi.fn(); + + helper.providers[instanceId] = { stop }; + helper.lastData[instanceId] = { + instanceId, + type: "current", + data: { temperature: 8.5 } + }; + + helper.stopWeatherProvider(instanceId); + + expect(stop).toHaveBeenCalledTimes(1); + expect(helper.providers[instanceId]).toBeUndefined(); + expect(helper.lastData[instanceId]).toBeUndefined(); + }); +}); diff --git a/tests/unit/modules/default/weather/provider_utils_spec.js b/tests/unit/modules/default/weather/provider_utils_spec.js new file mode 100644 index 0000000..511a843 --- /dev/null +++ b/tests/unit/modules/default/weather/provider_utils_spec.js @@ -0,0 +1,167 @@ +const defaults = require("../../../../../js/defaults"); + +const providerUtils = require(`../../../../../${defaults.defaultModulesDir}/weather/provider-utils`); + +describe("Weather provider utils tests", () => { + describe("convertWeatherType", () => { + it("should convert OpenWeatherMap day icons correctly", () => { + expect(providerUtils.convertWeatherType("01d")).toBe("day-sunny"); + expect(providerUtils.convertWeatherType("02d")).toBe("day-cloudy"); + expect(providerUtils.convertWeatherType("10d")).toBe("rain"); + expect(providerUtils.convertWeatherType("13d")).toBe("snow"); + }); + + it("should convert OpenWeatherMap night icons correctly", () => { + expect(providerUtils.convertWeatherType("01n")).toBe("night-clear"); + expect(providerUtils.convertWeatherType("02n")).toBe("night-cloudy"); + expect(providerUtils.convertWeatherType("10n")).toBe("night-rain"); + }); + + it("should return null for unknown weather types", () => { + expect(providerUtils.convertWeatherType("99x")).toBeNull(); + expect(providerUtils.convertWeatherType("")).toBeNull(); + }); + }); + + describe("applyTimezoneOffset", () => { + it("should apply positive offset correctly", () => { + const date = new Date("2026-02-02T12:00:00Z"); + const result = providerUtils.applyTimezoneOffset(date, 120); // +2 hours + // The function converts to UTC, then applies offset + const expected = new Date(date.getTime() + date.getTimezoneOffset() * 60000 + 120 * 60000); + expect(result.getTime()).toBe(expected.getTime()); + }); + + it("should apply negative offset correctly", () => { + const date = new Date("2026-02-02T12:00:00Z"); + const result = providerUtils.applyTimezoneOffset(date, -300); // -5 hours + const expected = new Date(date.getTime() + date.getTimezoneOffset() * 60000 - 300 * 60000); + expect(result.getTime()).toBe(expected.getTime()); + }); + + it("should handle zero offset", () => { + const date = new Date("2026-02-02T12:00:00Z"); + const result = providerUtils.applyTimezoneOffset(date, 0); + const expected = new Date(date.getTime() + date.getTimezoneOffset() * 60000); + expect(result.getTime()).toBe(expected.getTime()); + }); + }); + + describe("limitDecimals", () => { + it("should truncate decimals correctly", () => { + expect(providerUtils.limitDecimals(12.3456789, 4)).toBe(12.3456); + expect(providerUtils.limitDecimals(12.3456789, 2)).toBe(12.34); + }); + + it("should handle values with fewer decimals than limit", () => { + expect(providerUtils.limitDecimals(12.34, 6)).toBe(12.34); + expect(providerUtils.limitDecimals(12, 4)).toBe(12); + }); + + it("should handle negative values", () => { + expect(providerUtils.limitDecimals(-12.3456789, 2)).toBe(-12.34); + }); + + it("should truncate not round", () => { + expect(providerUtils.limitDecimals(12.9999, 2)).toBe(12.99); + expect(providerUtils.limitDecimals(12.9999, 0)).toBe(12); + }); + }); + + describe("getSunTimes", () => { + it("should return sunrise and sunset times", () => { + const date = new Date("2026-06-21T12:00:00Z"); // Summer solstice + const lat = 52.52; // Berlin + const lon = 13.405; + + const result = providerUtils.getSunTimes(date, lat, lon); + + expect(result).toHaveProperty("sunrise"); + expect(result).toHaveProperty("sunset"); + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + expect(result.sunrise.getTime()).toBeLessThan(result.sunset.getTime()); + }); + + it("should handle different locations", () => { + const date = new Date("2026-06-21T12:00:00Z"); + + // London + const london = providerUtils.getSunTimes(date, 51.5074, -0.1278); + // Tokyo + const tokyo = providerUtils.getSunTimes(date, 35.6762, 139.6503); + + expect(london.sunrise.getTime()).not.toBe(tokyo.sunrise.getTime()); + }); + }); + + describe("isDayTime", () => { + it("should return true when time is between sunrise and sunset", () => { + const sunrise = new Date("2026-02-02T07:00:00Z"); + const sunset = new Date("2026-02-02T17:00:00Z"); + const noon = new Date("2026-02-02T12:00:00Z"); + + expect(providerUtils.isDayTime(noon, sunrise, sunset)).toBe(true); + }); + + it("should return false when time is before sunrise", () => { + const sunrise = new Date("2026-02-02T07:00:00Z"); + const sunset = new Date("2026-02-02T17:00:00Z"); + const night = new Date("2026-02-02T03:00:00Z"); + + expect(providerUtils.isDayTime(night, sunrise, sunset)).toBe(false); + }); + + it("should return false when time is after sunset", () => { + const sunrise = new Date("2026-02-02T07:00:00Z"); + const sunset = new Date("2026-02-02T17:00:00Z"); + const night = new Date("2026-02-02T20:00:00Z"); + + expect(providerUtils.isDayTime(night, sunrise, sunset)).toBe(false); + }); + + it("should return true if sunrise/sunset are null", () => { + const noon = new Date("2026-02-02T12:00:00Z"); + expect(providerUtils.isDayTime(noon, null, null)).toBe(true); + }); + }); + + describe("formatTimezoneOffset", () => { + it("should format positive offsets correctly", () => { + expect(providerUtils.formatTimezoneOffset(60)).toBe("+01:00"); + expect(providerUtils.formatTimezoneOffset(120)).toBe("+02:00"); + expect(providerUtils.formatTimezoneOffset(330)).toBe("+05:30"); // India + }); + + it("should format negative offsets correctly", () => { + expect(providerUtils.formatTimezoneOffset(-300)).toBe("-05:00"); // EST + expect(providerUtils.formatTimezoneOffset(-480)).toBe("-08:00"); // PST + }); + + it("should format zero offset correctly", () => { + expect(providerUtils.formatTimezoneOffset(0)).toBe("+00:00"); + }); + + it("should pad single digits with zero", () => { + expect(providerUtils.formatTimezoneOffset(5)).toBe("+00:05"); + expect(providerUtils.formatTimezoneOffset(-5)).toBe("-00:05"); + }); + }); + + describe("getDateString", () => { + it("should format date as YYYY-MM-DD (local time)", () => { + const date = new Date(2026, 1, 2, 12, 34, 56); // Feb 2, 2026 (month is 0-indexed) + expect(providerUtils.getDateString(date)).toBe("2026-02-02"); + }); + + it("should handle single-digit months and days correctly", () => { + const date = new Date(2026, 0, 5, 12, 0, 0); // Jan 5, 2026 + expect(providerUtils.getDateString(date)).toBe("2026-01-05"); + }); + + it("should handle end of year", () => { + const date = new Date(2025, 11, 31, 23, 59, 59); // Dec 31, 2025 + expect(providerUtils.getDateString(date)).toBe("2025-12-31"); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/buienradar_spec.js b/tests/unit/modules/default/weather/providers/buienradar_spec.js new file mode 100644 index 0000000..25366f7 --- /dev/null +++ b/tests/unit/modules/default/weather/providers/buienradar_spec.js @@ -0,0 +1,243 @@ +/** + * Buienradar Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + * Buienradar covers NL/BE only, metric system, no API key required. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +const BUIENRADAR_URL = "https://forecast.buienradar.nl/2.0/forecast/*"; + +/** + * Builds a stable Buienradar mock payload for parsing tests. + * @returns {object} Buienradar forecast response fixture. + */ +function buildBuienradarResponse () { + const today = "2100-01-01"; + const tomorrow = "2100-01-02"; + + const makeHour = (datetime, overrides = {}) => ({ + datetime, + temperature: 5, + feeltemperature: 3, + humidity: 80, + windspeedms: 4, + winddirectiondegrees: 180, + precipitationmm: 0.5, + precipitation: 60, + iconcode: "a", + ...overrides + }); + + return { + location: { + name: "Rotterdam", + lat: 51.92, + lon: 4.48 + }, + days: [ + { + date: today, + sunrise: `${today}T07:00:00`, + sunset: `${today}T17:00:00`, + mintemp: 1, + maxtemp: 8, + humidity: 75, + windspeedms: 5, + winddirectiondegrees: 200, + precipitationmm: 2.5, + precipitation: 70, + iconcode: "b", + hours: [ + makeHour(`${today}T12:00:00`, { temperature: 6, iconcode: "a" }), + makeHour(`${today}T13:00:00`, { temperature: 7, iconcode: "b" }), + makeHour(`${today}T14:00:00`, { temperature: 7.5, iconcode: "c" }) + ] + }, + { + date: tomorrow, + sunrise: `${tomorrow}T07:01:00`, + sunset: `${tomorrow}T17:02:00`, + mintemp: 0, + maxtemp: 6, + windspeedms: 6, + winddirectiondegrees: 220, + precipitationmm: 1.0, + precipitation: 40, + iconcode: "f", + hours: [] + } + ] + }; +} + +let server; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("BuienradarProvider", () => { + let BuienradarProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/buienradar"); + BuienradarProvider = module.default; + }); + + describe("Constructor & Configuration", () => { + it("should apply defaults and merge params", () => { + const provider = new BuienradarProvider({ locationId: 6275, type: "hourly" }); + expect(provider.config.apiBase).toBe("https://forecast.buienradar.nl/2.0/forecast"); + expect(provider.config.locationId).toBe(6275); + expect(provider.config.type).toBe("hourly"); + expect(provider.config.updateInterval).toBe(10 * 60 * 1000); + }); + + it("should call error callback when locationId is missing", () => { + const provider = new BuienradarProvider({}); + const onError = vi.fn(); + provider.setCallbacks(vi.fn(), onError); + provider.initialize(); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ + translationKey: "MODULE_ERROR_UNSPECIFIED" + })); + expect(provider.fetcher).toBeNull(); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather, set locationName, and merge day metadata", async () => { + const provider = new BuienradarProvider({ locationId: 6275, type: "current" }); + + const dataPromise = new Promise((resolve, reject) => { + provider.setCallbacks(resolve, reject); + }); + + server.use( + http.get(BUIENRADAR_URL, () => HttpResponse.json(buildBuienradarResponse())) + ); + + provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(provider.locationName).toBe("Rotterdam"); + expect(typeof result.temperature).toBe("number"); + expect(result.humidity).toBe(80); + expect(result.windSpeed).toBe(4); + expect(result.windFromDirection).toBe(180); + expect(result.minTemperature).toBe(1); + expect(result.maxTemperature).toBe(8); + expect(result.precipitationAmount).toBe(0.5); + expect(result.precipitationProbability).toBe(60); + expect(result.precipitationUnits).toBe("mm"); + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + expect(typeof result.weatherType).toBe("string"); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new BuienradarProvider({ locationId: 6275, type: "forecast", maxEntries: 2 }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(BUIENRADAR_URL, () => HttpResponse.json(buildBuienradarResponse())) + ); + + provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(2); + + expect(result[0].minTemperature).toBe(1); + expect(result[0].maxTemperature).toBe(8); + expect(result[0].humidity).toBe(75); + expect(result[0].windSpeed).toBe(5); + expect(result[0].windFromDirection).toBe(200); + expect(result[0].precipitationAmount).toBe(2.5); + expect(result[0].precipitationProbability).toBe(70); + expect(result[0].sunrise).toBeInstanceOf(Date); + expect(result[0].sunset).toBeInstanceOf(Date); + expect(result[0].weatherType).toBe("day-cloudy"); + + expect(result[1].minTemperature).toBe(0); + expect(result[1].maxTemperature).toBe(6); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast data", async () => { + const provider = new BuienradarProvider({ locationId: 6275, type: "hourly", maxEntries: 3 }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(BUIENRADAR_URL, () => HttpResponse.json(buildBuienradarResponse())) + ); + + provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(3); + expect(result[0].temperature).toBe(6); + expect(result[0].feelsLikeTemp).toBe(3); + expect(result[0].humidity).toBe(80); + expect(result[0].windSpeed).toBe(4); + expect(result[0].windFromDirection).toBe(180); + expect(result[0].precipitationAmount).toBe(0.5); + expect(result[0].precipitationProbability).toBe(60); + expect(result[0].weatherType).toBe("day-sunny"); + expect(result[0].date).toBeInstanceOf(Date); + + expect(result[1].weatherType).toBe("day-cloudy"); + expect(result[2].weatherType).toBe("cloudy"); + }); + }); + + describe("Error Handling", () => { + it("should call error callback on invalid API response", async () => { + const provider = new BuienradarProvider({ locationId: 6275, type: "current" }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(BUIENRADAR_URL, () => HttpResponse.json({ days: [] })) + ); + + provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error).toHaveProperty("message"); + expect(error).toHaveProperty("translationKey"); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/envcanada_spec.js b/tests/unit/modules/default/weather/providers/envcanada_spec.js new file mode 100644 index 0000000..6308671 --- /dev/null +++ b/tests/unit/modules/default/weather/providers/envcanada_spec.js @@ -0,0 +1,309 @@ +/** + * Environment Canada Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + * Environment Canada is the Canadian weather service (XML-based). + */ + +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const indexHTML = fs.readFileSync(path.join(__dirname, "../../../../../mocks/weather_envcanada_index.html"), "utf-8"); +const cityPageXML = fs.readFileSync(path.join(__dirname, "../../../../../mocks/weather_envcanada.xml"), "utf-8"); + +// Match directory listing (index) - must end with / and nothing after +const ENVCANADA_INDEX_PATTERN = /https:\/\/dd\.weather\.gc\.ca\/today\/citypage_weather\/[A-Z]{2}\/\d{2}\/$/; +// Match actual XML files +const ENVCANADA_CITYPAGE_PATTERN = /https:\/\/dd\.weather\.gc\.ca\/today\/citypage_weather\/[A-Z]{2}\/\d{2}\/.*\.xml$/; + +let server; + +beforeAll(() => { + server = setupServer( + http.get(ENVCANADA_INDEX_PATTERN, () => { + return new HttpResponse(indexHTML, { + headers: { "Content-Type": "text/html" } + }); + }), + http.get(ENVCANADA_CITYPAGE_PATTERN, () => { + return new HttpResponse(cityPageXML, { + headers: { "Content-Type": "application/xml" } + }); + }) + ); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("EnvCanadaProvider", () => { + let EnvCanadaProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/envcanada"); + EnvCanadaProvider = module.default || module; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "current" + }); + expect(provider.config.siteCode).toBe("s0000458"); + expect(provider.config.provCode).toBe("ON"); + expect(provider.config.type).toBe("current"); + }); + + it("should throw error if siteCode or provCode missing", () => { + const provider = new EnvCanadaProvider({ siteCode: "", provCode: "" }); + provider.setCallbacks(vi.fn(), vi.fn()); + expect(() => provider.initialize()).toThrow("siteCode and provCode are required"); + }); + }); + + describe("Two-Step Fetch Pattern", () => { + it("should first fetch index page then city page", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + expect(result).toBeDefined(); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather from XML", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "current" + }); + + const dataPromise = new Promise((resolve, reject) => { + provider.setCallbacks( + (data) => { + resolve(data); + }, + (error) => { + reject(error); + } + ); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.temperature).toBe(-20.3); + expect(result.windSpeed).toBeCloseTo(5.28, 1); // 19 km/h -> m/s + expect(result.windFromDirection).toBe(346); // NNW + expect(result.humidity).toBe(67); + }); + + it("should use wind chill for feels like temperature when available", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // XML has windChill of -31 + expect(result.feelsLikeTemp).toBe(-31); + }); + + it("should parse sunrise/sunset from XML", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + + it("should convert icon code to weather type", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Icon code 40 = "Blowing Snow" → "snow-wind" + expect(result.weatherType).toBe("snow-wind"); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast from XML", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const day = result[0]; + expect(day).toHaveProperty("date"); + expect(day).toHaveProperty("minTemperature"); + expect(day).toHaveProperty("maxTemperature"); + expect(day).toHaveProperty("precipitationProbability"); + expect(day).toHaveProperty("weatherType"); + }); + + it("should extract max precipitation probability from day/night", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Real data has 40% for both day and night periods + expect(result[0].precipitationProbability).toBe(40); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast from XML", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(24); // Real data has 24 hourly forecasts + const hour = result[0]; + expect(hour).toHaveProperty("date"); + expect(hour).toHaveProperty("temperature"); + expect(hour).toHaveProperty("precipitationProbability"); + expect(hour).toHaveProperty("weatherType"); + }); + + it("should parse EC time format correctly", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s0000458", + provCode: "ON", + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // First hourly forecast is for 202602071300 = 2026-02-07 13:00 UTC + const expectedDate = new Date(Date.UTC(2026, 1, 7, 13, 0, 0)); + expect(result[0].date.getTime()).toBe(expectedDate.getTime()); + }); + }); + + describe("Error Handling", () => { + it("should handle missing city page URL", async () => { + const provider = new EnvCanadaProvider({ + siteCode: "s9999999", // Invalid site code + provCode: "ON", + type: "current" + }); + + let errorCalled = false; + provider.setCallbacks(vi.fn(), () => { + errorCalled = true; + }); + + await provider.initialize(); + provider.start(); + + // Should not call error callback if URL not found (it's expected during hour transitions) + // Wait a bit to see if callback is called + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(errorCalled).toBe(false); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/openmeteo_spec.js b/tests/unit/modules/default/weather/providers/openmeteo_spec.js new file mode 100644 index 0000000..225a970 --- /dev/null +++ b/tests/unit/modules/default/weather/providers/openmeteo_spec.js @@ -0,0 +1,343 @@ +/** + * OpenMeteo Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + * Uses MSW to mock HTTP responses from the Open-Meteo API. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +import openMeteoData from "../../../../../mocks/weather_openmeteo_current.json" with { type: "json" }; +import openMeteoCurrentWeatherData from "../../../../../mocks/weather_openmeteo_current_weather.json" with { type: "json" }; +// Real API returns current + forecast in one response +const currentData = openMeteoCurrentWeatherData; +const forecastData = openMeteoData; + +const GEOCODE_URL = "https://api.bigdatacloud.net/data/reverse-geocode-client*"; + +let server; + +beforeAll(() => { + // Mock global fetch for geocoding (used by provider's #fetchLocation) + server = setupServer( + http.get(GEOCODE_URL, () => { + return HttpResponse.json({ + city: "Munich", + locality: "Munich", + principalSubdivisionCode: "BY" + }); + }) + ); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("OpenMeteoProvider", () => { + let OpenMeteoProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/openmeteo"); + OpenMeteoProvider = module.default; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "current" + }); + expect(provider.config.lat).toBe(48.14); + expect(provider.config.lon).toBe(11.58); + expect(provider.config.type).toBe("current"); + }); + + it("should have default values", () => { + const provider = new OpenMeteoProvider({}); + expect(provider.config.lat).toBe(0); + expect(provider.config.lon).toBe(0); + expect(provider.config.type).toBe("current"); + expect(provider.config.maxNumberOfDays).toBe(5); + expect(provider.config.apiBase).toBe("https://api.open-meteo.com/v1"); + }); + + it("should initialize without callbacks", async () => { + const provider = new OpenMeteoProvider({ lat: 48.14, lon: 11.58 }); + await expect(provider.initialize()).resolves.not.toThrow(); + }); + + it("should resolve location name via geocoding", async () => { + const provider = new OpenMeteoProvider({ lat: 48.14, lon: 11.58 }); + await provider.initialize(); + expect(provider.locationName).toBe("Munich, BY"); + }); + + it("should use forecast_days instead of static start_date/end_date", async () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "current" + }); + + await provider.initialize(); + + const url = new URL(provider.fetcher.url); + const params = url.searchParams; + + expect(params.get("forecast_days")).toBe("1"); + expect(params.has("start_date")).toBe(false); + expect(params.has("end_date")).toBe(false); + }); + + it("should set forecast_days based on maxNumberOfDays for forecast type", async () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "forecast", + maxNumberOfDays: 5 + }); + + await provider.initialize(); + + const url = new URL(provider.fetcher.url); + const params = url.searchParams; + + expect(params.get("forecast_days")).toBe("6"); // 5 days + 1 + expect(params.has("start_date")).toBe(false); + expect(params.has("end_date")).toBe(false); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather data correctly", async () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "current" + }); + + const dataPromise = new Promise((resolve, reject) => { + provider.setCallbacks( + (data) => { + resolve(data); + }, + (error) => { + reject(error); + } + ); + }); + + server.use( + http.get("https://api.open-meteo.com/v1/forecast*", () => { + return HttpResponse.json(currentData); + }) + ); + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + const currentHourUnix = Math.floor(currentData.current_weather.time / 3600) * 3600; + const currentHourIndex = currentData.hourly.time.findIndex((time) => time === currentHourUnix); + + expect(result).toBeDefined(); + expect(result.temperature).toBe(8.5); + expect(result.windSpeed).toBeCloseTo(4.7, 1); + expect(result.windFromDirection).toBe(9); + expect(result.humidity).toBe(currentData.hourly.relativehumidity_2m[currentHourIndex]); + expect(result.humidity).not.toBe(currentData.hourly.relativehumidity_2m[0]); + }); + + it("should include sunrise and sunset from daily data", async () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "current" + }); + + const dataPromise = new Promise((resolve, reject) => { + provider.setCallbacks( + (data) => { + resolve(data); + }, + (error) => { + reject(error); + } + ); + }); + + server.use( + http.get("https://api.open-meteo.com/v1/forecast*", () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + expect(result.minTemperature).toBe(4.7); + expect(result.maxTemperature).toBe(9.5); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data correctly", async () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.open-meteo.com/v1/forecast*", () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(7); + const firstDay = result[0]; + expect(firstDay.minTemperature).toBe(-9.2); + expect(firstDay.maxTemperature).toBe(-0.2); + expect(firstDay.temperature).toBeCloseTo(-4.7, 0); // (-0.2+-9.2)/2 + + expect(firstDay.sunrise).toBeInstanceOf(Date); + expect(firstDay.sunset).toBeInstanceOf(Date); + }); + + it("should include precipitation data in forecast", async () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.open-meteo.com/v1/forecast*", () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Mock data has no rain_sum field - provider returns null for missing data + expect(result[0].rain).toBeNull(); + // precipitation_sum has value 0.0 in mock data + expect(result[0].precipitationAmount).toBe(0.0); + }); + }); + + describe("Error Handling", () => { + it("should call error callback on invalid API response", async () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get("https://api.open-meteo.com/v1/forecast*", () => { + return HttpResponse.json({}); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error).toHaveProperty("message"); + expect(error).toHaveProperty("translationKey"); + }); + + it("should call error callback on network failure", async () => { + const provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get("https://api.open-meteo.com/v1/forecast*", () => { + return HttpResponse.error(); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error).toHaveProperty("url"); + }); + }); + + describe("Callback Interface", () => { + it("should store callbacks via setCallbacks", () => { + const provider = new OpenMeteoProvider({}); + const onData = vi.fn(); + const onError = vi.fn(); + provider.setCallbacks(onData, onError); + expect(provider.onDataCallback).toBe(onData); + expect(provider.onErrorCallback).toBe(onError); + }); + }); + + describe("Lifecycle", () => { + it("should have start/stop methods", () => { + const provider = new OpenMeteoProvider({}); + expect(typeof provider.start).toBe("function"); + expect(typeof provider.stop).toBe("function"); + }); + + it("should clear timer on stop", async () => { + const provider = new OpenMeteoProvider({ lat: 48.14, lon: 11.58 }); + provider.setCallbacks(vi.fn(), vi.fn()); + + server.use( + http.get("https://api.open-meteo.com/v1/forecast*", () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.stop(); + + // Should not throw + expect(provider.fetcher).not.toBeNull(); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/openweathermap_spec.js b/tests/unit/modules/default/weather/providers/openweathermap_spec.js new file mode 100644 index 0000000..d5b92ed --- /dev/null +++ b/tests/unit/modules/default/weather/providers/openweathermap_spec.js @@ -0,0 +1,554 @@ +/** + * OpenWeatherMap Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +import onecallData from "../../../../../mocks/weather_owm_onecall.json" with { type: "json" }; +import currentData from "../../../../../mocks/weather_owm_current.json" with { type: "json" }; +import forecastData from "../../../../../mocks/weather_owm_forecast.json" with { type: "json" }; + +let server; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("OpenWeatherMapProvider", () => { + let OpenWeatherMapProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/openweathermap"); + OpenWeatherMapProvider = module.default; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key" + }); + expect(provider.config.lat).toBe(48.14); + expect(provider.config.lon).toBe(11.58); + expect(provider.config.apiKey).toBe("test-key"); + }); + + it("should have default values", () => { + const provider = new OpenWeatherMapProvider({ apiKey: "test" }); + expect(provider.config.apiVersion).toBe("3.0"); + expect(provider.config.weatherEndpoint).toBe("/onecall"); + expect(provider.config.apiBase).toBe("https://api.openweathermap.org/data/"); + }); + }); + + describe("API Key Validation", () => { + it("should call error callback without API key", async () => { + const provider = new OpenWeatherMapProvider({ apiKey: "" }); + const onError = vi.fn(); + provider.setCallbacks(vi.fn(), onError); + await provider.initialize(); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "API key is required" }) + ); + }); + + it("should not create fetcher without API key", async () => { + const provider = new OpenWeatherMapProvider({ apiKey: "" }); + provider.setCallbacks(vi.fn(), vi.fn()); + await provider.initialize(); + expect(provider.fetcher).toBeNull(); + }); + + it("should throw if setCallbacks not called before initialize", () => { + const provider = new OpenWeatherMapProvider({ apiKey: "test" }); + expect(() => provider.initialize()).toThrow("setCallbacks"); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse onecall current weather data", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/3.0/onecall", () => { + return HttpResponse.json(onecallData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.temperature).toBe(-0.27); + expect(result.windSpeed).toBe(3.09); + expect(result.windFromDirection).toBe(220); + expect(result.humidity).toBe(54); + expect(result.uvIndex).toBe(0); + expect(result.feelsLikeTemp).toBe(-3.9); + expect(result.weatherType).toBe("cloudy-windy"); + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + + it("should include precipitation data in current weather", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/3.0/onecall", () => { + return HttpResponse.json(onecallData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Real data has no precipitation + expect(result.rain).toBeUndefined(); + expect(result.snow).toBeUndefined(); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/3.0/onecall", () => { + return HttpResponse.json(onecallData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(8); + expect(result[0].minTemperature).toBe(-11.86); + expect(result[0].maxTemperature).toBe(-0.27); + expect(result[0].snow).toBe(0.69); + expect(result[0].precipitationProbability).toBe(100); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast data", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/3.0/onecall", () => { + return HttpResponse.json(onecallData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(48); + expect(result[0].temperature).toBe(-0.66); + expect(result[0].precipitationProbability).toBe(0); + expect(result[0].rain).toBeUndefined(); + }); + }); + + describe("Timezone Handling", () => { + it("should set location name from timezone", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/3.0/onecall", () => { + return HttpResponse.json(onecallData); + }) + ); + + await provider.initialize(); + provider.start(); + + await dataPromise; + + expect(provider.locationName).toBe("America/New_York"); + }); + }); + + describe("API v2.5 - Current Weather (/weather endpoint)", () => { + it("should parse current weather from /weather endpoint", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + apiVersion: "2.5", + weatherEndpoint: "/weather", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/2.5/weather", () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.temperature).toBe(-0.27); + expect(result.feelsLikeTemp).toBe(-3.9); + expect(result.humidity).toBe(54); + expect(result.windSpeed).toBe(3.09); + expect(result.windFromDirection).toBe(220); + expect(result.weatherType).toBe("cloudy-windy"); + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + + it("should set location name from city name and country", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + apiVersion: "2.5", + weatherEndpoint: "/weather", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/2.5/weather", () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + await dataPromise; + + expect(provider.locationName).toBe("Munich, DE"); + }); + }); + + describe("API v2.5 - Forecast (/forecast endpoint)", () => { + it("should parse /forecast endpoint into daily grouped forecast", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + apiVersion: "2.5", + weatherEndpoint: "/forecast", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/2.5/forecast", () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(2); + }); + + it("should correctly aggregate min/max temperatures per day", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + apiVersion: "2.5", + weatherEndpoint: "/forecast", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/2.5/forecast", () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Day 1: temp_min values: -1.5, -1.5, -1.0, 0.5, 1.5, 1.0, 0.5, -0.5 → min=-1.5 + expect(result[0].minTemperature).toBe(-1.5); + // Day 1: temp_max values: -0.5, -0.9, 0.0, 1.5, 2.5, 2.0, 1.2, 0.1 → max=2.5 + expect(result[0].maxTemperature).toBe(2.5); + // Day 2: temp_min values: 0.0, 0.5, 1.5, 3.0, 4.5, 4.0, 2.5, 1.0 → min=0.0 + expect(result[1].minTemperature).toBe(0.0); + // Day 2: temp_max values: 1.0, 1.5, 2.5, 4.0, 5.5, 5.0, 3.5, 2.0 → max=5.5 + expect(result[1].maxTemperature).toBe(5.5); + }); + + it("should pick daytime weather type (8-17h)", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + apiVersion: "2.5", + weatherEndpoint: "/forecast", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/2.5/forecast", () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Day 1 daytime entries have icon "10d" → "rain" + expect(result[0].weatherType).toBe("rain"); + // Day 2 daytime entries have icon "09d" → "showers" + expect(result[1].weatherType).toBe("showers"); + }); + + it("should accumulate precipitation per day", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + apiVersion: "2.5", + weatherEndpoint: "/forecast", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/2.5/forecast", () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Day 1: two rain entries of 0.6 each = 1.2 + expect(result[0].rain).toBeCloseTo(1.2); + expect(result[0].precipitationAmount).toBeCloseTo(1.2); + // Day 2: one snow entry of 0.5 + expect(result[1].snow).toBeCloseTo(0.5); + expect(result[1].precipitationAmount).toBeCloseTo(0.5); + }); + + it("should set location name from city in forecast response", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + apiVersion: "2.5", + weatherEndpoint: "/forecast", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/2.5/forecast", () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + await dataPromise; + + expect(provider.locationName).toBe("Munich, DE"); + }); + }); + + describe("API v2.5 - Hourly (/forecast endpoint with type hourly)", () => { + it("should return individual 3h entries instead of aggregating", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + apiVersion: "2.5", + weatherEndpoint: "/forecast", + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/2.5/forecast", () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(forecastData.list.length); + }); + + it("should map temperature and wind from each 3h slot", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + apiVersion: "2.5", + weatherEndpoint: "/forecast", + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/2.5/forecast", () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result[0].temperature).toBe(forecastData.list[0].main.temp); + expect(result[0].windSpeed).toBe(forecastData.list[0].wind.speed); + expect(result[0].precipitationProbability).toBe(forecastData.list[0].pop * 100); + }); + + it("should include precipitation when present in a slot", async () => { + const provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-key", + apiVersion: "2.5", + weatherEndpoint: "/forecast", + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://api.openweathermap.org/data/2.5/forecast", () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Entry at index 3 has rain: { "3h": 0.6 } + expect(result[3].rain).toBe(0.6); + expect(result[3].precipitationAmount).toBe(0.6); + // Entry at index 11 has snow: { "3h": 0.5 } + expect(result[11].snow).toBe(0.5); + expect(result[11].precipitationAmount).toBe(0.5); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/pirateweather_spec.js b/tests/unit/modules/default/weather/providers/pirateweather_spec.js new file mode 100644 index 0000000..e2e3a8e --- /dev/null +++ b/tests/unit/modules/default/weather/providers/pirateweather_spec.js @@ -0,0 +1,366 @@ +/** + * Pirate Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + * Pirate Weather is a Dark Sky API compatible service. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +import pirateweatherData from "../../../../../mocks/weather_pirateweather.json" with { type: "json" }; + +const PIRATEWEATHER_URL = "https://api.pirateweather.net/forecast/*"; + +let server; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("PirateweatherProvider", () => { + let PirateweatherProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/pirateweather"); + PirateweatherProvider = module.default || module; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new PirateweatherProvider({ + apiKey: "test-api-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + expect(provider.config.apiKey).toBe("test-api-key"); + expect(provider.config.lat).toBe(40.71); + expect(provider.config.lon).toBe(-74.0); + }); + + it("should error if API key is missing", async () => { + const provider = new PirateweatherProvider({ + lat: 40.71, + lon: -74.0 + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + await provider.initialize(); + + const error = await errorPromise; + expect(error.message).toContain("API key"); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather data", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.temperature).toBe(-0.26); + expect(result.feelsLikeTemp).toBe(-4.77); + expect(result.windSpeed).toBe(2.32); + expect(result.windFromDirection).toBe(166); + expect(Math.round(result.humidity)).toBe(56); // 0.56 * 100 with rounding + }); + + it("should include sunrise/sunset from daily data", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + + it("should convert icon to weather type", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // "cloudy" icon from real data + expect(result.weatherType).toBe("cloudy"); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(8); + const day = result[0]; + expect(day).toHaveProperty("date"); + expect(day).toHaveProperty("minTemperature"); + expect(day).toHaveProperty("maxTemperature"); + expect(day).toHaveProperty("weatherType"); + expect(day).toHaveProperty("precipitationProbability"); + }); + + it("should convert precipitation accumulation from cm to mm", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // First day has precipAccumulation: 0.0 cm + expect(result[0].precipitationAmount).toBe(0); + }); + + it("should categorize precipitation by type", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // First day has precipType: "snow" + expect(result[0].rain).toBe(0); + expect(result[0].snow).toBe(0); + + // Second day has precipType: "snow" with 0.0 accumulation + expect(result[1].rain).toBe(0); + expect(result[1].snow).toBe(0); + }); + + it("should convert precipitation probability to percentage", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // 0.33 -> 33% + expect(result[0].precipitationProbability).toBe(33); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast data", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(48); + + const hour = result[0]; + expect(hour).toHaveProperty("date"); + expect(hour).toHaveProperty("temperature"); + expect(hour).toHaveProperty("feelsLikeTemp"); + expect(hour).toHaveProperty("windSpeed"); + expect(hour).toHaveProperty("weatherType"); + }); + + it("should handle hourly precipitation", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json(pirateweatherData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // First hour has 0.0 cm precipitation + expect(result[0].precipitationAmount).toBe(0); + expect(result[0].rain).toBe(0); + }); + }); + + describe("Error Handling", () => { + it("should handle invalid JSON response", async () => { + const provider = new PirateweatherProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(PIRATEWEATHER_URL, () => { + return HttpResponse.json({}); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error.message).toContain("No usable data"); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/smhi_spec.js b/tests/unit/modules/default/weather/providers/smhi_spec.js new file mode 100644 index 0000000..de5eb04 --- /dev/null +++ b/tests/unit/modules/default/weather/providers/smhi_spec.js @@ -0,0 +1,210 @@ +/** + * SMHI Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + * SMHI provides data only for Sweden, uses metric system. + * + * Fixture: weather_smhi.json uses SNOW1gv1 format (replaced PMP3gv2 2026-03-31) + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +import smhiData from "../../../../../mocks/weather_smhi.json" with { type: "json" }; + +let server; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("SMHIProvider", () => { + let SMHIProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/smhi"); + SMHIProvider = module.default; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new SMHIProvider({ + lat: 59.3293, + lon: 18.0686 + }); + expect(provider.config.lat).toBe(59.3293); + expect(provider.config.lon).toBe(18.0686); + expect(provider.config.precipitationValue).toBe("pmedian"); + }); + + it("should fallback to pmedian for invalid precipitationValue", () => { + const provider = new SMHIProvider({ + precipitationValue: "invalid" + }); + expect(provider.config.precipitationValue).toBe("pmedian"); + }); + + it("should accept valid precipitationValue options", () => { + for (const value of ["pmin", "pmean", "pmedian", "pmax"]) { + const provider = new SMHIProvider({ precipitationValue: value }); + expect(provider.config.precipitationValue).toBe(value); + } + }); + }); + + describe("Coordinate Validation", () => { + it("should limit coordinates to 6 decimal places", async () => { + const provider = new SMHIProvider({ + lat: 59.32930123456789, + lon: 18.06860123456789 + }); + provider.setCallbacks(vi.fn(), vi.fn()); + + server.use( + http.get("https://opendata-download-metfcst.smhi.se/*", () => { + return HttpResponse.json(smhiData); + }) + ); + + await provider.initialize(); + + // After validateCoordinates(config, 6), decimals should be truncated + expect(provider.config.lat.toString().split(".")[1]?.length).toBeLessThanOrEqual(6); + expect(provider.config.lon.toString().split(".")[1]?.length).toBeLessThanOrEqual(6); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather from timeSeries", async () => { + const provider = new SMHIProvider({ + lat: 59.3293, + lon: 18.0686, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://opendata-download-metfcst.smhi.se/*", () => { + return HttpResponse.json(smhiData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(typeof result.temperature).toBe("number"); + expect(typeof result.windSpeed).toBe("number"); + expect(typeof result.humidity).toBe("number"); + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + + it("should detect precipitation category correctly", async () => { + const provider = new SMHIProvider({ + lat: 59.3293, + lon: 18.0686, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + // Use entry at index 2 which has ptype=5 (snow) but pmedian=0.0 + const rainData = JSON.parse(JSON.stringify(smhiData)); + rainData.timeSeries = [rainData.timeSeries[2]]; + rainData.timeSeries[0].time = new Date().toISOString(); + + server.use( + http.get("https://opendata-download-metfcst.smhi.se/*", () => { + return HttpResponse.json(rainData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // pmedian is 0.0 at this entry, so all precipitation amounts are 0 + expect(result.rain).toBe(0); + expect(result.precipitationAmount).toBe(0.0); + expect(result.snow).toBe(0); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new SMHIProvider({ + lat: 59.3293, + lon: 18.0686, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get("https://opendata-download-metfcst.smhi.se/*", () => { + return HttpResponse.json(smhiData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const firstDay = result[0]; + expect(firstDay).toHaveProperty("date"); + expect(firstDay).toHaveProperty("minTemperature"); + expect(firstDay).toHaveProperty("maxTemperature"); + expect(firstDay.minTemperature).toBeLessThanOrEqual(firstDay.maxTemperature); + }); + }); + + describe("Error Handling", () => { + it("should call error callback on invalid data", async () => { + const provider = new SMHIProvider({ + lat: 59.3293, + lon: 18.0686, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get("https://opendata-download-metfcst.smhi.se/*", () => { + return HttpResponse.json({ invalid: true }); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error).toHaveProperty("message"); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/ukmetofficedatahub_spec.js b/tests/unit/modules/default/weather/providers/ukmetofficedatahub_spec.js new file mode 100644 index 0000000..7bda9a6 --- /dev/null +++ b/tests/unit/modules/default/weather/providers/ukmetofficedatahub_spec.js @@ -0,0 +1,348 @@ +/** + * UK Met Office DataHub Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +import hourlyData from "../../../../../mocks/weather_ukmetoffice.json" with { type: "json" }; +import dailyData from "../../../../../mocks/weather_ukmetoffice_daily.json" with { type: "json" }; + +const UKMETOFFICE_HOURLY_URL = "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/hourly*"; +const UKMETOFFICE_THREE_HOURLY_URL = "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/three-hourly*"; +const UKMETOFFICE_DAILY_URL = "https://data.hub.api.metoffice.gov.uk/sitespecific/v0/point/daily*"; + +/** + * Returns a copy of the daily mock response with dates shifted to today and future days. + * @param {object} source Source UK Met Office daily mock payload. + * @returns {object} Cloned payload with deterministic future dates in `timeSeries`. + */ +function withFutureDailyTimes (source) { + const clone = JSON.parse(JSON.stringify(source)); + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + + clone.features[0].properties.timeSeries = clone.features[0].properties.timeSeries.map((day, index) => { + const shiftedDate = new Date(today); + shiftedDate.setUTCDate(today.getUTCDate() + index); + + return { + ...day, + time: `${shiftedDate.toISOString().split("T")[0]}T00:00Z` + }; + }); + + return clone; +} + +let server; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("UKMetOfficeDataHubProvider", () => { + let UKMetOfficeDataHubProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/ukmetofficedatahub"); + UKMetOfficeDataHubProvider = module.default || module; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-api-key", + lat: 51.5, + lon: -0.12, + type: "current" + }); + expect(provider.config.apiKey).toBe("test-api-key"); + expect(provider.config.lat).toBe(51.5); + expect(provider.config.lon).toBe(-0.12); + }); + + it("should error if API key is missing", async () => { + const provider = new UKMetOfficeDataHubProvider({ + lat: 51.5, + lon: -0.12 + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + await provider.initialize(); + + const error = await errorPromise; + expect(error.message).toContain("API key"); + }); + }); + + describe("Forecast Type Mapping", () => { + it("should use hourly endpoint for current type", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "current" + }); + + let requestedUrl = null; + server.use( + http.get(UKMETOFFICE_HOURLY_URL, ({ request }) => { + requestedUrl = request.url; + return HttpResponse.json(hourlyData); + }) + ); + + provider.setCallbacks(vi.fn(), vi.fn()); + await provider.initialize(); + provider.start(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(requestedUrl).toContain("/hourly?"); + }); + + it("should use daily endpoint for forecast type", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "forecast" + }); + + let requestedUrl = null; + server.use( + http.get(UKMETOFFICE_DAILY_URL, ({ request }) => { + requestedUrl = request.url; + return HttpResponse.json(dailyData); + }) + ); + + provider.setCallbacks(vi.fn(), vi.fn()); + await provider.initialize(); + provider.start(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(requestedUrl).toContain("/daily?"); + }); + + it("should use three-hourly endpoint for hourly type", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "hourly" + }); + + let requestedUrl = null; + server.use( + http.get(UKMETOFFICE_THREE_HOURLY_URL, ({ request }) => { + requestedUrl = request.url; + return HttpResponse.json(hourlyData); + }) + ); + + provider.setCallbacks(vi.fn(), vi.fn()); + await provider.initialize(); + provider.start(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(requestedUrl).toContain("/three-hourly?"); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather from hourly data", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(UKMETOFFICE_HOURLY_URL, () => { + return HttpResponse.json(hourlyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.temperature).toBeDefined(); + expect(result.windSpeed).toBeDefined(); + expect(result.humidity).toBeDefined(); + expect(result.weatherType).not.toBeNull(); + }); + + it("should include sunrise/sunset from SunCalc", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(UKMETOFFICE_HOURLY_URL, () => { + return HttpResponse.json(hourlyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + + it("should convert weather code to weather type", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(UKMETOFFICE_HOURLY_URL, () => { + return HttpResponse.json(hourlyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.weatherType).toBeTruthy(); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(UKMETOFFICE_DAILY_URL, () => { + return HttpResponse.json(withFutureDailyTimes(dailyData)); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const day = result[0]; + expect(day).toHaveProperty("date"); + expect(day).toHaveProperty("minTemperature"); + expect(day).toHaveProperty("maxTemperature"); + expect(day).toHaveProperty("weatherType"); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast data", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(UKMETOFFICE_THREE_HOURLY_URL, () => { + return HttpResponse.json(hourlyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const hour = result[0]; + expect(hour).toHaveProperty("date"); + expect(hour).toHaveProperty("temperature"); + expect(hour).toHaveProperty("windSpeed"); + expect(hour).toHaveProperty("weatherType"); + }); + }); + + describe("Error Handling", () => { + it("should handle invalid response", async () => { + const provider = new UKMetOfficeDataHubProvider({ + apiKey: "test-key", + lat: 51.5, + lon: -0.12, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(UKMETOFFICE_HOURLY_URL, () => { + return HttpResponse.json({}); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error).toHaveProperty("message"); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/weatherapi_spec.js b/tests/unit/modules/default/weather/providers/weatherapi_spec.js new file mode 100644 index 0000000..e53713c --- /dev/null +++ b/tests/unit/modules/default/weather/providers/weatherapi_spec.js @@ -0,0 +1,313 @@ +/** + * WeatherAPI Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +const WEATHER_API_URL = "https://api.weatherapi.com/v1/forecast.json*"; + +/** + * Builds a stable WeatherAPI mock payload for current, daily, and hourly parsing tests. + * @returns {object} WeatherAPI forecast response fixture. + */ +function buildWeatherApiResponse () { + return { + location: { + name: "Toronto", + region: "Ontario", + country: "Canada" + }, + current: { + last_updated_epoch: 4102444800, + temp_c: -2.5, + feelslike_c: -7.1, + humidity: 75, + wind_kph: 18, + wind_degree: 220, + condition: { code: 1003 }, + is_day: 1, + snow_cm: 0.2, + precip_mm: 1.1, + uv: 2 + }, + forecast: { + forecastday: [ + { + date: "2100-01-01", + date_epoch: 4102444800, + astro: { + sunrise: "07:45 AM", + sunset: "05:05 PM" + }, + day: { + mintemp_c: -8, + maxtemp_c: -1, + avgtemp_c: -4, + avghumidity: 81, + totalsnow_cm: 0.6, + totalprecip_mm: 2, + maxwind_kph: 22, + uv: 1, + condition: { code: 1183 } + }, + hour: [ + { + time: "2100-01-01 01:00", + time_epoch: 4102448400, + temp_c: -5, + humidity: 85, + wind_kph: 15, + wind_degree: 210, + wind_dir: "SSW", + condition: { code: 1183 }, + is_day: 0, + snow_cm: 0.1, + precip_mm: 0.5, + will_it_rain: 1, + will_it_snow: 0, + uv: 0 + }, + { + time: "2100-01-01 02:00", + time_epoch: 4102452000, + temp_c: -4.5, + humidity: 83, + wind_kph: 13, + wind_degree: 205, + wind_dir: "SSW", + condition: { code: 1003 }, + is_day: 0, + snow_cm: 0, + precip_mm: 0.2, + will_it_rain: 0, + will_it_snow: 0, + uv: 0 + }, + { + time: "2100-01-01 03:00", + time_epoch: 4102455600, + temp_c: -4, + humidity: 80, + wind_kph: 12, + wind_degree: 200, + wind_dir: "SSW", + condition: { code: 1000 }, + is_day: 0, + snow_cm: 0, + precip_mm: 0, + will_it_rain: 0, + will_it_snow: 0, + uv: 0 + } + ] + }, + { + date: "2100-01-02", + date_epoch: 4102531200, + astro: { + sunrise: "07:44 AM", + sunset: "05:06 PM" + }, + day: { + mintemp_c: -7, + maxtemp_c: 0, + avgtemp_c: -3.3, + avghumidity: 78, + totalsnow_cm: 0, + totalprecip_mm: 0.4, + maxwind_kph: 20, + uv: 2, + condition: { code: 1006 } + }, + hour: [] + } + ] + } + }; +} + +let server; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("WeatherAPIProvider", () => { + let WeatherAPIProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/weatherapi"); + WeatherAPIProvider = module.default; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new WeatherAPIProvider({ + lat: 43.65, + lon: -79.38, + apiKey: "test-key", + type: "current" + }); + expect(provider.config.lat).toBe(43.65); + expect(provider.config.lon).toBe(-79.38); + expect(provider.config.apiKey).toBe("test-key"); + expect(provider.config.type).toBe("current"); + }); + + it("should have default values", () => { + const provider = new WeatherAPIProvider({ apiKey: "test-key" }); + expect(provider.config.apiBase).toBe("https://api.weatherapi.com/v1"); + expect(provider.config.type).toBe("current"); + expect(provider.config.maxEntries).toBe(5); + expect(provider.config.updateInterval).toBe(10 * 60 * 1000); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather data correctly", async () => { + const provider = new WeatherAPIProvider({ + lat: 43.65, + lon: -79.38, + apiKey: "test-key", + type: "current" + }); + + const dataPromise = new Promise((resolve, reject) => { + provider.setCallbacks(resolve, reject); + }); + + server.use( + http.get(WEATHER_API_URL, () => HttpResponse.json(buildWeatherApiResponse())) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.temperature).toBe(-2.5); + expect(result.feelsLikeTemp).toBe(-7.1); + expect(result.humidity).toBe(75); + expect(result.windSpeed).toBeCloseTo(5, 1); + expect(result.windFromDirection).toBe(220); + expect(result.weatherType).toBe("day-cloudy"); + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + expect(result.minTemperature).toBe(-8); + expect(result.maxTemperature).toBe(-1); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data correctly", async () => { + const provider = new WeatherAPIProvider({ + lat: 43.65, + lon: -79.38, + apiKey: "test-key", + type: "forecast", + maxEntries: 2, + maxNumberOfDays: 2 + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHER_API_URL, () => HttpResponse.json(buildWeatherApiResponse())) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(2); + expect(result[0].minTemperature).toBe(-8); + expect(result[0].maxTemperature).toBe(-1); + expect(result[0].weatherType).toBe("day-sprinkle"); + expect(result[0].uvIndex).toBe(1); + expect(result[0].sunrise).toBeInstanceOf(Date); + expect(result[0].sunset).toBeInstanceOf(Date); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast data correctly", async () => { + const provider = new WeatherAPIProvider({ + lat: 43.65, + lon: -79.38, + apiKey: "test-key", + type: "hourly", + maxEntries: 3, + maxNumberOfDays: 1 + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHER_API_URL, () => HttpResponse.json(buildWeatherApiResponse())) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(3); + expect(result[0].temperature).toBe(-5); + expect(result[0].humidity).toBe(85); + expect(result[0].windFromDirection).toBe(210); + expect(result[0].weatherType).toBe("night-sprinkle"); + expect(result[0].uvIndex).toBe(0); + expect(result[0].precipitationProbability).toBe(50); + }); + }); + + describe("Error Handling", () => { + it("should call error callback on invalid API response", async () => { + const provider = new WeatherAPIProvider({ + lat: 43.65, + lon: -79.38, + apiKey: "test-key", + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(WEATHER_API_URL, () => HttpResponse.json({ + location: {}, + current: {}, + forecast: { forecastday: "invalid" } + })) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error).toHaveProperty("message"); + expect(error).toHaveProperty("translationKey"); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/weatherbit_spec.js b/tests/unit/modules/default/weather/providers/weatherbit_spec.js new file mode 100644 index 0000000..8bea45f --- /dev/null +++ b/tests/unit/modules/default/weather/providers/weatherbit_spec.js @@ -0,0 +1,247 @@ +/** + * Weatherbit Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +import currentData from "../../../../../mocks/weather_weatherbit.json" with { type: "json" }; +import forecastData from "../../../../../mocks/weather_weatherbit_forecast.json" with { type: "json" }; +import hourlyData from "../../../../../mocks/weather_weatherbit_hourly.json" with { type: "json" }; + +const WEATHERBIT_CURRENT_URL = "https://api.weatherbit.io/v2.0/current*"; +const WEATHERBIT_FORECAST_URL = "https://api.weatherbit.io/v2.0/forecast/daily*"; +const WEATHERBIT_HOURLY_URL = "https://api.weatherbit.io/v2.0/forecast/hourly*"; + +let server; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("WeatherbitProvider", () => { + let WeatherbitProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/weatherbit"); + WeatherbitProvider = module.default || module; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new WeatherbitProvider({ + apiKey: "test-api-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + expect(provider.config.apiKey).toBe("test-api-key"); + expect(provider.config.lat).toBe(40.71); + expect(provider.config.lon).toBe(-74.0); + }); + + it("should error if API key is missing", async () => { + const provider = new WeatherbitProvider({ + lat: 40.71, + lon: -74.0 + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + await provider.initialize(); + + const error = await errorPromise; + expect(error.message).toContain("API key"); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather data", async () => { + const provider = new WeatherbitProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERBIT_CURRENT_URL, () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.temperature).toBe(1); + expect(result.windSpeed).toBe(1.5); + expect(result.windFromDirection).toBe(210); + expect(result.humidity).toBe(47); + }); + + it("should parse sunrise/sunset from HH:mm format", async () => { + const provider = new WeatherbitProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERBIT_CURRENT_URL, () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + + it("should convert icon code to weather type", async () => { + const provider = new WeatherbitProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERBIT_CURRENT_URL, () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.weatherType).not.toBeNull(); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new WeatherbitProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERBIT_FORECAST_URL, () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const day = result[0]; + expect(day).toHaveProperty("date"); + expect(day).toHaveProperty("minTemperature"); + expect(day).toHaveProperty("maxTemperature"); + expect(day).toHaveProperty("weatherType"); + expect(day).toHaveProperty("precipitationProbability"); + }); + }); + + describe("Hourly Parsing", () => { + it("should handle hourly API endpoint access error", async () => { + const provider = new WeatherbitProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "hourly" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(WEATHERBIT_HOURLY_URL, () => { + return HttpResponse.json(hourlyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + + expect(error).toBeDefined(); + expect(error.message || error).toContain("No usable data"); + }); + }); + + describe("Error Handling", () => { + it("should handle invalid response", async () => { + const provider = new WeatherbitProvider({ + apiKey: "test-key", + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(WEATHERBIT_CURRENT_URL, () => { + return HttpResponse.json({ data: [] }); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error).toHaveProperty("message"); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/weatherflow_spec.js b/tests/unit/modules/default/weather/providers/weatherflow_spec.js new file mode 100644 index 0000000..f6b28ca --- /dev/null +++ b/tests/unit/modules/default/weather/providers/weatherflow_spec.js @@ -0,0 +1,267 @@ +/** + * WeatherFlow Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +import weatherflowData from "../../../../../mocks/weather_weatherflow.json" with { type: "json" }; + +const WEATHERFLOW_URL = "https://swd.weatherflow.com/swd/rest/better_forecast*"; + +let server; + +beforeAll(() => { + server = setupServer(); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe("WeatherFlowProvider", () => { + let WeatherFlowProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/weatherflow"); + WeatherFlowProvider = module.default || module; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new WeatherFlowProvider({ + token: "test-token", + stationid: "12345", + type: "current" + }); + expect(provider.config.token).toBe("test-token"); + expect(provider.config.stationid).toBe("12345"); + }); + + it("should error if token or stationid is missing", async () => { + const provider = new WeatherFlowProvider({}); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + await provider.initialize(); + + const error = await errorPromise; + expect(error.message).toContain("token"); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather data", async () => { + const provider = new WeatherFlowProvider({ + token: "test-token", + stationid: "12345", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERFLOW_URL, () => { + return HttpResponse.json(weatherflowData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.temperature).toBe(16); + expect(result.humidity).toBe(28); + expect(result.precipitationAmount).toBe(0); + expect(result.precipitationUnits).toBe("mm"); + expect(result.precipitationProbability).toBe(0); + expect(result.weatherType).not.toBeNull(); + }); + + it("should convert wind speed from km/h to m/s", async () => { + const provider = new WeatherFlowProvider({ + token: "test-token", + stationid: "12345", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERFLOW_URL, () => { + return HttpResponse.json(weatherflowData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Wind speed 15 km/h -> ~4.17 m/s + expect(result.windSpeed).toBeCloseTo(4.17, 1); + }); + + it("should include sunrise/sunset", async () => { + const provider = new WeatherFlowProvider({ + token: "test-token", + stationid: "12345", + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERFLOW_URL, () => { + return HttpResponse.json(weatherflowData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new WeatherFlowProvider({ + token: "test-token", + stationid: "12345", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERFLOW_URL, () => { + return HttpResponse.json(weatherflowData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const day = result[0]; + expect(day).toHaveProperty("date"); + expect(day).toHaveProperty("minTemperature"); + expect(day).toHaveProperty("maxTemperature"); + expect(day).toHaveProperty("weatherType"); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast data", async () => { + const provider = new WeatherFlowProvider({ + token: "test-token", + stationid: "12345", + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERFLOW_URL, () => { + return HttpResponse.json(weatherflowData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const hour = result[0]; + expect(hour).toHaveProperty("date"); + expect(hour).toHaveProperty("temperature"); + expect(hour).toHaveProperty("windSpeed"); + }); + + it("should aggregate UV data from hourly forecasts", async () => { + const provider = new WeatherFlowProvider({ + token: "test-token", + stationid: "12345", + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERFLOW_URL, () => { + return HttpResponse.json(weatherflowData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // First day should have UV from hourly data + expect(result[0]).toHaveProperty("uvIndex"); + expect(result[0].uvIndex).toBeGreaterThanOrEqual(0); + }); + }); + + describe("Error Handling", () => { + it("should handle invalid response", async () => { + const provider = new WeatherFlowProvider({ + token: "test-token", + stationid: "12345", + type: "current" + }); + + // Invalid responses return null without calling error callback + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERFLOW_URL, () => { + return HttpResponse.json({}); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + expect(result).toBeNull(); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/weathergov_spec.js b/tests/unit/modules/default/weather/providers/weathergov_spec.js new file mode 100644 index 0000000..6326a7f --- /dev/null +++ b/tests/unit/modules/default/weather/providers/weathergov_spec.js @@ -0,0 +1,442 @@ +/** + * Weather.gov Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + * Weather.gov is the US National Weather Service API. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from "vitest"; + +import pointsData from "../../../../../mocks/weather_weathergov_points.json" with { type: "json" }; +import stationsData from "../../../../../mocks/weather_weathergov_stations.json" with { type: "json" }; +import currentData from "../../../../../mocks/weather_weathergov_current.json" with { type: "json" }; +import forecastData from "../../../../../mocks/weather_weathergov_forecast.json" with { type: "json" }; +import hourlyData from "../../../../../mocks/weather_weathergov_hourly.json" with { type: "json" }; + +const WEATHERGOV_POINTS_URL = "https://api.weather.gov/points/*"; +const WEATHERGOV_STATIONS_URL = "https://api.weather.gov/gridpoints/*/stations"; +const WEATHERGOV_CURRENT_URL = "https://api.weather.gov/stations/*/observations/latest"; +const WEATHERGOV_FORECAST_URL = "https://api.weather.gov/gridpoints/*/forecast*"; +const WEATHERGOV_HOURLY_URL = "https://api.weather.gov/gridpoints/*/forecast/hourly*"; + +let server; + +beforeAll(() => { + server = setupServer( + // Default handlers for initialization + http.get(WEATHERGOV_POINTS_URL, () => { + return HttpResponse.json(pointsData); + }), + http.get(WEATHERGOV_STATIONS_URL, () => { + return HttpResponse.json(stationsData); + }) + ); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +afterEach(() => { + server.resetHandlers(); + // Re-add default initialization handlers + server.use( + http.get(WEATHERGOV_POINTS_URL, () => { + return HttpResponse.json(pointsData); + }), + http.get(WEATHERGOV_STATIONS_URL, () => { + return HttpResponse.json(stationsData); + }) + ); +}); + +describe("WeatherGovProvider", () => { + let WeatherGovProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/weathergov"); + WeatherGovProvider = module.default || module; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "current" + }); + expect(provider.config.lat).toBe(40.71); + expect(provider.config.lon).toBe(-74.0); + expect(provider.config.type).toBe("current"); + }); + + it("should have default update interval", () => { + const provider = new WeatherGovProvider({}); + expect(provider.config.updateInterval).toBe(600000); // 10 minutes + }); + }); + + describe("Two-Step Initialization", () => { + it("should fetch points URL and then stations URL", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "current" + }); + + provider.setCallbacks(vi.fn(), vi.fn()); + + let pointsRequested = false; + let stationsRequested = false; + + server.use( + http.get(WEATHERGOV_POINTS_URL, () => { + pointsRequested = true; + return HttpResponse.json(pointsData); + }), + http.get(WEATHERGOV_STATIONS_URL, () => { + stationsRequested = true; + return HttpResponse.json(stationsData); + }) + ); + + await provider.initialize(); + + expect(pointsRequested).toBe(true); + expect(stationsRequested).toBe(true); + expect(provider.locationName).toBe("Washington, DC"); + }); + + it("should store forecast URLs after initialization", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0 + }); + + provider.setCallbacks(vi.fn(), vi.fn()); + await provider.initialize(); + + expect(provider.forecastURL).toContain("forecast?units=si"); + expect(provider.forecastHourlyURL).toContain("forecast/hourly?units=si"); + expect(provider.stationObsURL).toContain("observations/latest"); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather data", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERGOV_CURRENT_URL, () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.temperature).toBe(-1); + expect(result.windSpeed).toBe(0); + expect(result.windFromDirection).toBe(0); + expect(result.humidity).toBe(64); // Rounded from 63.77 + expect(result.weatherType).not.toBeNull(); + }); + + it("should use heat index or wind chill for feels like temperature", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERGOV_CURRENT_URL, () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Real data has null windChill - falls back to temperature + expect(result.feelsLikeTemp).toBe(-1); + }); + + it("should include sunrise/sunset", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERGOV_CURRENT_URL, () => { + return HttpResponse.json(currentData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERGOV_FORECAST_URL, () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const day = result[0]; + expect(day).toHaveProperty("date"); + expect(day).toHaveProperty("minTemperature"); + expect(day).toHaveProperty("maxTemperature"); + expect(day).toHaveProperty("weatherType"); + expect(day).toHaveProperty("precipitationProbability"); + }); + + it("should not skip the first day of forecast data", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERGOV_FORECAST_URL, () => { + return HttpResponse.json(forecastData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Mock data starts on 2026-02-06 ("This Afternoon"). + // Before the fix, slice(1) dropped today, so result[0] would have been 2026-02-07. + const firstDate = result[0].date; + expect(firstDate.getFullYear()).toBe(2026); + expect(firstDate.getMonth()).toBe(1); // February (0-indexed) + expect(firstDate.getDate()).toBe(6); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast data", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERGOV_HOURLY_URL, () => { + return HttpResponse.json(hourlyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(156); // Real API returns 156 hourly periods + expect(result[0]).toHaveProperty("temperature"); + expect(result[0]).toHaveProperty("windSpeed"); + expect(result[0]).toHaveProperty("windFromDirection"); + expect(result[0]).toHaveProperty("weatherType"); + }); + + it("should convert wind direction strings to degrees", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERGOV_HOURLY_URL, () => { + return HttpResponse.json(hourlyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Real data has "S" wind for both hours + expect(result[0].windFromDirection).toBe(180); + // Third hour also has "S" wind + expect(result[2].windFromDirection).toBe(180); + }); + + it("should parse wind speed with units", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(WEATHERGOV_HOURLY_URL, () => { + return HttpResponse.json(hourlyData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Wind speeds should be converted from km/h to m/s + expect(result[0].windSpeed).toBeCloseTo(1.11, 1); // Real data: 4 km/h -> ~1.11 m/s + }); + }); + + describe("Error Handling", () => { + it("should categorize DNS errors as retryable", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(WEATHERGOV_POINTS_URL, () => { + return HttpResponse.error(); + }) + ); + + await provider.initialize(); + + // Should call error callback + const error = await errorPromise; + expect(error).toHaveProperty("message"); + }); + + it("should handle invalid JSON response", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(WEATHERGOV_CURRENT_URL, () => { + return HttpResponse.json({ properties: null }); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error.message).toContain("Invalid"); + }); + }); + + describe("Weather Type Conversion", () => { + it("should convert textDescription to weather types", async () => { + const provider = new WeatherGovProvider({ + lat: 40.71, + lon: -74.0, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + const testData = JSON.parse(JSON.stringify(currentData)); + testData.properties.textDescription = "Thunderstorm"; + + server.use( + http.get(WEATHERGOV_CURRENT_URL, () => { + return HttpResponse.json(testData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // Thunderstorm should map to day or night thunderstorm + expect(["thunderstorm", "night-thunderstorm"]).toContain(result.weatherType); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/providers/yr_spec.js b/tests/unit/modules/default/weather/providers/yr_spec.js new file mode 100644 index 0000000..64598f2 --- /dev/null +++ b/tests/unit/modules/default/weather/providers/yr_spec.js @@ -0,0 +1,286 @@ +/** + * Yr.no Weather Provider Tests + * + * Tests data parsing for current, forecast, and hourly weather types. + * Yr.no is the Norwegian Meteorological Institute API. + * + * Uses fake timers to ensure deterministic timeseries selection. + * The provider picks the closest past entry from timeseries based on new Date(). + * Fixed to 2026-02-06T21:30:00Z → selects timeseries[0] at 21:00 with T=-5.8°C. + */ +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from "vitest"; + +import yrData from "../../../../../mocks/weather_yr.json" with { type: "json" }; + +const YR_FORECAST_URL = "https://api.met.no/weatherapi/locationforecast/**"; + +// Fixed time: 30 minutes after the first timeseries entry (2026-02-06T21:00:00Z) +// This ensures timeseries[0] is always chosen as the closest past entry. +const FAKE_NOW = new Date("2026-02-06T21:30:00Z"); + +let server; + +beforeAll(() => { + server = setupServer( + http.get(({ request }) => request.url.includes("/locationforecast/"), () => { + return HttpResponse.json(yrData); + }), + http.get(({ request }) => request.url.includes("/sunrise/"), () => { + return HttpResponse.json({ + when: { interval: ["2026-02-06T00:00:00+01:00"] }, + properties: { + sunrise: { time: "2026-02-06T08:30:00+01:00" }, + sunset: { time: "2026-02-06T16:30:00+01:00" } + } + }); + }) + ); + server.listen({ onUnhandledRequest: "bypass" }); +}); + +afterAll(() => { + server.close(); +}); + +beforeEach(() => { + vi.useFakeTimers({ now: FAKE_NOW }); +}); + +afterEach(() => { + vi.useRealTimers(); + server.resetHandlers(); +}); + +describe("YrProvider", () => { + let YrProvider; + + beforeAll(async () => { + const module = await import("../../../../../../defaultmodules/weather/providers/yr"); + YrProvider = module.default; + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + const provider = new YrProvider({ + lat: 59.91, + lon: 10.72, + altitude: 94 + }); + expect(provider.config.lat).toBe(59.91); + expect(provider.config.lon).toBe(10.72); + expect(provider.config.altitude).toBe(94); + }); + + it("should enforce minimum 10-minute update interval", () => { + const provider = new YrProvider({ + updateInterval: 60000 // 1 minute - too short + }); + expect(provider.config.updateInterval).toBe(600000); + }); + + it("should allow intervals >= 10 minutes", () => { + const provider = new YrProvider({ + updateInterval: 900000 // 15 minutes + }); + expect(provider.config.updateInterval).toBe(900000); + }); + }); + + describe("Coordinate Validation", () => { + it("should limit coordinates to 4 decimal places", async () => { + const provider = new YrProvider({ + lat: 59.91234567, + lon: 10.72345678 + }); + provider.setCallbacks(vi.fn(), vi.fn()); + + server.use( + http.get(YR_FORECAST_URL, () => { + return HttpResponse.json(yrData); + }) + ); + + await provider.initialize(); + + expect(provider.config.lat.toString().split(".")[1]?.length).toBeLessThanOrEqual(4); + expect(provider.config.lon.toString().split(".")[1]?.length).toBeLessThanOrEqual(4); + }); + }); + + describe("Current Weather Parsing", () => { + it("should parse current weather from timeseries", async () => { + const provider = new YrProvider({ + lat: 59.91, + lon: 10.72, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(YR_FORECAST_URL, () => { + return HttpResponse.json(yrData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + // With fake time at 21:30, provider selects timeseries[0] (21:00 UTC) + expect(result.temperature).toBe(-5.8); + expect(result.windSpeed).toBe(6.0); + expect(result.windFromDirection).toBe(37.0); + expect(result.humidity).toBe(66.5); + // 21:00 is after sunset (16:30), symbol_code "snow" maps to "snow" + expect(result.weatherType).toBe("snow"); + }); + + it("should include sunrise/sunset from stellar data", async () => { + const provider = new YrProvider({ + lat: 59.91, + lon: 10.72, + type: "current" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(result).toBeDefined(); + expect(result.sunrise).toBeInstanceOf(Date); + expect(result.sunset).toBeInstanceOf(Date); + expect(result.sunset.getTime()).toBeGreaterThan(result.sunrise.getTime()); + }); + }); + + describe("Forecast Parsing", () => { + it("should parse daily forecast data", async () => { + const provider = new YrProvider({ + lat: 59.91, + lon: 10.72, + type: "forecast" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(YR_FORECAST_URL, () => { + return HttpResponse.json(yrData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const day = result[0]; + expect(day).toHaveProperty("date"); + expect(day).toHaveProperty("minTemperature"); + expect(day).toHaveProperty("maxTemperature"); + expect(day.minTemperature).toBeLessThanOrEqual(day.maxTemperature); + }); + }); + + describe("Hourly Parsing", () => { + it("should parse hourly forecast data", async () => { + const provider = new YrProvider({ + lat: 59.91, + lon: 10.72, + type: "hourly" + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + server.use( + http.get(YR_FORECAST_URL, () => { + return HttpResponse.json(yrData); + }) + ); + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThan(0); + + const hour = result[0]; + expect(hour).toHaveProperty("temperature"); + expect(hour).toHaveProperty("windSpeed"); + expect(hour).toHaveProperty("precipitationAmount"); + expect(hour).toHaveProperty("weatherType"); + }); + }); + + describe("Error Handling", () => { + it("should call error callback on invalid data", async () => { + const provider = new YrProvider({ + lat: 59.91, + lon: 10.72, + type: "current" + }); + + const errorPromise = new Promise((resolve) => { + provider.setCallbacks(vi.fn(), resolve); + }); + + server.use( + http.get(YR_FORECAST_URL, () => { + return HttpResponse.json({ properties: {} }); + }) + ); + + await provider.initialize(); + provider.start(); + + const error = await errorPromise; + expect(error).toHaveProperty("message"); + }); + }); + + describe("Weather Type Conversion", () => { + it("should convert yr symbol codes correctly", async () => { + const provider = new YrProvider({ + lat: 59.91, + lon: 10.72, + type: "current", + currentForecastHours: 1 + }); + + const dataPromise = new Promise((resolve) => { + provider.setCallbacks(resolve, vi.fn()); + }); + + // Uses yrData from beforeAll which has symbol_code "snow" + + await provider.initialize(); + provider.start(); + + const result = await dataPromise; + + // 21:00 is after sunset (16:30), next_1_hours symbol_code is "snow" + expect(result.weatherType).toBe("snow"); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/weather_object_spec.js b/tests/unit/modules/default/weather/weather_object_spec.js new file mode 100644 index 0000000..cef65bf --- /dev/null +++ b/tests/unit/modules/default/weather/weather_object_spec.js @@ -0,0 +1,50 @@ +const defaults = require("../../../../../js/defaults"); + +const WeatherObject = require(`../../../../../${defaults.defaultModulesDir}/weather/weatherobject`); + +global.moment = require("moment-timezone"); +global.SunCalc = require("suncalc"); + +describe("WeatherObject", () => { + let originalTimeZone; + let weatherobject; + + beforeAll(() => { + originalTimeZone = moment.tz.guess(); + moment.tz.setDefault("Africa/Dar_es_Salaam"); + weatherobject = new WeatherObject(); + }); + + it("should return true for daytime at noon", () => { + weatherobject.date = moment("12:00", "HH:mm"); + weatherobject.updateSunTime(-6.774877582342688, 37.63345667023327); + expect(weatherobject.isDayTime()).toBe(true); + }); + + it("should return false for daytime at midnight", () => { + weatherobject.date = moment("00:00", "HH:mm"); + weatherobject.updateSunTime(-6.774877582342688, 37.63345667023327); + expect(weatherobject.isDayTime()).toBe(false); + }); + + it("should return sunrise as the next sunaction", () => { + weatherobject.updateSunTime(-6.774877582342688, 37.63345667023327); + let midnight = moment("00:00", "HH:mm"); + expect(weatherobject.nextSunAction(midnight)).toBe("sunrise"); + }); + + it("should return sunset as the next sunaction", () => { + weatherobject.updateSunTime(-6.774877582342688, 37.63345667023327); + let noon = moment(weatherobject.sunrise).hour(14); + expect(weatherobject.nextSunAction(noon)).toBe("sunset"); + }); + + it("should return an already defined feelsLike info", () => { + weatherobject.feelsLikeTemp = "feelsLikeTempValue"; + expect(weatherobject.feelsLike()).toBe("feelsLikeTempValue"); + }); + + afterAll(() => { + moment.tz.setDefault(originalTimeZone); + }); +}); diff --git a/tests/unit/modules/default/weather/weather_providers_spec.js b/tests/unit/modules/default/weather/weather_providers_spec.js new file mode 100644 index 0000000..4d226da --- /dev/null +++ b/tests/unit/modules/default/weather/weather_providers_spec.js @@ -0,0 +1,189 @@ +/** + * Weather Provider Smoke Tests + * + * Tests basic provider functionality: configuration, callbacks, and validation. + * Parser logic with private methods (#) is validated through live testing. + */ +import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from "vitest"; + +// Mock global fetch for location lookup +const originalFetch = global.fetch; + +global.fetch = vi.fn(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ city: "Munich", locality: "Munich" }) +})); + +// Restore original fetch after all tests +afterAll(() => { + global.fetch = originalFetch; +}); + +describe("Weather Provider Smoke Tests", () => { + describe("OpenMeteoProvider", () => { + let OpenMeteoProvider; + let provider; + + beforeAll(async () => { + const module = await import("../../../../../defaultmodules/weather/providers/openmeteo"); + OpenMeteoProvider = module.default; + }); + + beforeEach(() => { + provider = new OpenMeteoProvider({ + lat: 48.14, + lon: 11.58, + type: "current", + updateInterval: 600000 + }); + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + expect(provider.config.lat).toBe(48.14); + expect(provider.config.lon).toBe(11.58); + expect(provider.config.type).toBe("current"); + expect(provider.config.updateInterval).toBe(600000); + }); + + it("should have default values", () => { + const defaultProvider = new OpenMeteoProvider({}); + expect(defaultProvider.config.lat).toBe(0); + expect(defaultProvider.config.lon).toBe(0); + expect(defaultProvider.config.type).toBe("current"); + expect(defaultProvider.config.maxNumberOfDays).toBe(5); + }); + + it("should accept all supported types", () => { + expect(new OpenMeteoProvider({ type: "current" }).config.type).toBe("current"); + expect(new OpenMeteoProvider({ type: "forecast" }).config.type).toBe("forecast"); + expect(new OpenMeteoProvider({ type: "hourly" }).config.type).toBe("hourly"); + }); + }); + + describe("Callback Interface", () => { + it("should store callbacks via setCallbacks", () => { + const onData = vi.fn(); + const onError = vi.fn(); + provider.setCallbacks(onData, onError); + expect(provider.onDataCallback).toBe(onData); + expect(provider.onErrorCallback).toBe(onError); + }); + + it("should initialize without callbacks", async () => { + await expect(provider.initialize()).resolves.not.toThrow(); + }); + }); + + describe("Public Methods", () => { + it("should have start/stop methods", () => { + expect(typeof provider.start).toBe("function"); + expect(typeof provider.stop).toBe("function"); + }); + + it("should have initialize method", () => { + expect(typeof provider.initialize).toBe("function"); + }); + + it("should have setCallbacks method", () => { + expect(typeof provider.setCallbacks).toBe("function"); + }); + }); + }); + + describe("OpenWeatherMapProvider", () => { + let OpenWeatherMapProvider; + let provider; + + beforeAll(async () => { + const module = await import("../../../../../defaultmodules/weather/providers/openweathermap"); + OpenWeatherMapProvider = module.default; + }); + + beforeEach(() => { + provider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "test-api-key", + type: "current" + }); + }); + + describe("Constructor & Configuration", () => { + it("should set config values from params", () => { + expect(provider.config.lat).toBe(48.14); + expect(provider.config.lon).toBe(11.58); + expect(provider.config.apiKey).toBe("test-api-key"); + expect(provider.config.type).toBe("current"); + }); + + it("should have default values", () => { + const defaultProvider = new OpenWeatherMapProvider({ apiKey: "test" }); + expect(defaultProvider.config.apiVersion).toBe("3.0"); + expect(defaultProvider.config.weatherEndpoint).toBe("/onecall"); + expect(defaultProvider.config.apiBase).toBe("https://api.openweathermap.org/data/"); + }); + + it("should accept all supported types", () => { + expect(new OpenWeatherMapProvider({ apiKey: "test", type: "current" }).config.type).toBe("current"); + expect(new OpenWeatherMapProvider({ apiKey: "test", type: "forecast" }).config.type).toBe("forecast"); + expect(new OpenWeatherMapProvider({ apiKey: "test", type: "hourly" }).config.type).toBe("hourly"); + }); + }); + + describe("API Key Validation", () => { + it("should call onErrorCallback if no API key provided", async () => { + const noKeyProvider = new OpenWeatherMapProvider({ + lat: 48.14, + lon: 11.58, + apiKey: "" + }); + + const onError = vi.fn(); + noKeyProvider.setCallbacks(vi.fn(), onError); + await noKeyProvider.initialize(); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + message: "API key is required" + }) + ); + }); + + it("should not create fetcher without API key", async () => { + const noKeyProvider = new OpenWeatherMapProvider({ + apiKey: "" + }); + noKeyProvider.setCallbacks(vi.fn(), vi.fn()); + await noKeyProvider.initialize(); + + expect(noKeyProvider.fetcher).toBeNull(); + }); + }); + + describe("Callback Interface", () => { + it("should store callbacks via setCallbacks", () => { + const onData = vi.fn(); + const onError = vi.fn(); + provider.setCallbacks(onData, onError); + expect(provider.onDataCallback).toBe(onData); + expect(provider.onErrorCallback).toBe(onError); + }); + }); + + describe("Public Methods", () => { + it("should have start/stop methods", () => { + expect(typeof provider.start).toBe("function"); + expect(typeof provider.stop).toBe("function"); + }); + + it("should have initialize method", () => { + expect(typeof provider.initialize).toBe("function"); + }); + + it("should have setCallbacks method", () => { + expect(typeof provider.setCallbacks).toBe("function"); + }); + }); + }); +}); diff --git a/tests/unit/modules/default/weather/weather_utils_spec.js b/tests/unit/modules/default/weather/weather_utils_spec.js new file mode 100644 index 0000000..98c64b7 --- /dev/null +++ b/tests/unit/modules/default/weather/weather_utils_spec.js @@ -0,0 +1,163 @@ +const defaults = require("../../../../../js/defaults"); + +const weather = require(`../../../../../${defaults.defaultModulesDir}/weather/weatherutils`); +const WeatherUtils = require(`../../../../../${defaults.defaultModulesDir}/weather/weatherutils`); + +describe("Weather utils tests", () => { + describe("temperature conversion to imperial", () => { + it("should convert temp correctly from Celsius to Celsius", () => { + expect(Math.round(WeatherUtils.convertTemp(10, "metric"))).toBe(10); + }); + + it("should convert temp correctly from Celsius to Fahrenheit", () => { + expect(Math.round(WeatherUtils.convertTemp(10, "imperial"))).toBe(50); + }); + + it("should convert temp correctly from Fahrenheit to Celsius", () => { + expect(Math.round(WeatherUtils.convertTempToMetric(10))).toBe(-12); + }); + }); + + describe("windspeed conversion to beaufort", () => { + it("should convert windspeed correctly from mps to beaufort", () => { + expect(Math.round(WeatherUtils.convertWind(5, "beaufort"))).toBe(3); + expect(Math.round(WeatherUtils.convertWind(300, "beaufort"))).toBe(12); + }); + + it("should convert windspeed correctly from mps to mps", () => { + expect(WeatherUtils.convertWind(11.75, "FOOBAR")).toBe(11.75); + }); + + it("should convert windspeed correctly from mps to kmh", () => { + expect(Math.round(WeatherUtils.convertWind(11.75, "kmh"))).toBe(42); + }); + + it("should convert windspeed correctly from mps to knots", () => { + expect(Math.round(WeatherUtils.convertWind(10, "knots"))).toBe(19); + }); + + it("should convert windspeed correctly from mph to mps", () => { + expect(Math.round(WeatherUtils.convertWindToMetric(93.951324266285))).toBe(42); + }); + + it("should convert windspeed correctly from kmh to mps", () => { + expect(Math.round(WeatherUtils.convertWindToMs(151.2))).toBe(42); + }); + }); + + describe("wind direction conversion", () => { + it("should convert wind direction correctly from cardinal to value", () => { + expect(WeatherUtils.convertWindDirection("SSE")).toBe(157); + expect(WeatherUtils.convertWindDirection("XXX")).toBeNull(); + }); + }); + + describe("feelsLike calculation", () => { + it("should return a calculated feelsLike info (negative value)", () => { + expect(WeatherUtils.calculateFeelsLike(0, 20, 40)).toBe(-9.397005931555448); + }); + + it("should return a calculated feelsLike info (positive value)", () => { + expect(WeatherUtils.calculateFeelsLike(30, 0, 60)).toBe(32.832032277777756); + }); + }); + + describe("precipitationUnit conversion", () => { + it("should keep value and unit if outputUnit is undefined", () => { + const values = [1, 2]; + const units = ["mm", "cm"]; + + for (let i = 0; i < values.length; i++) { + const result = weather.convertPrecipitationUnit(values[i], units[i], undefined); + expect(result).toBe(`${values[i].toFixed(2)} ${units[i]}`); + } + }); + + it("should keep value and unit if outputUnit is metric", () => { + const values = [1, 2]; + const units = ["mm", "cm"]; + + for (let i = 0; i < values.length; i++) { + const result = weather.convertPrecipitationUnit(values[i], units[i], "metric"); + expect(result).toBe(`${values[i].toFixed(2)} ${units[i]}`); + } + }); + + it("should use mm unit if input unit is undefined", () => { + const values = [1, 2]; + + for (let i = 0; i < values.length; i++) { + const result = weather.convertPrecipitationUnit(values[i], undefined, "metric"); + expect(result).toBe(`${values[i].toFixed(2)} mm`); + } + }); + + it("should convert value and unit if outputUnit is imperial", () => { + const values = [1, 2]; + const units = ["mm", "cm"]; + const expectedValues = [0.04, 0.79]; + + for (let i = 0; i < values.length; i++) { + const result = weather.convertPrecipitationUnit(values[i], units[i], "imperial"); + expect(result).toBe(`${expectedValues[i]} in`); + } + }); + + it("should round percentage values regardless of output units", () => { + const values = [0.1, 2.22, 9.999]; + const output = [undefined, "imperial", "metric"]; + const result = ["0 %", "2 %", "10 %"]; + + for (let i = 0; i < values.length; i++) { + expect(weather.convertPrecipitationUnit(values[i], "%", output[i])).toBe(result[i]); + } + }); + }); + + describe("convertWeatherObjectToImperial", () => { + it("should return null for null or empty input", () => { + expect(WeatherUtils.convertWeatherObjectToImperial(null)).toBeNull(); + expect(WeatherUtils.convertWeatherObjectToImperial({})).toBeNull(); + }); + + it("should convert 0°C correctly to 32°F", () => { + const result = WeatherUtils.convertWeatherObjectToImperial({ temperature: 0 }); + expect(result.temperature).toBe(32); + }); + + it("should convert all temperature fields", () => { + const result = WeatherUtils.convertWeatherObjectToImperial({ + temperature: 0, + feelsLikeTemp: 0, + minTemperature: -10, + maxTemperature: 10 + }); + expect(result.temperature).toBe(32); + expect(result.feelsLikeTemp).toBe(32); + expect(result.minTemperature).toBe(14); + expect(result.maxTemperature).toBe(50); + }); + + it("should convert windSpeed correctly", () => { + const result = WeatherUtils.convertWeatherObjectToImperial({ windSpeed: 10 }); + expect(result.windSpeed).toBeCloseTo(22.369, 2); + }); + + it("should convert precipitationAmount correctly", () => { + const result = WeatherUtils.convertWeatherObjectToImperial({ precipitationAmount: 25.4, precipitationUnits: "mm" }); + expect(result.precipitationAmount).toBeCloseTo(1, 5); + }); + + it("should not modify properties that are not present", () => { + const result = WeatherUtils.convertWeatherObjectToImperial({ temperature: 20 }); + expect("windSpeed" in result).toBe(false); + expect("feelsLikeTemp" in result).toBe(false); + }); + + it("should not mutate the original object", () => { + const input = { temperature: 20 }; + WeatherUtils.convertWeatherObjectToImperial(input); + expect(input.temperature).toBe(20); + }); + }); +}); diff --git a/tests/utils/vitest-setup.js b/tests/utils/vitest-setup.js new file mode 100644 index 0000000..c1e0a3b --- /dev/null +++ b/tests/utils/vitest-setup.js @@ -0,0 +1,39 @@ +/** + * Vitest setup file for module aliasing and CI logging + * This allows require("logger") to work in unit tests + */ + +const Module = require("node:module"); +const path = require("node:path"); + +// Set test mode flag for application code to detect test environment +process.env.mmTestMode = "true"; + +// overrides native implementation (Nodejs >= v25) to avoid warnings in tests +// "ExperimentalWarning: localStorage is not available because --localstorage-file was not provided." +Object.defineProperty(globalThis, "localStorage", { value: undefined, writable: true }); + +// Store the original require +const originalRequire = Module.prototype.require; + +// Track if we've already applied log level +let logLevelApplied = false; + +// Override require to handle our custom aliases +Module.prototype.require = function (id) { + // Handle "logger" alias + if (id === "logger") { + const logger = originalRequire.call(this, path.resolve(__dirname, "../../js/logger.js")); + + // Suppress debug/info logs in CI to keep output clean + if (!logLevelApplied && process.env.CI === "true") { + logger.setLogLevel("ERROR"); + logLevelApplied = true; + } + + return logger; + } + + // Handle all other requires normally + return originalRequire.apply(this, arguments); +}; diff --git a/translations/af.json b/translations/af.json new file mode 100644 index 0000000..8a138c2 --- /dev/null +++ b/translations/af.json @@ -0,0 +1,54 @@ +{ + "LOADING": "Besig om te laai …", + + "DAYBEFOREYESTERDAY": "Eergister", + "YESTERDAY": "Gister", + "TODAY": "Vandag", + "TOMORROW": "Môre", + "DAYAFTERTOMORROW": "Oormôre", + "RUNNING": "Eindig in", + "EMPTY": "Geen komende gebeurtenisse.", + "WEEK": "Week {weekNumber}", + + "N": "N", + "NNE": "NNO", + "NE": "NO", + "ENE": "ONO", + "E": "O", + "ESE": "OSO", + "SE": "SO", + "SSE": "SSO", + "S": "S", + "SSW": "SSW", + "SW": "SW", + "WSW": "WSW", + "W": "W", + "WNW": "WNW", + "NW": "NW", + "NNW": "NNW", + + "FEELS": "Voel soos {DEGREE}", + "PRECIP_POP": "Neerslag waarskynlikheid", + "PRECIP_AMOUNT": "Neerslag hoeveelheid", + + "MODULE_CONFIG_CHANGED": "Die konfigurasie opsies vir die {MODULE_NAME} module het verander.\nGaan asseblief die dokumentasie na.", + "MODULE_CONFIG_ERROR": "Fout in die {MODULE_NAME} module. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "Ongeldige URL.", + "MODULE_ERROR_NO_CONNECTION": "Geen internetverbinding.", + "MODULE_ERROR_UNAUTHORIZED": "Owerheid het misluk.", + "MODULE_ERROR_RATE_LIMITED": "Te veel versoeke. Probeer later weer.", + "MODULE_ERROR_SERVER_ERROR": "Bediener fout. Probeer later weer.", + "MODULE_ERROR_CLIENT_ERROR": "Versoek het misluk.", + "MODULE_ERROR_UNSPECIFIED": "Gaan die logs na vir meer besonderhede.", + + "NEWSFEED_NO_ITEMS": "Geen nuus op die oomblik.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artikel kan nie hier vertoon word nie.", + + "UPDATE_NOTIFICATION": "MagicMirror² update beskikbaar.", + "UPDATE_NOTIFICATION_MODULE": "Update beskikbaar vir {MODULE_NAME} module.", + "UPDATE_INFO_SINGLE": "Die huidige installasie is {COMMIT_COUNT} commit agter op die {BRANCH_NAME} branch.", + "UPDATE_INFO_MULTIPLE": "Die huidige installasie is {COMMIT_COUNT} commits agter op die {BRANCH_NAME} branch.", + "UPDATE_NOTIFICATION_DONE": "Update voltooi vir {MODULE_NAME} module.", + "UPDATE_NOTIFICATION_ERROR": "Fout tydens opdatering van {MODULE_NAME} module.", + "UPDATE_NOTIFICATION_NEED-RESTART": "MagicMirror moet herbegin word." +} diff --git a/translations/ar.json b/translations/ar.json new file mode 100644 index 0000000..fd2bfb6 --- /dev/null +++ b/translations/ar.json @@ -0,0 +1,52 @@ +{ + "LOADING": "جار التحميل …", + + "YESTERDAY": "أمس", + "TODAY": "اليوم", + "TOMORROW": "غدًا", + "RUNNING": "ينتهي خلال", + "EMPTY": "لا توجد أحداث قادمة.", + "WEEK": "الأسبوع {weekNumber}", + + "N": "شمال", + "NNE": "شمال شمال شرقي", + "NE": "شمال شرقي", + "ENE": "شرق شمال شرقي", + "E": "شرق", + "ESE": "شرق جنوب شرقي", + "SE": "جنوب شرقي", + "SSE": "جنوب جنوب شرقي", + "S": "جنوب", + "SSW": "جنوب جنوب غربي", + "SW": "جنوب غربي", + "WSW": "غرب جنوب غربي", + "W": "غرب", + "WNW": "غرب شمال غربي", + "NW": "شمال غربي", + "NNW": "شمال شمال غربي", + + "FEELS": "كأنها {DEGREE}", + "PRECIP_POP": "احتمالية الهطول", + "PRECIP_AMOUNT": "كمية الهطول", + + "MODULE_CONFIG_CHANGED": "تم تغيير خيارات التهيئة لوحدة {MODULE_NAME}.\nيرجى مراجعة الوثائق.", + "MODULE_CONFIG_ERROR": "خطأ في وحدة {MODULE_NAME}. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "رابط غير صحيح.", + "MODULE_ERROR_NO_CONNECTION": "لا يوجد اتصال بالإنترنت.", + "MODULE_ERROR_UNAUTHORIZED": "فشل التصريح.", + "MODULE_ERROR_RATE_LIMITED": "طلبات كثيرة جدا. إعادة المحاولة لاحقا.", + "MODULE_ERROR_SERVER_ERROR": "خطأ في الخادم. إعادة المحاولة لاحقا.", + "MODULE_ERROR_CLIENT_ERROR": "فشل الطلب.", + "MODULE_ERROR_UNSPECIFIED": "تحقق من السجلات لمزيد من التفاصيل.", + + "NEWSFEED_NO_ITEMS": "لا توجد أخبار في الوقت الحالي.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "لا يمكن عرض المقالة هنا.", + + "UPDATE_NOTIFICATION": "تحديث MagicMirror² متاح.", + "UPDATE_NOTIFICATION_MODULE": "تحديث متاح لوحدة {MODULE_NAME}.", + "UPDATE_INFO_SINGLE": "التثبيت الحالي متخلف عن تحديث واحد على فرع {BRANCH_NAME}.", + "UPDATE_INFO_MULTIPLE": "التثبيت الحالي متخلف عن {COMMIT_COUNT} تحديثات على فرع {BRANCH_NAME}.", + "UPDATE_NOTIFICATION_DONE": "تم التحديث لوحدة {MODULE_NAME}", + "UPDATE_NOTIFICATION_ERROR": "حدث خطأ أثناء تحديث وحدة {MODULE_NAME}", + "UPDATE_NOTIFICATION_NEED-RESTART": "يتطلب إعادة تشغيل MagicMirror." +} diff --git a/translations/bg.json b/translations/bg.json new file mode 100644 index 0000000..018422c --- /dev/null +++ b/translations/bg.json @@ -0,0 +1,54 @@ +{ + "LOADING": "Зареждане на …", + + "DAYBEFOREYESTERDAY": "Завчера", + "YESTERDAY": "Вчера", + "TODAY": "Днес", + "TOMORROW": "Утре", + "DAYAFTERTOMORROW": "Вдругиден", + "RUNNING": "Свършва след", + "EMPTY": "Няма предстоящи събития.", + "WEEK": "Седмица {weekNumber}", + + "N": "С", + "NNE": "ССИ", + "NE": "СИ", + "ENE": "ИСИ", + "E": "И", + "ESE": "ИЮИ", + "SE": "ЮИ", + "SSE": "ЮЮИ", + "S": "Ю", + "SSW": "ЮЮЗ", + "SW": "ЮЗ", + "WSW": "ЗЮЗ", + "W": "З", + "WNW": "ЗСЗ", + "NW": "СЗ", + "NNW": "ССЗ", + + "FEELS": "Усеща се като {DEGREE}", + "PRECIP_POP": "Вероятност за валежи", + "PRECIP_AMOUNT": "Количество валежи", + + "MODULE_CONFIG_CHANGED": "Променени са опциите за конфигурация на модула „{MODULE_NAME}“.\nМоля, проверете документацията.", + "MODULE_CONFIG_ERROR": "Грешка в модула „{MODULE_NAME}“. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "Неправилен URL адрес.", + "MODULE_ERROR_NO_CONNECTION": "Няма интернет връзка.", + "MODULE_ERROR_UNAUTHORIZED": "Неуспешна авторизация.", + "MODULE_ERROR_RATE_LIMITED": "Твърде много заявки. Повторен опит по-късно.", + "MODULE_ERROR_SERVER_ERROR": "Грешка в сървъра. Повторен опит по-късно.", + "MODULE_ERROR_CLIENT_ERROR": "Заявката неуспешна.", + "MODULE_ERROR_UNSPECIFIED": "Проверете логовете за повече подробности.", + + "NEWSFEED_NO_ITEMS": "Няма новини в момента.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Статията не може да бъде показана тук.", + + "UPDATE_NOTIFICATION": "Налична е актуализация за MagicMirror².", + "UPDATE_NOTIFICATION_MODULE": "Налична е актуализация за модула „{MODULE_NAME}“.", + "UPDATE_INFO_SINGLE": "Инсталираната версия е с {COMMIT_COUNT} ревизия назад от клона „{BRANCH_NAME}“.", + "UPDATE_INFO_MULTIPLE": "Инсталираната версия е с {COMMIT_COUNT} ревизии назад от клона „{BRANCH_NAME}“.", + "UPDATE_NOTIFICATION_DONE": "Актуализацията на модула „{MODULE_NAME}“ е завършена.", + "UPDATE_NOTIFICATION_ERROR": "Грешка при актуализацията на модула „{MODULE_NAME}“", + "UPDATE_NOTIFICATION_NEED-RESTART": "Необходимо е рестартиране на MagicMirror." +} diff --git a/translations/ca.json b/translations/ca.json new file mode 100644 index 0000000..1832d2c --- /dev/null +++ b/translations/ca.json @@ -0,0 +1,53 @@ +{ + "LOADING": "Carregant …", + + "YESTERDAY": "Ahir", + "TODAY": "Avui", + "TOMORROW": "Demà", + "DAYAFTERTOMORROW": "Demà passat", + "RUNNING": "Acaba en", + "EMPTY": "No hi ha esdeveniments programats.", + "WEEK": "Setmana", + + "N": "N", + "NNE": "NNE", + "NE": "NE", + "ENE": "ENE", + "E": "E", + "ESE": "ESE", + "SE": "SE", + "SSE": "SSE", + "S": "S", + "SSW": "SSO", + "SW": "SO", + "WSW": "OSO", + "W": "O", + "WNW": "ONO", + "NW": "NO", + "NNW": "NNO", + + "FEELS": "Sensació tèrmica {DEGREE}", + "PRECIP_POP": "Probabilitat de precipitació", + "PRECIP_AMOUNT": "Quantitat de precipitació", + + "MODULE_CONFIG_CHANGED": "S'ha canviat l'opció de configuració del mòdul {MODULE_NAME}.\nConsulta la documentació.", + "MODULE_CONFIG_ERROR": "S'ha produït un error al mòdul {MODULE_NAME}. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "L'URL és mal format.", + "MODULE_ERROR_NO_CONNECTION": "No hi ha connexió a Internet.", + "MODULE_ERROR_UNAUTHORIZED": "L'autorització ha fallat.", + "MODULE_ERROR_RATE_LIMITED": "Masses sol·licituds. Reintentant més tard.", + "MODULE_ERROR_SERVER_ERROR": "Error del servidor. Reintentant més tard.", + "MODULE_ERROR_CLIENT_ERROR": "La sol·licitud ha fallat.", + "MODULE_ERROR_UNSPECIFIED": "Consulta els registres per a més detalls.", + + "NEWSFEED_NO_ITEMS": "No hi ha notícies disponibles en aquest moment.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "L'article no es pot mostrar aquí.", + + "UPDATE_NOTIFICATION": "MagicMirror² actualizació disponible.", + "UPDATE_NOTIFICATION_MODULE": "Disponible una actualizació per al mòdul {MODULE_NAME}.", + "UPDATE_INFO_SINGLE": "La teva instal·lació actual està {COMMIT_COUNT} commit canvis darrere de la branca {BRANCH_NAME}.", + "UPDATE_INFO_MULTIPLE": "La teva instal·lació actual està {COMMIT_COUNT} commits canvis darrere de la branca {BRANCH_NAME}.", + "UPDATE_NOTIFICATION_DONE": "S'ha completat l'actualització del mòdul {MODULE_NAME}.", + "UPDATE_NOTIFICATION_ERROR": "S'ha produït un error durant l'actualització del mòdul {MODULE_NAME}.", + "UPDATE_NOTIFICATION_NEED-RESTART": "És necessari reiniciar MagicMirror." +} diff --git a/translations/cs.json b/translations/cs.json new file mode 100644 index 0000000..72034ac --- /dev/null +++ b/translations/cs.json @@ -0,0 +1,54 @@ +{ + "LOADING": "Načítání …", + + "DAYBEFOREYESTERDAY": "Předevčírem", + "YESTERDAY": "Včera", + "TODAY": "Dnes", + "TOMORROW": "Zítra", + "DAYAFTERTOMORROW": "Pozítří", + "RUNNING": "Končí za", + "EMPTY": "Žádné nadcházející události.", + "WEEK": "{weekNumber}. týden", + + "N": "S", + "NNE": "SSV", + "NE": "SV", + "ENE": "VSV", + "E": "V", + "ESE": "VJV", + "SE": "JV", + "SSE": "JJV", + "S": "J", + "SSW": "JJZ", + "SW": "JZ", + "WSW": "ZJZ", + "W": "Z", + "WNW": "ZSZ", + "NW": "SZ", + "NNW": "SSZ", + + "FEELS": "Pocitově {DEGREE}", + "PRECIP_POP": "Pravděpodobnost deště", + "PRECIP_AMOUNT": "Množství deště", + + "MODULE_CONFIG_CHANGED": "Konfigurační možnosti modulu {MODULE_NAME} byly změněny.\nProsím, zkontrolujte dokumentaci.", + "MODULE_CONFIG_ERROR": "Chyba v modulu {MODULE_NAME}. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "Nesprávná URL adresa.", + "MODULE_ERROR_NO_CONNECTION": "Není připojení k internetu.", + "MODULE_ERROR_UNAUTHORIZED": "Autorizace selhala.", + "MODULE_ERROR_RATE_LIMITED": "Příliš mnoho požadavků. Zkouším znovu později.", + "MODULE_ERROR_SERVER_ERROR": "Chyba serveru. Zkouším znovu později.", + "MODULE_ERROR_CLIENT_ERROR": "Požadavek selhal.", + "MODULE_ERROR_UNSPECIFIED": "Zkontrolujte protokoly pro více informací.", + + "NEWSFEED_NO_ITEMS": "Žádné zprávy.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Článek zde nelze zobrazit.", + + "UPDATE_NOTIFICATION": "Dostupná aktualizace pro MagicMirror².", + "UPDATE_NOTIFICATION_MODULE": "Dostupná aktualizace pro modul {MODULE_NAME}.", + "UPDATE_INFO_SINGLE": "Současná instalace je na větvi {BRANCH_NAME} pozadu o {COMMIT_COUNT} commit.", + "UPDATE_INFO_MULTIPLE": "Současná instalace je na větvi {BRANCH_NAME} pozadu o {COMMIT_COUNT} commits.", + "UPDATE_NOTIFICATION_DONE": "Aktualizace dokončena pro modul {MODULE_NAME}.", + "UPDATE_NOTIFICATION_ERROR": "Chyba aktualizace modulu {MODULE_NAME}.", + "UPDATE_NOTIFICATION_NEED-RESTART": "Je třeba restartovat MagicMirror." +} diff --git a/translations/cv.json b/translations/cv.json new file mode 100644 index 0000000..22e2325 --- /dev/null +++ b/translations/cv.json @@ -0,0 +1,53 @@ +{ + "LOADING": "Тиенет …", + + "YESTERDAY": "Знон", + "TODAY": "Паян", + "TOMORROW": "Ыран", + "DAYAFTERTOMORROW": "Виҫмине", + "RUNNING": "Хальхи", + "EMPTY": "Пулас ӗҫ ҫук", + "WEEK": "{weekNumber} эрне", + + "N": "Ҫ", + "NNE": "ҪҪТ", + "NE": "ҪТ", + "ENE": "ТҪТ", + "E": "Т", + "ESE": "ТКТ", + "SE": "КТ", + "SSE": "ККТ", + "S": "К", + "SSW": "ККА", + "SW": "КА", + "WSW": "АКА", + "W": "А", + "WNW": "АҪА", + "NW": "ҪА", + "NNW": "ҪҪА", + + "FEELS": "Туйӑннӑ {DEGREE}", + "PRECIP_POP": "Ҫумӑр ҫума пултарасси", + "PRECIP_AMOUNT": "Ҫумӑр виҫи", + + "MODULE_CONFIG_CHANGED": "{MODULE_NAME} модулӗн конфигураци опциялӗ пур ҫӗнтерӗ.", + "MODULE_CONFIG_ERROR": "{MODULE_NAME} модулӗнде хата. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "Ҫӗҫ ҫӗнӗ URL хата.", + "MODULE_ERROR_NO_CONNECTION": "Интернет-пулла хӗҫҫӗн.", + "MODULE_ERROR_UNAUTHORIZED": "Авторизация хата.", + "MODULE_ERROR_RATE_LIMITED": "Нумай ыйту. Хыҫра тепӗр хут.", + "MODULE_ERROR_SERVER_ERROR": "Сервер хатӗ. Хыҫра тепӗр хут.", + "MODULE_ERROR_CLIENT_ERROR": "Ыйту хатӗ.", + "MODULE_ERROR_UNSPECIFIED": "Тӗп лог ҫӗнтерӗ.", + + "NEWSFEED_NO_ITEMS": "Пулас ҫӗнтер ҫук.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Article cannot be displayed here.", + + "UPDATE_NOTIFICATION": "MagicMirror² валли ҫӗнетӳ пур.", + "UPDATE_NOTIFICATION_MODULE": "{MODULE_NAME} модуль валли ҫӗнетӳ пур.", + "UPDATE_INFO_SINGLE": "Ҫак инсталляци {BRANCH_NAME} commit турат {COMMIT_COUNT} коммитпа кая уйрӑлса тӑрать.", + "UPDATE_INFO_MULTIPLE": "Ҫак инсталляци {BRANCH_NAME} commit турат {COMMIT_COUNT} коммитпа кая уйрӑлса тӑрать.", + "UPDATE_NOTIFICATION_DONE": "{MODULE_NAME} модулӗнде валли ҫӗнетӳ пур.", + "UPDATE_NOTIFICATION_ERROR": "{MODULE_NAME} модулӗнде валли ҫӗнетӳ хата.", + "UPDATE_NOTIFICATION_NEED-RESTART": "MagicMirror перезагрузка тӗрӗҫҫӗн тӑрать." +} diff --git a/translations/cy.json b/translations/cy.json new file mode 100644 index 0000000..d172aa2 --- /dev/null +++ b/translations/cy.json @@ -0,0 +1,53 @@ +{ + "LOADING": "Llwytho …", + + "YESTERDAY": "Ddoe", + "TODAY": "Heddiw", + "TOMORROW": "Yfory", + "DAYAFTERTOMORROW": "Drennydd", + "RUNNING": "Gorffen mewn", + "EMPTY": "Dim digwyddiadau.", + "WEEK": "Wythnos {weekNumber}", + + "N": "Go", + "NNE": "GoGoDw", + "NE": "GoDw", + "ENE": "DwGoDw", + "E": "Dw", + "ESE": "DwDeDw", + "SE": "DwDe", + "SSE": "DeDeDw", + "S": "De", + "SSW": "DeDeGr", + "SW": "DeGr", + "WSW": "GrDeGr", + "W": "Gr", + "WNW": "GrGoGr", + "NW": "GoGr", + "NNW": "GoGoGe", + + "FEELS": "Teimlad {DEGREE}", + "PRECIP_POP": "Tebygolrwydd glawiad", + "PRECIP_AMOUNT": "Cyfanswm glawiad", + + "MODULE_CONFIG_CHANGED": "Mae'r dewisiadau ar gyfer y modiwl {MODULE_NAME} wedi newid.\nGwiriwch y ddogfennaeth.", + "MODULE_CONFIG_ERROR": "Gwall yn y modiwl {MODULE_NAME}. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "URL anghywir.", + "MODULE_ERROR_NO_CONNECTION": "Dim cysylltiad rhyngrwyd.", + "MODULE_ERROR_UNAUTHORIZED": "Methiant awdurdodi.", + "MODULE_ERROR_RATE_LIMITED": "Gormod o geisiadau. Yn ceisio eto yn nes ymlaen.", + "MODULE_ERROR_SERVER_ERROR": "Gwall gweinydd. Yn ceisio eto yn nes ymlaen.", + "MODULE_ERROR_CLIENT_ERROR": "Cais wedi methu.", + "MODULE_ERROR_UNSPECIFIED": "Gwiriwch y logiau am ragor o fanylion.", + + "NEWSFEED_NO_ITEMS": "Dim newyddion ar hyn o bryd.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Ni ellir dangos yr erthygl yma.", + + "UPDATE_NOTIFICATION": "MagicMirror² mwy diweddar yn barod.", + "UPDATE_NOTIFICATION_MODULE": "Mae diweddaraiad ar gyfer y modiwl {MODULE_NAME}.", + "UPDATE_INFO_SINGLE": "Mae'r fersiwn bresenol {COMMIT_COUNT} commit tu ôl i'r gangen {BRANCH_NAME}.", + "UPDATE_INFO_MULTIPLE": "Mae'r fersiwn bresenol {COMMIT_COUNT} commit tu ôl i'r gangen {BRANCH_NAME}.", + "UPDATE_NOTIFICATION_DONE": "Diweddariad wedi'i gwblhau ar gyfer y modiwl {MODULE_NAME}", + "UPDATE_NOTIFICATION_ERROR": "Gwall diweddariad ar gyfer y modiwl {MODULE_NAME}", + "UPDATE_NOTIFICATION_NEED-RESTART": "Mae angen ailgychwyn MagicMirror." +} diff --git a/translations/da.json b/translations/da.json new file mode 100644 index 0000000..6b56913 --- /dev/null +++ b/translations/da.json @@ -0,0 +1,54 @@ +{ + "LOADING": "Indlæser …", + + "DAYBEFOREYESTERDAY": "Forgårs", + "YESTERDAY": "I går", + "TODAY": "I dag", + "TOMORROW": "I morgen", + "DAYAFTERTOMORROW": "I overmorgen", + "RUNNING": "Slutter om", + "EMPTY": "Ingen kommende begivenheder.", + "WEEK": "Uge {weekNumber}", + + "N": "N", + "NNE": "NNØ", + "NE": "NØ", + "ENE": "ØNØ", + "E": "Ø", + "ESE": "ØSØ", + "SE": "SØ", + "SSE": "SSØ", + "S": "S", + "SSW": "SSV", + "SW": "SV", + "WSW": "VSV", + "W": "V", + "WNW": "VNV", + "NW": "NV", + "NNW": "NNV", + + "FEELS": "Føles som {DEGREE}", + "PRECIP_POP": "Sandsynlighed for nedbør", + "PRECIP_AMOUNT": "Nedbørsmængde", + + "MODULE_CONFIG_CHANGED": "Konfigurationsmulighederne for {MODULE_NAME} modulet er ændret.\nSe venligst dokumentationen.", + "MODULE_CONFIG_ERROR": "Fejl i {MODULE_NAME} modulet. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "Forkert url.", + "MODULE_ERROR_NO_CONNECTION": "Ingen internetforbindelse.", + "MODULE_ERROR_UNAUTHORIZED": "Godkendelse mislykkedes.", + "MODULE_ERROR_RATE_LIMITED": "For mange anmodninger. Prøver igen senere.", + "MODULE_ERROR_SERVER_ERROR": "Serverfejl. Prøver igen senere.", + "MODULE_ERROR_CLIENT_ERROR": "Anmodning mislykkedes.", + "MODULE_ERROR_UNSPECIFIED": "Tjek logfiler for flere detaljer.", + + "NEWSFEED_NO_ITEMS": "Ingen nyheder i øjeblikket.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artiklen kan ikke vises her.", + + "UPDATE_NOTIFICATION": "MagicMirror² opdatering tilgængelig.", + "UPDATE_NOTIFICATION_MODULE": "Opdatering tilgængelig for {MODULE_NAME} modulet.", + "UPDATE_INFO_SINGLE": "Den nuværende installation er {COMMIT_COUNT} commit bagud på {BRANCH_NAME} branch'en.", + "UPDATE_INFO_MULTIPLE": "Den nuværende installation er {COMMIT_COUNT} commits bagud på {BRANCH_NAME} branch'en.", + "UPDATE_NOTIFICATION_DONE": "Opdatering færdig for {MODULE_NAME} modulet", + "UPDATE_NOTIFICATION_ERROR": "Opdateringsfejl for {MODULE_NAME} modulet", + "UPDATE_NOTIFICATION_NEED-RESTART": "Genstart af MagicMirror er påkrævet." +} diff --git a/translations/de.json b/translations/de.json new file mode 100644 index 0000000..af50e69 --- /dev/null +++ b/translations/de.json @@ -0,0 +1,55 @@ +{ + "LOADING": "Lade …", + + "DAYBEFOREYESTERDAY": "Vorgestern", + "YESTERDAY": "Gestern", + "TODAY": "Heute", + "TOMORROW": "Morgen", + "DAYAFTERTOMORROW": "Übermorgen", + "RUNNING": "noch", + "EMPTY": "Keine Termine.", + "WEEK": "{weekNumber}. Kalenderwoche", + "WEEK_SHORT": "{weekNumber}KW", + + "N": "N", + "NNE": "NNO", + "NE": "NO", + "ENE": "ONO", + "E": "O", + "ESE": "OSO", + "SE": "SO", + "SSE": "SSO", + "S": "S", + "SSW": "SSW", + "SW": "SW", + "WSW": "WSW", + "W": "W", + "WNW": "WNW", + "NW": "NW", + "NNW": "NNW", + + "FEELS": "Gefühlt {DEGREE}", + "PRECIP_POP": "Niederschlagswahrscheinlichkeit", + "PRECIP_AMOUNT": "Niederschlagsmenge", + + "MODULE_CONFIG_CHANGED": "Die Konfigurationsoptionen für das Modul „{MODULE_NAME}“ haben sich geändert. \nBitte überprüfen Sie die Dokumentation.", + "MODULE_CONFIG_ERROR": "Fehler im Modul „{MODULE_NAME}“. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "Fehlerhafte URL.", + "MODULE_ERROR_NO_CONNECTION": "Keine Internetverbindung.", + "MODULE_ERROR_UNAUTHORIZED": "Autorisierung fehlgeschlagen.", + "MODULE_ERROR_RATE_LIMITED": "Zu viele Anfragen. Erneuter Versuch später.", + "MODULE_ERROR_SERVER_ERROR": "Serverfehler. Erneuter Versuch später.", + "MODULE_ERROR_CLIENT_ERROR": "Anfrage fehlgeschlagen.", + "MODULE_ERROR_UNSPECIFIED": "Prüfe die Logdateien für weitere Details.", + + "NEWSFEED_NO_ITEMS": "Momentan keine Neuigkeiten.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artikel kann hier nicht angezeigt werden.", + + "UPDATE_NOTIFICATION": "Aktualisierung für MagicMirror² verfügbar.", + "UPDATE_NOTIFICATION_MODULE": "Aktualisierung für das Modul „{MODULE_NAME}“ verfügbar.", + "UPDATE_INFO_SINGLE": "Die aktuelle Installation ist ein Commit hinter dem {BRANCH_NAME}-Branch.", + "UPDATE_INFO_MULTIPLE": "Die aktuelle Installation ist {COMMIT_COUNT} Commits hinter dem {BRANCH_NAME}-Branch.", + "UPDATE_NOTIFICATION_DONE": "Aktualisierung für das Modul {MODULE_NAME} abgeschlossen.", + "UPDATE_NOTIFICATION_ERROR": "Fehler bei der Aktualisierung für das Modul {MODULE_NAME}.", + "UPDATE_NOTIFICATION_NEED-RESTART": "MagicMirror muss neu gestartet werden." +} diff --git a/translations/el.json b/translations/el.json new file mode 100644 index 0000000..9cc161d --- /dev/null +++ b/translations/el.json @@ -0,0 +1,54 @@ +{ + "LOADING": "Φόρτωση ...", + + "DAYBEFOREYESTERDAY": "Προχθές", + "YESTERDAY": "Χθες", + "TODAY": "Σήμερα", + "TOMORROW": "Αύριο", + "DAYAFTERTOMORROW": "Μεθαύριο", + "RUNNING": "Λήγει σε", + "EMPTY": "Δεν υπάρχουν προσεχείς εκδηλώσεις.", + "WEEK": "Εβδομάδα {weekNumber}", + + "N": "B", + "NNE": "BBA", + "NE": "BA", + "ENE": "ABA", + "E": "A", + "ESE": "ANA", + "SE": "NA", + "SSE": "NNA", + "S": "N", + "SSW": "NNΔ", + "SW": "NΔ", + "WSW": "ΔNΔ", + "W": "Δ", + "WNW": "ΔΒΔ", + "NW": "ΒΔ", + "NNW": "ΒΒΔ", + + "FEELS": "Αίσθηση {DEGREE}", + "PRECIP_POP": "Πιθ. υετού", + "PRECIP_AMOUNT": "Ποσότητα υετού", + + "MODULE_CONFIG_CHANGED": "Οι επιλογές διαμόρφωσης για το module {MODULE_NAME} έχουν αλλάξει.\nΕλέγξτε την τεκμηρίωση.", + "MODULE_CONFIG_ERROR": "Σφάλμα στο module {MODULE_NAME}. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "Λανθασμένη μορφή url.", + "MODULE_ERROR_NO_CONNECTION": "Δεν υπάρχει σύνδεση στο διαδίκτυο.", + "MODULE_ERROR_UNAUTHORIZED": "Η εξουσιοδότηση απέτυχε.", + "MODULE_ERROR_RATE_LIMITED": "Πάρα πολλά αιτήματα. Θα ξαναπροσπαθήσω αργότερα.", + "MODULE_ERROR_SERVER_ERROR": "Σφάλμα διακομιστή. Θα ξαναπροσπαθήσω αργότερα.", + "MODULE_ERROR_CLIENT_ERROR": "Το αίτημα απέτυχε.", + "MODULE_ERROR_UNSPECIFIED": "Ελέγξτε τα αρχεία καταγραφής για περισσότερες λεπτομέρειες.", + + "NEWSFEED_NO_ITEMS": "Δεν υπάρχουν ειδήσεις αυτή τη στιγμή.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Το άρθρο δεν μπορεί να εμφανιστεί εδώ.", + + "UPDATE_NOTIFICATION": "Διατίθεται ενημέρωση MagicMirror².", + "UPDATE_NOTIFICATION_MODULE": "Διατίθεται ενημέρωση για το module {MODULE_NAME}.", + "UPDATE_INFO_SINGLE": "Η τρέχουσα εγκατάσταση είναι {COMMIT_COUNT} commit πίσω στο branch {BRANCH_NAME}.", + "UPDATE_INFO_MULTIPLE": "Η τρέχουσα εγκατάσταση είναι {COMMIT_COUNT} commit πίσω στο branch {BRANCH_NAME}", + "UPDATE_NOTIFICATION_DONE": "Η ενημέρωση ολοκληρώθηκε για το module {MODULE_NAME}", + "UPDATE_NOTIFICATION_ERROR": "Η ενημέρωση απέτυχε για το module {MODULE_NAME}", + "UPDATE_NOTIFICATION_NEED-RESTART": "Απαιτείται επανεκκίνηση του MagicMirror." +} diff --git a/translations/en.json b/translations/en.json new file mode 100644 index 0000000..311d497 --- /dev/null +++ b/translations/en.json @@ -0,0 +1,53 @@ +{ + "LOADING": "Loading …", + + "YESTERDAY": "Yesterday", + "TODAY": "Today", + "TOMORROW": "Tomorrow", + "RUNNING": "Ends in", + "EMPTY": "No upcoming events.", + "WEEK": "Week {weekNumber}", + "WEEK_SHORT": "W{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", + + "FEELS": "Feels like {DEGREE}", + "PRECIP_POP": "PoP", + "PRECIP_AMOUNT": "Precipitation amount", + + "MODULE_CONFIG_CHANGED": "The configuration options for the {MODULE_NAME} module have changed.\nPlease check the documentation.", + "MODULE_CONFIG_ERROR": "Error in the {MODULE_NAME} module. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "Malformed url.", + "MODULE_ERROR_NO_CONNECTION": "No internet connection.", + "MODULE_ERROR_UNAUTHORIZED": "Authorization failed.", + "MODULE_ERROR_RATE_LIMITED": "Too many requests. Retrying later.", + "MODULE_ERROR_SERVER_ERROR": "Server error. Retrying later.", + "MODULE_ERROR_CLIENT_ERROR": "Request failed.", + "MODULE_ERROR_UNSPECIFIED": "Check logs for more details.", + + "NEWSFEED_NO_ITEMS": "No news at the moment.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Article cannot be displayed here.", + + "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.", + "UPDATE_NOTIFICATION_DONE": "Update done for {MODULE_NAME} module", + "UPDATE_NOTIFICATION_ERROR": "Update error for {MODULE_NAME} module", + "UPDATE_NOTIFICATION_NEED-RESTART": "Restarting of MagicMirror is required." +} diff --git a/translations/eo.json b/translations/eo.json new file mode 100644 index 0000000..e1a9475 --- /dev/null +++ b/translations/eo.json @@ -0,0 +1,55 @@ +{ + "LOADING": "Ŝarĝas …", + + "DAYBEFOREYESTERDAY": "antaŭhieraŭ", + "YESTERDAY": "hieraŭ", + "TODAY": "hodiaŭ", + "TOMORROW": "morgaŭ", + "DAYAFTERTOMORROW": "postmorgaŭ", + "RUNNING": "ankoraŭ", + "EMPTY": "Neniu evento.", + "WEEK": "{weekNumber}a kalendara semajno", + "WEEK_SHORT": "{weekNumber}a KS", + + "N": "N", + "NNE": "NNOr", + "NE": "NOr", + "ENE": "OrNOr", + "E": "Or", + "ESE": "OrSOr", + "SE": "SOr", + "SSE": "SSOr", + "S": "S", + "SSW": "SSOk", + "SW": "SOk", + "WSW": "OkSOk", + "W": "Ok", + "WNW": "OkNOk", + "NW": "NOk", + "NNW": "NNOk", + + "FEELS": "Perceptite kiel {DEGREE}", + "PRECIP_POP": "Ŝanco de pluvado", + "PRECIP_AMOUNT": "Kvanto de pluvo", + + "MODULE_CONFIG_CHANGED": "La agordaj opcioj por la modulo „{MODULE_NAME}“ ŝanĝiĝis. \nBonvolu kontroli la dokumentadon.", + "MODULE_CONFIG_ERROR": "Eraro en la modulo „{MODULE_NAME}“. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "Malĝusta URL.", + "MODULE_ERROR_NO_CONNECTION": "Neniu interreta konekto.", + "MODULE_ERROR_UNAUTHORIZED": "Aŭtorigo malsukcesis.", + "MODULE_ERROR_RATE_LIMITED": "Tro multaj petoj. Reprovo poste.", + "MODULE_ERROR_SERVER_ERROR": "Servila eraro. Reprovo poste.", + "MODULE_ERROR_CLIENT_ERROR": "Peto malsukcesis.", + "MODULE_ERROR_UNSPECIFIED": "Kontrolu la protokolajn dosierojn por pli da detaloj.", + + "NEWSFEED_NO_ITEMS": "Momente neniu novaĵoj.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artikolo ne estas montrebla ĉi tie.", + + "UPDATE_NOTIFICATION": "Ĝisdatigo por MagicMirror² disponebla.", + "UPDATE_NOTIFICATION_MODULE": "Ĝisdatigo por la modulo „{MODULE_NAME}“ disponebla.", + "UPDATE_INFO_SINGLE": "La nuna instalado estas unu komito malantaŭ la {BRANCH_NAME}-branĉo.", + "UPDATE_INFO_MULTIPLE": "La nuna instalado estas {COMMIT_COUNT} komitoj malantaŭ la {BRANCH_NAME}-branĉo.", + "UPDATE_NOTIFICATION_DONE": "Ĝisdatigo por la modulo {MODULE_NAME} finita.", + "UPDATE_NOTIFICATION_ERROR": "Eraro dum la ĝisdatigo de la modulo {MODULE_NAME}.", + "UPDATE_NOTIFICATION_NEED-RESTART": "MagicMirror devas esti restartigita." +} diff --git a/translations/es.json b/translations/es.json new file mode 100644 index 0000000..f157d35 --- /dev/null +++ b/translations/es.json @@ -0,0 +1,55 @@ +{ + "LOADING": "Cargando …", + + "DAYBEFOREYESTERDAY": "Anteayer", + "YESTERDAY": "Ayer", + "TODAY": "Hoy", + "TOMORROW": "Mañana", + "DAYAFTERTOMORROW": "Pasado mañana", + "RUNNING": "Termina en", + "EMPTY": "No hay eventos programados.", + "WEEK": "Semana {weekNumber}", + "WEEK_SHORT": "S{weekNumber}", + + "N": "N", + "NNE": "NNE", + "NE": "NE", + "ENE": "ENE", + "E": "E", + "ESE": "ESE", + "SE": "SE", + "SSE": "SSE", + "S": "S", + "SSW": "SSO", + "SW": "SO", + "WSW": "OSO", + "W": "O", + "WNW": "ONO", + "NW": "NO", + "NNW": "NNO", + + "FEELS": "Sensación térmica de {DEGREE}", + "PRECIP_POP": "Precipitación", + "PRECIP_AMOUNT": "Cantidad de precipitación", + + "MODULE_CONFIG_CHANGED": "Las opciones de configuración para el módulo {MODULE_NAME} han cambiado. \nVerifique la documentación.", + "MODULE_CONFIG_ERROR": "Error en el módulo {MODULE_NAME}. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "URL mal formado.", + "MODULE_ERROR_NO_CONNECTION": "No hay conexión a Internet.", + "MODULE_ERROR_UNAUTHORIZED": "No autorizado.", + "MODULE_ERROR_RATE_LIMITED": "Demasiadas solicitudes. Reintentando más tarde.", + "MODULE_ERROR_SERVER_ERROR": "Error del servidor. Reintentando más tarde.", + "MODULE_ERROR_CLIENT_ERROR": "La solicitud falló.", + "MODULE_ERROR_UNSPECIFIED": "Por favor, consulte los registros para obtener más información.", + + "NEWSFEED_NO_ITEMS": "No hay noticias disponibles en este momento.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "El artículo no se puede mostrar aquí.", + + "UPDATE_NOTIFICATION": "MagicMirror² actualización disponible.", + "UPDATE_NOTIFICATION_MODULE": "Disponible una actualización para el módulo {MODULE_NAME}.", + "UPDATE_INFO_SINGLE": "Tu actual instalación está {COMMIT_COUNT} commit cambios detrás de la rama {BRANCH_NAME}.", + "UPDATE_INFO_MULTIPLE": "Tu actual instalación está {COMMIT_COUNT} commits cambios detrás de la rama {BRANCH_NAME}.", + "UPDATE_NOTIFICATION_DONE": "S'ha completat l'actualització del mòdul {MODULE_NAME}.", + "UPDATE_NOTIFICATION_ERROR": "S'ha produït un error durant l'actualització del mòdul {MODULE_NAME}.", + "UPDATE_NOTIFICATION_NEED-RESTART": "És necessari reiniciar MagicMirror." +} diff --git a/translations/et.json b/translations/et.json new file mode 100644 index 0000000..e077798 --- /dev/null +++ b/translations/et.json @@ -0,0 +1,54 @@ +{ + "LOADING": "Laadimine…", + + "DAYBEFOREYESTERDAY": "Üleeile", + "YESTERDAY": "Eile", + "TODAY": "Täna", + "TOMORROW": "Homme", + "DAYAFTERTOMORROW": "Ülehomme", + "RUNNING": "Lõpeb", + "EMPTY": "Eelolevaid sündmusi pole.", + "WEEK": "Nädal {weekNumber}", + + "N": "Põhi", + "NNE": "Põhjakirre", + "NE": "Kirre", + "ENE": "Idakirre", + "E": "Ida", + "ESE": "Idakagu", + "SE": "Kagu", + "SSE": "Lõunakagu", + "S": "Lõuna", + "SSW": "Lõunaedel", + "SW": "Edel", + "WSW": "Lääneedel", + "W": "Lääs", + "WNW": "Lääneloe", + "NW": "Loe", + "NNW": "Põhjaloe", + + "FEELS": "Tajutav temperatuur {DEGREE}", + "PRECIP_POP": "Sademete tõenäosus", + "PRECIP_AMOUNT": "Sademete hulk", + + "MODULE_CONFIG_CHANGED": "Konfiguratiooni sätted {MODULE_NAME} moodulile on muutunud.\nPalun vaadake dokumentatsiooni.", + "MODULE_CONFIG_ERROR": "Error moodulis {MODULE_NAME}. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "Ebakorrektne url.", + "MODULE_ERROR_NO_CONNECTION": "Interneti ühendus puudub.", + "MODULE_ERROR_UNAUTHORIZED": "Autoriseerimine ebaõnnestus.", + "MODULE_ERROR_RATE_LIMITED": "Liiga palju päringuid. Proovin hiljem uuesti.", + "MODULE_ERROR_SERVER_ERROR": "Serveri viga. Proovin hiljem uuesti.", + "MODULE_ERROR_CLIENT_ERROR": "Päring ebaõnnestus.", + "MODULE_ERROR_UNSPECIFIED": "Lisateabe saamiseks kontrollige logifaile.", + + "NEWSFEED_NO_ITEMS": "Hetkel ei ole uudiseid.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artiklit ei saa siin kuvada.", + + "UPDATE_NOTIFICATION": "MagicMirror²'le on uuendus saadaval.", + "UPDATE_NOTIFICATION_MODULE": "Uuendus saadaval {MODULE_NAME} moodulile.", + "UPDATE_INFO_SINGLE": "Praegune paigaldus on {COMMIT_COUNT} muudatust tagapool {BRANCH_NAME} harus.", + "UPDATE_INFO_MULTIPLE": "Praegune paigaldus on {COMMIT_COUNT} muudatust tagapool {BRANCH_NAME} harus.", + "UPDATE_NOTIFICATION_DONE": "{MODULE_NAME} moodul uuendati edukalt", + "UPDATE_NOTIFICATION_ERROR": "{MODULE_NAME} mooduli uuendamine ebaõnnestus", + "UPDATE_NOTIFICATION_NEED-RESTART": "Palun taaskäivitage MagicMirror." +} diff --git a/translations/fi.json b/translations/fi.json new file mode 100644 index 0000000..b6e2d30 --- /dev/null +++ b/translations/fi.json @@ -0,0 +1,54 @@ +{ + "LOADING": "Lataa …", + + "DAYBEFOREYESTERDAY": "Toissapäivänä", + "YESTERDAY": "Eilen", + "TODAY": "Tänään", + "TOMORROW": "Huomenna", + "DAYAFTERTOMORROW": "Ylihuomenna", + "RUNNING": "Päättyy {timeUntilEnd} päästä", + "EMPTY": "Ei tulevia tapahtumia.", + "WEEK": "Viikko {weekNumber}", + + "N": "P", + "NNE": "PPI", + "NE": "PI", + "ENE": "IPI", + "E": "I", + "ESE": "IEI", + "SE": "EI", + "SSE": "EEI", + "S": "E", + "SSW": "EEL", + "SW": "EL", + "WSW": "LEL", + "W": "L", + "WNW": "LPL", + "NW": "PL", + "NNW": "PPL", + + "FEELS": "Tuntuu kuin {DEGREE}", + "PRECIP_POP": "Sateen todennäköisyys", + "PRECIP_AMOUNT": "Sateen määrä", + + "MODULE_CONFIG_CHANGED": "Moduulin {MODULE_NAME} asetukset on muutettu.\nOle hyvä ja tarkista dokumentaatio.", + "MODULE_CONFIG_ERROR": "Virhe moduulissa {MODULE_NAME}. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "Virheellinen url.", + "MODULE_ERROR_NO_CONNECTION": "Ei internet-yhteyttä.", + "MODULE_ERROR_UNAUTHORIZED": "Valtuutus epäonnistui.", + "MODULE_ERROR_RATE_LIMITED": "Liikaa pyyntöjä. Yritetään myöhemmin uudelleen.", + "MODULE_ERROR_SERVER_ERROR": "Palvelinvirhe. Yritetään myöhemmin uudelleen.", + "MODULE_ERROR_CLIENT_ERROR": "Pyyntö epäonnistui.", + "MODULE_ERROR_UNSPECIFIED": "Tarkista lokitiedostot saadaksesi lisätietoja.", + + "NEWSFEED_NO_ITEMS": "Ei uutisia tällä hetkellä.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artikkelia ei voi näyttää täällä.", + + "UPDATE_NOTIFICATION": "MagicMirror² päivitys saatavilla.", + "UPDATE_NOTIFICATION_MODULE": "Päivitys saatavilla moduulille {MODULE_NAME}.", + "UPDATE_INFO_SINGLE": "Nykyasennus on {COMMIT_COUNT} muutoksen jäljessä {BRANCH_NAME} haaraan nähden.", + "UPDATE_INFO_MULTIPLE": "Nykyasennus on {COMMIT_COUNT} muutosta jäljessä {BRANCH_NAME} haaraan nähden.", + "UPDATE_NOTIFICATION_DONE": "Päivitys moduulille {MODULE_NAME} valmis.", + "UPDATE_NOTIFICATION_ERROR": "Virhe moduulin {MODULE_NAME} päivityksessä.", + "UPDATE_NOTIFICATION_NEED-RESTART": "MagicMirror tulee käynnistää uudelleen." +} diff --git a/translations/fr.json b/translations/fr.json new file mode 100644 index 0000000..47c9369 --- /dev/null +++ b/translations/fr.json @@ -0,0 +1,55 @@ +{ + "LOADING": "Chargement…", + + "DAYBEFOREYESTERDAY": "Avant-hier", + "YESTERDAY": "Hier", + "TODAY": "Aujourd'hui", + "TOMORROW": "Demain", + "DAYAFTERTOMORROW": "Après-demain", + "RUNNING": "Se termine dans", + "EMPTY": "Aucun RDV à venir.", + "WEEK": "Semaine {weekNumber}", + "WEEK_SHORT": "S{weekNumber}", + + "N": "N", + "NNE": "NNE", + "NE": "NE", + "ENE": "ENE", + "E": "E", + "ESE": "ESE", + "SE": "SE", + "SSE": "SSE", + "S": "S", + "SSW": "SSO", + "SW": "SO", + "WSW": "OSO", + "W": "O", + "WNW": "ONO", + "NW": "NO", + "NNW": "NNO", + + "FEELS": "Ressenti {DEGREE}", + "PRECIP_POP": "Probabilité de précipitations", + "PRECIP_AMOUNT": "Quantité des précipitations", + + "MODULE_CONFIG_CHANGED": "Les options de configuration du module {MODULE_NAME} ont changé.\nVeuillez consulter la documentation.", + "MODULE_CONFIG_ERROR": "Erreur dans le module {MODULE_NAME}. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "URL mal formée.", + "MODULE_ERROR_NO_CONNECTION": "Pas de connexion Internet.", + "MODULE_ERROR_UNAUTHORIZED": "L'autorisation à échouée.", + "MODULE_ERROR_RATE_LIMITED": "Trop de requêtes. Nouvelle tentative plus tard.", + "MODULE_ERROR_SERVER_ERROR": "Erreur du serveur. Nouvelle tentative plus tard.", + "MODULE_ERROR_CLIENT_ERROR": "La requête a échoué.", + "MODULE_ERROR_UNSPECIFIED": "Consultez les journaux pour plus de détails.", + + "NEWSFEED_NO_ITEMS": "Aucune nouvelle pour le moment.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "L'article ne peut pas être affiché ici.", + + "UPDATE_NOTIFICATION": "Une mise à jour de MagicMirror² est disponible", + "UPDATE_NOTIFICATION_MODULE": "Une mise à jour est disponible pour le module {MODULE_NAME}.", + "UPDATE_INFO_SINGLE": "L'installation actuelle est {COMMIT_COUNT} commit en retard sur la branche {BRANCH_NAME}.", + "UPDATE_INFO_MULTIPLE": "L'installation actuelle est {COMMIT_COUNT} commits en retard sur la branche {BRANCH_NAME}.", + "UPDATE_NOTIFICATION_DONE": "Mise à jour effectuée pour le module {MODULE_NAME}", + "UPDATE_NOTIFICATION_ERROR": "Erreur lors de la mise à jour du module {MODULE_NAME}", + "UPDATE_NOTIFICATION_NEED-RESTART": "Le redémarrage de MagicMirror est nécessaire." +} diff --git a/translations/fy.json b/translations/fy.json new file mode 100644 index 0000000..01d0133 --- /dev/null +++ b/translations/fy.json @@ -0,0 +1,53 @@ +{ + "LOADING": "Bezich mei laden …", + + "YESTERDAY": "Juster", + "TODAY": "Hjoed", + "TOMORROW": "Moarn", + "DAYAFTERTOMORROW": "Oaremoarn", + "RUNNING": "Einigest oer", + "EMPTY": "Gjin plande ôfspraken.", + "WEEK": "Wike {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", + + "FEELS": "Voelt as {DEGREE}", + "PRECIP_POP": "Kans op rein", + "PRECIP_AMOUNT": "Hoeveelheid rein", + + "MODULE_CONFIG_CHANGED": "De konfiguraasje fan it {MODULE_NAME} module is feroare.\nSjoch de dokumintaasje foar mear ynformaasje.", + "MODULE_CONFIG_ERROR": "Fout yn it {MODULE_NAME} module. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "De URL is net jildich.", + "MODULE_ERROR_NO_CONNECTION": "Gjin ynternetferbining.", + "MODULE_ERROR_UNAUTHORIZED": "Autorisearje mislearre.", + "MODULE_ERROR_RATE_LIMITED": "Te folle fersiken. Letter opnij besykje.", + "MODULE_ERROR_SERVER_ERROR": "Tsjinnerfout. Letter opnij besykje.", + "MODULE_ERROR_CLIENT_ERROR": "Fersyk mislearre.", + "MODULE_ERROR_UNSPECIFIED": "Sjoch de logs foar mear ynformaasje.", + + "NEWSFEED_NO_ITEMS": "Op it stuit gjin nijsberjochten.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artikel kin hjir net werjûn wurde.", + + "UPDATE_NOTIFICATION": "Der is in update beskikber foar MagicMirror².", + "UPDATE_NOTIFICATION_MODULE": "Der is in update beskikber foar it {MODULE_NAME} module.", + "UPDATE_INFO_SINGLE": "Dizze ynstallaasje is {BRANCH_NAME} commit efter op {COMMIT_COUNT} commits.", + "UPDATE_INFO_MULTIPLE": "Dizze ynstallaasje is {BRANCH_NAME} commits achter op {COMMIT_COUNT} commits.", + "UPDATE_NOTIFICATION_DONE": "It {MODULE_NAME} module is bywurke.", + "UPDATE_NOTIFICATION_ERROR": "Fout by it bywurkje fan it {MODULE_NAME} module.", + "UPDATE_NOTIFICATION_NEED-RESTART": "It is nedich om MagicMirror te herstarten." +} diff --git a/translations/gl.json b/translations/gl.json new file mode 100644 index 0000000..217029b --- /dev/null +++ b/translations/gl.json @@ -0,0 +1,54 @@ +{ + "LOADING": "Cargando …", + + "YESTERDAY": "Onte", + "TODAY": "Hoxe", + "TOMORROW": "Mañá", + "DAYAFTERTOMORROW": "Pasado mañá", + "RUNNING": "Remata en", + "EMPTY": "Non hai próximos eventos.", + + "WEEK": "Semana {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", + + "FEELS": "Semella como {DEGREE}", + "PRECIP_POP": "Precipitacións", + "PRECIP_AMOUNT": "Cantidade de precipitacións", + + "MODULE_CONFIG_CHANGED": "Cambiaron as opcións de configuración para o módulo {MODULE_NAME}.\nPor favor, verifique a documentación.", + "MODULE_CONFIG_ERROR": "Hai un erro no módulo {MODULE_NAME}. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "URL mal formado.", + "MODULE_ERROR_NO_CONNECTION": "Non hai conexión a Internet.", + "MODULE_ERROR_UNAUTHORIZED": "A autorización fallou.", + "MODULE_ERROR_RATE_LIMITED": "Demasiadas solicitudes. Reintentando máis tarde.", + "MODULE_ERROR_SERVER_ERROR": "Erro do servidor. Reintentando máis tarde.", + "MODULE_ERROR_CLIENT_ERROR": "A solicitude fallou.", + "MODULE_ERROR_UNSPECIFIED": "Verifique os rexistros para obter máis información.", + + "NEWSFEED_NO_ITEMS": "Non hai novas no momento.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "O artigo non se pode mostrar aquí.", + + "UPDATE_NOTIFICATION": "Actualización dispoñible para MagicMirror².", + "UPDATE_NOTIFICATION_MODULE": "Actualización dispoñible para o módulo {MODULE_NAME}.", + "UPDATE_INFO_SINGLE": "A instalación actual está {COMMIT_COUNT} commits detrás da rama {BRANCH_NAME}.", + "UPDATE_INFO_MULTIPLE": "A instalación actual está {COMMIT_COUNT} commits detrás da rama {BRANCH_NAME}.", + "UPDATE_NOTIFICATION_DONE": "Actualización feita para o módulo {MODULE_NAME}.", + "UPDATE_NOTIFICATION_ERROR": "Erro na actualización do módulo {MODULE_NAME}.", + "UPDATE_NOTIFICATION_NEED-RESTART": "É necesario reiniciar MagicMirror." +} diff --git a/translations/gu.json b/translations/gu.json new file mode 100644 index 0000000..bbd5fb5 --- /dev/null +++ b/translations/gu.json @@ -0,0 +1,54 @@ +{ + "LOADING": "લોડ થઈ રહ્યું છે …", + + "DAYBEFOREYESTERDAY": "પરમ ગઇકાલે", + "YESTERDAY": "ગઇકાલે", + "TODAY": "આજે", + "TOMORROW": "આવતી કાલે", + "DAYAFTERTOMORROW": "પરમ દિવસે", + "RUNNING": "માં સમાપ્ત થાય છે", + "EMPTY": "કોઈ આગામી કાર્યક્રમ નથી.", + "WEEK": "સપ્તાહ {weekNumber}", + + "N": "ઉ", + "NNE": "ઉઉપુ", + "NE": "ઉપુ", + "ENE": "પુઉપુ", + "E": "પુ", + "ESE": "પુદપુ", + "SE": "દપુ", + "SSE": "દદપુ", + "S": "દ", + "SSW": "દદપ", + "SW": "દપ", + "WSW": "પદપ", + "W": "પ", + "WNW": "પઉપ", + "NW": "ઉપ", + "NNW": "ઉઉપ", + + "FEELS": "{DEGREE} જેવું લાગશે", + "PRECIP_POP": "વર્ષા સંભાવના", + "PRECIP_AMOUNT": "વર્ષા માત્રા", + + "MODULE_CONFIG_CHANGED": "{MODULE_NAME} મોડ્યુલ માટે ગોઠવણી વિકલ્પો બદલાયા છે. \nકૃપા કરીને દસ્તાવેજોને તપાસો.", + "MODULE_CONFIG_ERROR": "{MODULE_NAME} મોડ્યુલમાં ભૂલ છે. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "ખોટી URL.", + "MODULE_ERROR_NO_CONNECTION": "ઇન્ટરનેટ કનેક્શન નથી.", + "MODULE_ERROR_UNAUTHORIZED": "અધિકૃત કરવું નિષ્ફળ.", + "MODULE_ERROR_RATE_LIMITED": "ઘણી બધી વિનંતીઓ. પછીથી પુનઃપ્રયાસ.", + "MODULE_ERROR_SERVER_ERROR": "સર્વર ભૂલ. પછીથી પુનઃપ્રયાસ.", + "MODULE_ERROR_CLIENT_ERROR": "વિનંતી નિષ્ફળ.", + "MODULE_ERROR_UNSPECIFIED": "વધુ વિગતો માટે લોગ તપાસો.", + + "NEWSFEED_NO_ITEMS": "હાલમાં કોઈ સમાચાર નથી.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "લેખ અહીં પ્રદર્શિત કરી શકાતો નથી.", + + "UPDATE_NOTIFICATION": "MagicMirror² અપડેટ ઉપલબ્ધ છે.", + "UPDATE_NOTIFICATION_MODULE": "{MODULE_NAME} મોડ્યુલ માટે અપડેટ ઉપલબ્ધ છે.", + "UPDATE_INFO_SINGLE": "વર્તમાન ઇન્સ્ટોલેશન એ {BRANCH_NAME} શાખા ની {COMMIT_COUNT} કમીટ પાછળ છે. ", + "UPDATE_INFO_MULTIPLE": "વર્તમાન ઇન્સ્ટોલેશન એ {BRANCH_NAME} શાખા ની {COMMIT_COUNT} કમીટ પાછળ છે. ", + "UPDATE_NOTIFICATION_DONE": "{MODULE_NAME} મોડ્યુલ માટે અપડેટ પૂર્ણ થયું.", + "UPDATE_NOTIFICATION_ERROR": "{MODULE_NAME} મોડ્યુલ માટે અપડેટમાં ભૂલ આવી.", + "UPDATE_NOTIFICATION_NEED-RESTART": "MagicMirror ને ફરી શરૂ કરવાની જરૂર છે." +} diff --git a/translations/he.json b/translations/he.json new file mode 100644 index 0000000..45c440e --- /dev/null +++ b/translations/he.json @@ -0,0 +1,54 @@ +{ + "LOADING": "טוען...", + + "DAYBEFOREYESTERDAY": "שלשום", + "YESTERDAY": "אתמול", + "TODAY": "היום", + "TOMORROW": "מחר", + "DAYAFTERTOMORROW": "בעוד יומיים", + "RUNNING": "מסתיים ב", + "EMPTY": "אין ארועים", + "WEEK": "{weekNumber} שבוע", + + "N": "צ", + "NNE": "צ-צ-מז", + "NE": "צ-מז", + "ENE": "מז-צ-מז", + "E": "מז", + "ESE": "מז-ד-מז", + "SE": "ד-מז", + "SSE": "ד-ד-מז", + "S": "ד", + "SSW": "ד-ד-מע", + "SW": "ד-מע", + "WSW": "מע-ד-מע", + "W": "מע", + "WNW": "מע-ז-מע", + "NW": "ז-מע", + "NNW": "צ-צ-מע", + + "FEELS": "מרגיש כמו {DEGREE}", + "PRECIP_POP": "משקעים", + "PRECIP_AMOUNT": "כמות משקעים", + + "MODULE_CONFIG_CHANGED": "אפשרויות התצורה עבור מודול {MODULE_NAME} השתנו.\nאנא בדוק את התיעוד.", + "MODULE_CONFIG_ERROR": "שגיאה במודול {MODULE_NAME}. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "כתובת אתר לא תקינה.", + "MODULE_ERROR_NO_CONNECTION": "אין חיבור לאינטרנט.", + "MODULE_ERROR_UNAUTHORIZED": "הזדהות נכשלה.", + "MODULE_ERROR_RATE_LIMITED": "יותר מדי בקשות. מנסה שוב מאוחר יותר.", + "MODULE_ERROR_SERVER_ERROR": "שגיאת שרת. מנסה שוב מאוחר יותר.", + "MODULE_ERROR_CLIENT_ERROR": "הבקשה נכשלה.", + "MODULE_ERROR_UNSPECIFIED": "בדוק את היומנים לפרטים נוספים.", + + "NEWSFEED_NO_ITEMS": "אין חדשות כרגע.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "לא ניתן להציג את המאמר כאן.", + + "UPDATE_NOTIFICATION": "עדכון זמין ל-MagicMirror²", + "UPDATE_NOTIFICATION_MODULE": "עדכון זמין ב-{MODULE_NAME} מודול", + "UPDATE_INFO_SINGLE": "ההתקנה הנוכחית נמצאת מאחור הענף {BRANCH_NAME} ב-{COMMIT_COUNT} מופע", + "UPDATE_INFO_MULTIPLE": "ההתקנה הנוכחית נמצאת מאחור הענף {BRANCH_NAME} ב-{COMMIT_COUNT} מופעים", + "UPDATE_NOTIFICATION_DONE": "העדכון הסתיים עבור מודול {MODULE_NAME}", + "UPDATE_NOTIFICATION_ERROR": "שגיאת עדכון עבור מודול {MODULE_NAME}", + "UPDATE_NOTIFICATION_NEED-RESTART": "יש צורך לאתחל את ה-MagicMirror" +} diff --git a/translations/hi.json b/translations/hi.json new file mode 100644 index 0000000..a488e39 --- /dev/null +++ b/translations/hi.json @@ -0,0 +1,54 @@ +{ + "LOADING": "लोड हो रहा है …", + + "DAYBEFOREYESTERDAY": "परसों", + "YESTERDAY": "कल", + "TODAY": "आज", + "TOMORROW": "आने वाला कल", + "DAYAFTERTOMORROW": "2 दिनों में", + "RUNNING": "में समाप्त", + "EMPTY": "कोई आगामी कार्यक्रम नहीं।", + "WEEK": "सप्ताह {weekNumber}", + + "N": "उ", + "NNE": "उउपु", + "NE": "उपु", + "ENE": "पुउपु", + "E": "पु", + "ESE": "पुदपु", + "SE": "दपु", + "SSE": "ददपु", + "S": "द", + "SSW": "ददप", + "SW": "दप", + "WSW": "पदप", + "W": "प", + "WNW": "पउप", + "NW": "उप", + "NNW": "उउप", + + "FEELS": "{DEGREE} की तरह लगना", + "PRECIP_POP": "वृष्टि की संभावना", + "PRECIP_AMOUNT": "वृष्टि मात्रा", + + "MODULE_CONFIG_CHANGED": "{MODULE_NAME} मॉड्यूल के लिए कॉन्फ़िगरेशन विकल्प बदल गए हैं। n कृपया दस्तावेज़ देखें।", + "MODULE_CONFIG_ERROR": "{MODULE_NAME} मॉड्यूल में त्रुटि। {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "गलत URL।", + "MODULE_ERROR_NO_CONNECTION": "कोई इंटरनेट कनेक्शन नहीं।", + "MODULE_ERROR_UNAUTHORIZED": "प्राधिकरण विफल।", + "MODULE_ERROR_RATE_LIMITED": "बहुत सारे अनुरोध। बाद में पुनः प्रयास करना।", + "MODULE_ERROR_SERVER_ERROR": "सर्वर त्रुटि। बाद में पुनः प्रयास करना।", + "MODULE_ERROR_CLIENT_ERROR": "अनुरोध विफल रहा।", + "MODULE_ERROR_UNSPECIFIED": "अधिक जानकारी के लिए लॉग जांचें।", + + "NEWSFEED_NO_ITEMS": "इस समय कोई समाचार नहीं।", + "NEWSFEED_ARTICLE_UNAVAILABLE": "लेख यहाँ प्रदर्शित नहीं किया जा सकता।", + + "UPDATE_NOTIFICATION": "MagicMirror² अपडेट उपलब्ध।", + "UPDATE_NOTIFICATION_MODULE": "{MODULE_NAME} मॉड्यूल के लिए उपलब्ध अद्यतन।", + "UPDATE_INFO_SINGLE": "वर्तमान स्थापना {COMMIT_COUNT} {BRANCH_NAME} शाखा के पीछे है।", + "UPDATE_INFO_MULTIPLE": "वर्तमान स्थापना {COMMIT_COUNT} पीछे {BRANCH_NAME} शाखा पर है।", + "UPDATE_NOTIFICATION_DONE": "{MODULE_NAME} मॉड्यूल के लिए अद्यतन पूरा।", + "UPDATE_NOTIFICATION_ERROR": "{MODULE_NAME} मॉड्यूल के लिए अद्यतन त्रुटि।", + "UPDATE_NOTIFICATION_NEED-RESTART": "MagicMirror को पुनः आरंभ करने की आवश्यकता है।" +} diff --git a/translations/hr.json b/translations/hr.json new file mode 100644 index 0000000..93d59c9 --- /dev/null +++ b/translations/hr.json @@ -0,0 +1,54 @@ +{ + "LOADING": "Učitavanje …", + + "DAYBEFOREYESTERDAY": "Prekjučer", + "YESTERDAY": "Jučer", + "TODAY": "Danas", + "TOMORROW": "Sutra", + "DAYAFTERTOMORROW": "Prekosutra", + "RUNNING": "Završava za", + "EMPTY": "Nema nadolazećih događaja.", + "WEEK": "Tjedan {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", + + "FEELS": "Osjećaj {DEGREE}", + "PRECIP_POP": "Vjerojatnost padalina", + "PRECIP_AMOUNT": "Količina padalina", + + "MODULE_CONFIG_CHANGED": "Opcije konfiguracije za modul {MODULE_NAME} su promijenjene.\nMolimo provjerite dokumentaciju.", + "MODULE_CONFIG_ERROR": "Greška u modulu {MODULE_NAME}. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "Neispravan URL.", + "MODULE_ERROR_NO_CONNECTION": "Nema internetske veze.", + "MODULE_ERROR_UNAUTHORIZED": "Autorizacija nije uspjela.", + "MODULE_ERROR_RATE_LIMITED": "Previše zahtjeva. Ponovni pokušaj kasnije.", + "MODULE_ERROR_SERVER_ERROR": "Greška poslužitelja. Ponovni pokušaj kasnije.", + "MODULE_ERROR_CLIENT_ERROR": "Zahtjev nije uspio.", + "MODULE_ERROR_UNSPECIFIED": "Provjerite dnevnike za više informacija.", + + "NEWSFEED_NO_ITEMS": "Trenutno nema vijesti.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Članak se ne može prikazati ovdje.", + + "UPDATE_NOTIFICATION": "Dostupna je aktualizacija MagicMirror².", + "UPDATE_NOTIFICATION_MODULE": "Dostupna je aktualizacija modula {MODULE_NAME}.", + "UPDATE_INFO_SINGLE": "Instalirana verzija {COMMIT_COUNT} commit kasni za branch-om {BRANCH_NAME}.", + "UPDATE_INFO_MULTIPLE": "Instalirana verzija {COMMIT_COUNT} commit-ova kasni za branch-om {BRANCH_NAME}.", + "UPDATE_NOTIFICATION_DONE": "Ažuriranje je završeno za modul {MODULE_NAME}.", + "UPDATE_NOTIFICATION_ERROR": "Greška pri ažuriranju modula {MODULE_NAME}.", + "UPDATE_NOTIFICATION_NEED-RESTART": "Potrebno je ponovno pokretanje MagicMirror-a." +} diff --git a/translations/hu.json b/translations/hu.json new file mode 100644 index 0000000..e8b6b12 --- /dev/null +++ b/translations/hu.json @@ -0,0 +1,53 @@ +{ + "LOADING": "Betöltés …", + + "YESTERDAY": "Tegnap", + "TODAY": "Ma", + "TOMORROW": "Holnap", + "DAYAFTERTOMORROW": "Holnapután", + "RUNNING": "Vége lesz", + "EMPTY": "Nincs közelgő esemény.", + "WEEK": "{weekNumber}. hét", + + "N": "É", + "NNE": "ÉÉK", + "NE": "ÉK", + "ENE": "KÉK", + "E": "K", + "ESE": "KDK", + "SE": "DK", + "SSE": "DDK", + "S": "D", + "SSW": "DDNy", + "SW": "DNy", + "WSW": "NyDNy", + "W": "Ny", + "WNW": "NyÉNy", + "NW": "ÉNy", + "NNW": "ÉÉNy", + + "FEELS": "Érzet {DEGREE}", + "PRECIP_POP": "Csapadék valószínűség", + "PRECIP_AMOUNT": "Csapadék mennyisége", + + "MODULE_CONFIG_CHANGED": "A(z) {MODULE_NAME} modul konfigurációs beállításai megváltoztak.\nKérjük, ellenőrizze a dokumentációt.", + "MODULE_CONFIG_ERROR": "Hiba a(z) {MODULE_NAME} modulban. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "Hibás URL.", + "MODULE_ERROR_NO_CONNECTION": "Nincs internetkapcsolat.", + "MODULE_ERROR_UNAUTHORIZED": "Azonosítás sikertelen.", + "MODULE_ERROR_RATE_LIMITED": "Túl sok kérés. Újrapróbálkozás később.", + "MODULE_ERROR_SERVER_ERROR": "Szerverhiba. Újrapróbálkozás később.", + "MODULE_ERROR_CLIENT_ERROR": "A kérés meghiúsult.", + "MODULE_ERROR_UNSPECIFIED": "Ellenőrizze a naplókat további részletekért.", + + "NEWSFEED_NO_ITEMS": "Jelenleg nincsenek hírek.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "A cikk itt nem jeleníthető meg.", + + "UPDATE_NOTIFICATION": "MagicMirror²-hoz frissítés érhető el.", + "UPDATE_NOTIFICATION_MODULE": "A {MODULE_NAME} modulhoz frissítés érhető el.", + "UPDATE_INFO_SINGLE": "A jelenlegi telepítés óta {COMMIT_COUNT} új commit jelent meg a {BRANCH_NAME} ágon.", + "UPDATE_INFO_MULTIPLE": "A jelenlegi telepítés óta {COMMIT_COUNT} új commit jelent meg a {BRANCH_NAME} ágon.", + "UPDATE_NOTIFICATION_DONE": "A frissítés befejeződött a {MODULE_NAME} modulhoz.", + "UPDATE_NOTIFICATION_ERROR": "Hiba történt a frissítés során a {MODULE_NAME} modulhoz.", + "UPDATE_NOTIFICATION_NEED-RESTART": "A MagicMirror újraindítása szükséges." +} diff --git a/translations/id.json b/translations/id.json new file mode 100644 index 0000000..738bd94 --- /dev/null +++ b/translations/id.json @@ -0,0 +1,53 @@ +{ + "LOADING": "Memuat …", + + "YESTERDAY": "Kemarin", + "TODAY": "Hari ini", + "TOMORROW": "Besok", + "DAYAFTERTOMORROW": "Lusa", + "RUNNING": "Berakhir dalam", + "EMPTY": "Tidak ada agenda", + "WEEK": "Pekan", + + "N": "U", + "NNE": "UTL", + "NE": "TL", + "ENE": "TTL", + "E": "T", + "ESE": "TMg", + "SE": "TG", + "SSE": "SMg", + "S": "S", + "SSW": "SBD", + "SW": "BD", + "WSW": "BBD", + "W": "B", + "WNW": "BBL", + "NW": "BL", + "NNW": "UBL", + + "FEELS": "Terasa {DEGREE}", + "PRECIP_POP": "Kemungkinan curah hujan", + "PRECIP_AMOUNT": "Jumlah curah hujan", + + "MODULE_CONFIG_CHANGED": "Opsi konfigurasi modul {MODULE_NAME} telah diubah.\nSilakan periksa dokumentasi.", + "MODULE_CONFIG_ERROR": "Terjadi kesalahan pada modul {MODULE_NAME}. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "URL tidak valid.", + "MODULE_ERROR_NO_CONNECTION": "Tidak ada koneksi internet.", + "MODULE_ERROR_UNAUTHORIZED": "Gagal otentikasi.", + "MODULE_ERROR_RATE_LIMITED": "Terlalu banyak permintaan. Mencoba lagi nanti.", + "MODULE_ERROR_SERVER_ERROR": "Kesalahan server. Mencoba lagi nanti.", + "MODULE_ERROR_CLIENT_ERROR": "Permintaan gagal.", + "MODULE_ERROR_UNSPECIFIED": "Silakan periksa log untuk informasi lebih lanjut.", + + "NEWSFEED_NO_ITEMS": "Saat ini tidak ada berita.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artikel tidak dapat ditampilkan di sini.", + + "UPDATE_NOTIFICATION": "Memperbarui MagicMirror² tersedia.", + "UPDATE_NOTIFICATION_MODULE": "Memperbarui tersedia untuk modul {MODULE_NAME}.", + "UPDATE_INFO_SINGLE": "Instalasi saat ini tertinggal {COMMIT_COUNT} commit pada cabang {BRANCH_NAME}.", + "UPDATE_INFO_MULTIPLE": "Instalasi saat ini tertinggal {COMMIT_COUNT} commits pada cabang {BRANCH_NAME}.", + "UPDATE_NOTIFICATION_DONE": "Pembaruan modul {MODULE_NAME} selesai.", + "UPDATE_NOTIFICATION_ERROR": "Terjadi kesalahan saat memperbarui modul {MODULE_NAME}.", + "UPDATE_NOTIFICATION_NEED-RESTART": "Diperlukan restart MagicMirror." +} diff --git a/translations/is.json b/translations/is.json new file mode 100644 index 0000000..e21d6ef --- /dev/null +++ b/translations/is.json @@ -0,0 +1,54 @@ +{ + "LOADING": "Hleð upp …", + + "DAYBEFOREYESTERDAY": "Í fyrradag", + "YESTERDAY": "Í gær", + "TODAY": "Í dag", + "TOMORROW": "Á morgun", + "DAYAFTERTOMORROW": "Ekki á morgun, heldur hinn", + "RUNNING": "Endar eftir", + "EMPTY": "Ekkert framundan.", + "WEEK": "Vika {weekNumber}", + + "N": "N", + "NNE": "NNA", + "NE": "NA", + "ENE": "ANA", + "E": "A", + "ESE": "ASA", + "SE": "SA", + "SSE": "SSA", + "S": "S", + "SSW": "SSV", + "SW": "SV", + "WSW": "VSV", + "W": "V", + "WNW": "VNV", + "NW": "NV", + "NNW": "NNV", + + "FEELS": "Kælist á {DEGREE}", + "PRECIP_POP": "Úrkoma", + "PRECIP_AMOUNT": "Magn úrkomu", + + "MODULE_CONFIG_CHANGED": "Stillingar fyrir {MODULE_NAME} module hafa breyst.\nVinsamlegast skoðaðu skjöl.", + "MODULE_CONFIG_ERROR": "Villa í {MODULE_NAME} module. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "Villa í slóð.", + "MODULE_ERROR_NO_CONNECTION": "Engin nettenging.", + "MODULE_ERROR_UNAUTHORIZED": "Auðkenning mistókst.", + "MODULE_ERROR_RATE_LIMITED": "Of margar beiðnir. Reyni aftur síðar.", + "MODULE_ERROR_SERVER_ERROR": "Villa í þjóni. Reyni aftur síðar.", + "MODULE_ERROR_CLIENT_ERROR": "Beiðni mistókst.", + "MODULE_ERROR_UNSPECIFIED": "Vinsamlegast athugaðu skráningu fyrir frekari upplýsingar.", + + "NEWSFEED_NO_ITEMS": "Engar fréttir í boði núna.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Grein er ekki hægt að sýna hér.", + + "UPDATE_NOTIFICATION": "MagicMirror² uppfærsla í boði.", + "UPDATE_NOTIFICATION_MODULE": "Uppfærsla í boði fyrir {MODULE_NAME} module.", + "UPDATE_INFO_SINGLE": "Núverandi kerfi er {COMMIT_COUNT} commit á eftir {BRANCH_NAME} branchinu.", + "UPDATE_INFO_MULTIPLE": "Núverandi kerfi er {COMMIT_COUNT} commits á eftir {BRANCH_NAME} branchinu.", + "UPDATE_NOTIFICATION_DONE": "Uppfærsla lokið fyrir {MODULE_NAME} module", + "UPDATE_NOTIFICATION_ERROR": "Villa við uppfærslu fyrir {MODULE_NAME} module", + "UPDATE_NOTIFICATION_NEED-RESTART": "Endurræsa MagicMirror er nauðsynlegt." +} diff --git a/translations/it.json b/translations/it.json new file mode 100644 index 0000000..7c4591e --- /dev/null +++ b/translations/it.json @@ -0,0 +1,53 @@ +{ + "LOADING": "Caricamento in corso …", + + "YESTERDAY": "Ieri", + "TODAY": "Oggi", + "TOMORROW": "Domani", + "DAYAFTERTOMORROW": "Dopodomani", + "RUNNING": "Termina entro", + "EMPTY": "Nessun evento imminente.", + "WEEK": "Settimana {weekNumber}", + + "N": "N", + "NNE": "NNE", + "NE": "NE", + "ENE": "ENE", + "E": "E", + "ESE": "ESE", + "SE": "SE", + "SSE": "SSE", + "S": "S", + "SSW": "SSO", + "SW": "SO", + "WSW": "OSO", + "W": "O", + "WNW": "ONO", + "NW": "NO", + "NNW": "NNO", + + "FEELS": "Percepiti {DEGREE}", + "PRECIP_POP": "Probabilità di precipitazioni", + "PRECIP_AMOUNT": "Quantità di precipitazioni", + + "MODULE_CONFIG_CHANGED": "Le opzioni di configurazione del modulo {MODULE_NAME} sono state modificate.\nSi prega di consultare la documentazione.", + "MODULE_CONFIG_ERROR": "Errore nel modulo {MODULE_NAME}. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "URL non valido.", + "MODULE_ERROR_NO_CONNECTION": "Nessuna connessione a Internet.", + "MODULE_ERROR_UNAUTHORIZED": "Autenticazione non riuscita.", + "MODULE_ERROR_RATE_LIMITED": "Troppe richieste. Riprovo più tardi.", + "MODULE_ERROR_SERVER_ERROR": "Errore del server. Riprovo più tardi.", + "MODULE_ERROR_CLIENT_ERROR": "Richiesta fallita.", + "MODULE_ERROR_UNSPECIFIED": "Si prega di controllare i log per ulteriori dettagli.", + + "NEWSFEED_NO_ITEMS": "Al momento non ci sono notizie.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "L'articolo non può essere visualizzato qui.", + + "UPDATE_NOTIFICATION": "E' disponibile un aggiornamento di MagicMirror².", + "UPDATE_NOTIFICATION_MODULE": "E' disponibile un aggiornamento del modulo {MODULE_NAME}.", + "UPDATE_INFO_SINGLE": "L'installazione è {COMMIT_COUNT} commit indietro rispetto all'attuale branch {BRANCH_NAME}.", + "UPDATE_INFO_MULTIPLE": "L'installazione è {COMMIT_COUNT} commits indietro rispetto all'attuale branch {BRANCH_NAME}.", + "UPDATE_NOTIFICATION_DONE": "L'aggiornamento del modulo {MODULE_NAME} è stato completato.", + "UPDATE_NOTIFICATION_ERROR": "Errore durante l'aggiornamento del modulo {MODULE_NAME}.", + "UPDATE_NOTIFICATION_NEED-RESTART": "E' necessario riavviare MagicMirror." +} diff --git a/translations/ja.json b/translations/ja.json new file mode 100644 index 0000000..22052f0 --- /dev/null +++ b/translations/ja.json @@ -0,0 +1,54 @@ +{ + "LOADING": "ローディング …", + + "DAYBEFOREYESTERDAY": "おととい", + "YESTERDAY": "昨日", + "TODAY": "今日", + "TOMORROW": "明日", + "DAYAFTERTOMORROW": "明後日", + "RUNNING": "で終わります", + "EMPTY": "直近のイベントはありません", + "WEEK": "第 {weekNumber} 週", + + "N": "北", + "NNE": "北北東", + "NE": "北東", + "ENE": "東北東", + "E": "東", + "ESE": "東南東", + "SE": "南東", + "SSE": "南南東", + "S": "南", + "SSW": "南南西", + "SW": "南西", + "WSW": "西南西", + "W": "西", + "WNW": "西北西", + "NW": "北西", + "NNW": "北北西", + + "FEELS": "体感温度 {DEGREE}", + "PRECIP_POP": "降水確率", + "PRECIP_AMOUNT": "降水量", + + "MODULE_CONFIG_CHANGED": "モジュール {MODULE_NAME} の設定オプションが変更されました。\nドキュメントを確認してください。", + "MODULE_CONFIG_ERROR": "モジュール {MODULE_NAME} でエラーが発生しました。{ERROR}", + "MODULE_ERROR_MALFORMED_URL": "不正なURLです。", + "MODULE_ERROR_NO_CONNECTION": "インターネット接続がありません。", + "MODULE_ERROR_UNAUTHORIZED": "認証に失敗しました。", + "MODULE_ERROR_RATE_LIMITED": "リクエストが多すぎます。後で再試行します。", + "MODULE_ERROR_SERVER_ERROR": "サーバーエラー。後で再試行します。", + "MODULE_ERROR_CLIENT_ERROR": "リクエストが失敗しました。", + "MODULE_ERROR_UNSPECIFIED": "詳細はログを確認してください。", + + "NEWSFEED_NO_ITEMS": "現在ニュースはありません。", + "NEWSFEED_ARTICLE_UNAVAILABLE": "この記事はここでは表示できません。", + + "UPDATE_NOTIFICATION": "MagicMirror² のアップデートが利用可能です。", + "UPDATE_NOTIFICATION_MODULE": "モジュール {MODULE_NAME} のアップデートが利用可能です。", + "UPDATE_INFO_SINGLE": "現在のインストールは {BRANCH_NAME} ブランチから {COMMIT_COUNT} コミット遅れています。", + "UPDATE_INFO_MULTIPLE": "現在のインストールは {BRANCH_NAME} ブランチから {COMMIT_COUNT} コミット遅れています。", + "UPDATE_NOTIFICATION_DONE": "モジュール {MODULE_NAME} のアップデートが完了しました。", + "UPDATE_NOTIFICATION_ERROR": "モジュール {MODULE_NAME} のアップデート中にエラーが発生しました。", + "UPDATE_NOTIFICATION_NEED-RESTART": "MagicMirror の再起動が必要です。" +} diff --git a/translations/ko.json b/translations/ko.json new file mode 100644 index 0000000..9ee81b2 --- /dev/null +++ b/translations/ko.json @@ -0,0 +1,54 @@ +{ + "LOADING": "로드 중 …", + + "DAYBEFOREYESTERDAY": "그저께", + "YESTERDAY": "어제", + "TODAY": "오늘", + "TOMORROW": "내일", + "DAYAFTERTOMORROW": "모레", + "RUNNING": "종료 일", + "EMPTY": "예정된 이벤트가 없습니다.", + "WEEK": "{weekNumber}주차", + + "N": "북풍", + "NNE": "북북동풍", + "NE": "북동풍", + "ENE": "동북동풍", + "E": "동풍", + "ESE": "동남동풍", + "SE": "남동풍", + "SSE": "남남동풍", + "S": "남풍", + "SSW": "남남서풍", + "SW": "남서풍", + "WSW": "서남서풍", + "W": "서풍", + "WNW": "서북서풍", + "NW": "북서풍", + "NNW": "북북서풍", + + "FEELS": "체감온도 {DEGREE}", + "PRECIP_POP": "강수 확률", + "PRECIP_AMOUNT": "강수량", + + "MODULE_CONFIG_CHANGED": "모듈 {MODULE_NAME}의 설정값이 바뀌었습니다.\n매뉴얼을 참고하세요.", + "MODULE_CONFIG_ERROR": "에러 : {MODULE_NAME} - {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "잘못된 URL 형식입니다.", + "MODULE_ERROR_NO_CONNECTION": "인터넷이 연결되지 않았습니다.", + "MODULE_ERROR_UNAUTHORIZED": "인증이 실패했습니다.", + "MODULE_ERROR_RATE_LIMITED": "요청이 너무 많습니다. 나중에 다시 시도합니다.", + "MODULE_ERROR_SERVER_ERROR": "서버 오류. 나중에 다시 시도합니다.", + "MODULE_ERROR_CLIENT_ERROR": "요청 실패.", + "MODULE_ERROR_UNSPECIFIED": "상세 내용은 로그를 확인하세요.", + + "NEWSFEED_NO_ITEMS": "현재 뉴스가 없습니다.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "이 기사는 여기에 표시할 수 없습니다.", + + "UPDATE_NOTIFICATION": "새로운 MagicMirror² 업데이트가 있습니다.", + "UPDATE_NOTIFICATION_MODULE": "{MODULE_NAME} 모듈에서 사용 가능한 업데이트 입니다.", + "UPDATE_INFO_SINGLE": "설치할 {COMMIT_COUNT} commit 는 {BRANCH_NAME} 분기에 해당됩니다.", + "UPDATE_INFO_MULTIPLE": "설치할 {COMMIT_COUNT} commits 는 {BRANCH_NAME} 분기에 해당됩니다.", + "UPDATE_NOTIFICATION_DONE": "모듈 {MODULE_NAME}의 업데이트가 완료되었습니다.", + "UPDATE_NOTIFICATION_ERROR": "모듈 {MODULE_NAME}의 업데이트 중 오류가 발생했습니다.", + "UPDATE_NOTIFICATION_NEED-RESTART": "MagicMirror를 재시작해야 합니다." +} diff --git a/translations/lt.json b/translations/lt.json new file mode 100644 index 0000000..cfc5353 --- /dev/null +++ b/translations/lt.json @@ -0,0 +1,54 @@ +{ + "LOADING": "Kraunasi …", + + "DAYBEFOREYESTERDAY": "Užvakar", + "YESTERDAY": "Vakar", + "TODAY": "Šiandien", + "TOMORROW": "Rytoj", + "DAYAFTERTOMORROW": "Už 2 dienų", + "RUNNING": "Pasibaigs už", + "EMPTY": "Nėra artimų įvykių.", + "WEEK": "{weekNumber} savaitė", + + "N": "Š", + "NNE": "ŠŠR", + "NE": "ŠR", + "ENE": "RŠR", + "E": "R", + "ESE": "RPR", + "SE": "PR", + "SSE": "PPR", + "S": "P", + "SSW": "PPV", + "SW": "PV", + "WSW": "VPV", + "W": "V", + "WNW": "VŠV", + "NW": "ŠV", + "NNW": "ŠŠV", + + "FEELS": "Jutiminė temp. {DEGREE}", + "PRECIP_POP": "Krituliai", + "PRECIP_AMOUNT": "Kritulių kiekis", + + "MODULE_CONFIG_CHANGED": "Modulio {MODULE_NAME} konfigūracijos parinktys pasikeitė.\nPrašome patikrinti dokumentaciją.", + "MODULE_CONFIG_ERROR": "Klaida modulyje {MODULE_NAME}. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "Netinkama URL nuoroda.", + "MODULE_ERROR_NO_CONNECTION": "Nėra interneto ryšio.", + "MODULE_ERROR_UNAUTHORIZED": "Autorizacija nepavyko.", + "MODULE_ERROR_RATE_LIMITED": "Per daug užklausų. Bandoma vėl vėliau.", + "MODULE_ERROR_SERVER_ERROR": "Serverio klaida. Bandoma vėl vėliau.", + "MODULE_ERROR_CLIENT_ERROR": "Užklausa nepavyko.", + "MODULE_ERROR_UNSPECIFIED": "Patikrinkite žurnalus, kad gautumėte daugiau informacijos.", + + "NEWSFEED_NO_ITEMS": "Šiuo metu naujienų nėra.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Straipsnis čia negali būti rodomas.", + + "UPDATE_NOTIFICATION": "Galimas MagicMirror² naujinimas.", + "UPDATE_NOTIFICATION_MODULE": "Galimas {MODULE_NAME} naujinimas.", + "UPDATE_INFO_SINGLE": "Šis įdiegimas atsilieka {COMMIT_COUNT} įsipareigojimu {BRANCH_NAME} šakoje.", + "UPDATE_INFO_MULTIPLE": "Šis įdiegimas atsilieka {COMMIT_COUNT} įsipareigojimais {BRANCH_NAME} šakoje.", + "UPDATE_NOTIFICATION_DONE": "Naujinimas {MODULE_NAME} baigtas.", + "UPDATE_NOTIFICATION_ERROR": "Klaida atnaujinant {MODULE_NAME}.", + "UPDATE_NOTIFICATION_NEED-RESTART": "Reikalingas MagicMirror perkrovimas." +} diff --git a/translations/ms-my.json b/translations/ms-my.json new file mode 100644 index 0000000..e93dd8c --- /dev/null +++ b/translations/ms-my.json @@ -0,0 +1,54 @@ +{ + "LOADING": "Tunggu Sebentar …", + + "DAYBEFOREYESTERDAY": "Kelmarin", + "YESTERDAY": "Semalam", + "TODAY": "Hari ini", + "TOMORROW": "Esok", + "DAYAFTERTOMORROW": "Lusa", + "RUNNING": "Berakhir dalam", + "EMPTY": "Tidak ada agenda", + "WEEK": "Minggu ke-{weekNumber}", + + "N": "U", + "NNE": "UUT", + "NE": "TL", + "ENE": "TTL", + "E": "T", + "ESE": "TT", + "SE": "T", + "SSE": "ST", + "S": "S", + "SSW": "SBD", + "SW": "BD", + "WSW": "BBD", + "W": "B", + "WNW": "BBL", + "NW": "BL", + "NNW": "UBL", + + "FEELS": "Rasa seperti {DEGREE}", + "PRECIP_POP": "Kemungkinan hujan", + "PRECIP_AMOUNT": "Jumlah hujan", + + "MODULE_CONFIG_CHANGED": "Pilihan konfigurasi untuk modul {MODULE_NAME} telah berubah.\nSila rujuk dokumentasi.", + "MODULE_CONFIG_ERROR": "Ralat dalam modul {MODULE_NAME}. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "URL tidak sah.", + "MODULE_ERROR_NO_CONNECTION": "Tiada sambungan internet.", + "MODULE_ERROR_UNAUTHORIZED": "Kebenaran gagal.", + "MODULE_ERROR_RATE_LIMITED": "Terlalu banyak permintaan. Cuba lagi nanti.", + "MODULE_ERROR_SERVER_ERROR": "Ralat pelayan. Cuba lagi nanti.", + "MODULE_ERROR_CLIENT_ERROR": "Permintaan gagal.", + "MODULE_ERROR_UNSPECIFIED": "Sila semak log untuk maklumat lanjut.", + + "NEWSFEED_NO_ITEMS": "Tiada berita buat masa ini.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artikel tidak dapat dipaparkan di sini.", + + "UPDATE_NOTIFICATION": "MagicMirror² mempunyai update terkini.", + "UPDATE_NOTIFICATION_MODULE": "Modul {MODULE_NAME} mempunyai update terkini.", + "UPDATE_INFO_SINGLE": "Pemasangan MagicMirror² ini mempunyai {COMMIT_COUNT} commit terkebelakang dari branch {BRANCH_NAME}.", + "UPDATE_INFO_MULTIPLE": "Pemasangan MagicMirror² ini mempunyai {COMMIT_COUNT} commit terkebelakang dari branch {BRANCH_NAME}.", + "UPDATE_NOTIFICATION_DONE": "Update selesai untuk modul {MODULE_NAME}", + "UPDATE_NOTIFICATION_ERROR": "Ralat update untuk modul {MODULE_NAME}", + "UPDATE_NOTIFICATION_NEED-RESTART": "Perlu restart MagicMirror." +} diff --git a/translations/nb.json b/translations/nb.json new file mode 100644 index 0000000..d459ff9 --- /dev/null +++ b/translations/nb.json @@ -0,0 +1,54 @@ +{ + "LOADING": "Laster …", + + "DAYBEFOREYESTERDAY": "I forgårs", + "YESTERDAY": "I går", + "TODAY": "I dag", + "TOMORROW": "I morgen", + "DAYAFTERTOMORROW": "I overmorgen", + "RUNNING": "Slutter om", + "EMPTY": "Ingen kommende arrangementer.", + "WEEK": "Uke {weekNumber}", + + "N": "N", + "NNE": "NNØ", + "NE": "NØ", + "ENE": "ØNØ", + "E": "Ø", + "ESE": "ØSØ", + "SE": "SØ", + "SSE": "SSØ", + "S": "S", + "SSW": "SSV", + "SW": "SV", + "WSW": "VSV", + "W": "V", + "WNW": "VNV", + "NW": "NV", + "NNW": "NNV", + + "FEELS": "Føles som {DEGREE}", + "PRECIP_POP": "Sannsynlighet for nedbør", + "PRECIP_AMOUNT": "Nedbørsmengde", + + "MODULE_CONFIG_CHANGED": "Innstillingene for {MODULE_NAME}-modulen har endret seg.\nVennligst sjekk dokumentasjonen.", + "MODULE_CONFIG_ERROR": "Feil i {MODULE_NAME}-modulen. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "Ugyldig URL.", + "MODULE_ERROR_NO_CONNECTION": "Ingen internettforbindelse.", + "MODULE_ERROR_UNAUTHORIZED": "Autentisering mislyktes.", + "MODULE_ERROR_RATE_LIMITED": "For mange forespørsler. Prøver igjen senere.", + "MODULE_ERROR_SERVER_ERROR": "Serverfeil. Prøver igjen senere.", + "MODULE_ERROR_CLIENT_ERROR": "Forespørselen mislyktes.", + "MODULE_ERROR_UNSPECIFIED": "Vennligst sjekk loggene for mer informasjon.", + + "NEWSFEED_NO_ITEMS": "Ingen nyheter tilgjengelig for øyeblikket.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artikkelen kan ikke vises her.", + + "UPDATE_NOTIFICATION": "MagicMirror²-oppdatering er tilgjengelig.", + "UPDATE_NOTIFICATION_MODULE": "Oppdatering tilgjengelig for modulen {MODULE_NAME}.", + "UPDATE_INFO_SINGLE": "Nåværende installasjon er {COMMIT_COUNT} commit bak {BRANCH_NAME} grenen.", + "UPDATE_INFO_MULTIPLE": "Nåværende installasjon er {COMMIT_COUNT} commits bak {BRANCH_NAME} grenen.", + "UPDATE_NOTIFICATION_DONE": "Oppdateringen av modulen {MODULE_NAME} er fullført.", + "UPDATE_NOTIFICATION_ERROR": "Det oppstod en feil under oppdateringen av modulen {MODULE_NAME}.", + "UPDATE_NOTIFICATION_NEED-RESTART": "Det er nødvendig med en omstart av MagicMirror." +} diff --git a/translations/nl.json b/translations/nl.json new file mode 100644 index 0000000..6102c1d --- /dev/null +++ b/translations/nl.json @@ -0,0 +1,54 @@ +{ + "LOADING": "Bezig met laden …", + + "DAYBEFOREYESTERDAY": "Eergisteren", + "YESTERDAY": "Gisteren", + "TODAY": "Vandaag", + "TOMORROW": "Morgen", + "DAYAFTERTOMORROW": "Overmorgen", + "RUNNING": "Eindigt over", + "EMPTY": "Geen geplande afspraken.", + "WEEK": "Week {weekNumber}", + + "N": "N", + "NNE": "NNO", + "NE": "NO", + "ENE": "ONO", + "E": "O", + "ESE": "OZO", + "SE": "ZO", + "SSE": "ZZO", + "S": "Z", + "SSW": "ZZW", + "SW": "ZW", + "WSW": "WZW", + "W": "W", + "WNW": "WNW", + "NW": "NW", + "NNW": "NNW", + + "FEELS": "Voelt als {DEGREE}", + "PRECIP_POP": "Neerslagkans", + "PRECIP_AMOUNT": "Neerslaghoeveelheid", + + "MODULE_CONFIG_CHANGED": "De configuratie opties voor de module {MODULE_NAME} zijn gewijzigd.\nControleer de documentatie.", + "MODULE_CONFIG_ERROR": "Fout in de {MODULE_NAME} module. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "Ongeldige url.", + "MODULE_ERROR_NO_CONNECTION": "Geen internet verbinding.", + "MODULE_ERROR_UNAUTHORIZED": "Authenticatie mislukt.", + "MODULE_ERROR_RATE_LIMITED": "Te veel verzoeken. Later opnieuw proberen.", + "MODULE_ERROR_SERVER_ERROR": "Serverfout. Later opnieuw proberen.", + "MODULE_ERROR_CLIENT_ERROR": "Verzoek mislukt.", + "MODULE_ERROR_UNSPECIFIED": "Bekijk de logs voor meer informatie.", + + "NEWSFEED_NO_ITEMS": "Geen nieuws op dit moment.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artikel kan hier niet worden weergegeven.", + + "UPDATE_NOTIFICATION": "MagicMirror² update beschikbaar.", + "UPDATE_NOTIFICATION_MODULE": "Update beschikbaar voor {MODULE_NAME} module.", + "UPDATE_INFO_SINGLE": "De huidige installatie loopt {COMMIT_COUNT} commit achter op de {BRANCH_NAME} branch.", + "UPDATE_INFO_MULTIPLE": "De huidige installatie loopt {COMMIT_COUNT} commits achter op de {BRANCH_NAME} branch.", + "UPDATE_NOTIFICATION_DONE": "Update voltooid voor {MODULE_NAME} module.", + "UPDATE_NOTIFICATION_ERROR": "Fout tijdens het updaten van {MODULE_NAME} module.", + "UPDATE_NOTIFICATION_NEED-RESTART": "MagicMirror moet opnieuw worden opgestart." +} diff --git a/translations/nn.json b/translations/nn.json new file mode 100644 index 0000000..8706cc1 --- /dev/null +++ b/translations/nn.json @@ -0,0 +1,54 @@ +{ + "LOADING": "Lastar …", + + "DAYBEFOREYESTERDAY": "I forgårs", + "YESTERDAY": "I går", + "TODAY": "I dag", + "TOMORROW": "I morgon", + "DAYAFTERTOMORROW": "I overmorgon", + "RUNNING": "Sluttar om", + "EMPTY": "Ingen komande hendingar.", + "WEEK": "Veke {weekNumber}", + + "N": "N", + "NNE": "NNA", + "NE": "NA", + "ENE": "ANA", + "E": "A", + "ESE": "ASA", + "SE": "SA", + "SSE": "SSA", + "S": "S", + "SSW": "SSV", + "SW": "SV", + "WSW": "VSV", + "W": "V", + "WNW": "VNV", + "NW": "NV", + "NNW": "NNV", + + "FEELS": "Kjenst som {DEGREE}", + "PRECIP_POP": "Sannsyn for nedbør", + "PRECIP_AMOUNT": "Nedbørsmengde", + + "MODULE_CONFIG_CHANGED": "Innstillingane for {MODULE_NAME} modulen har endra seg.\nVennligst sjekk dokumentasjonen.", + "MODULE_CONFIG_ERROR": "Feil i {MODULE_NAME} modulen. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "Ugyldig URL.", + "MODULE_ERROR_NO_CONNECTION": "Ingen internettforbindelse.", + "MODULE_ERROR_UNAUTHORIZED": "Autentisering mislyktes.", + "MODULE_ERROR_RATE_LIMITED": "For mange førespurnader. Prøvar igjen seinare.", + "MODULE_ERROR_SERVER_ERROR": "Serverfeil. Prøvar igjen seinare.", + "MODULE_ERROR_CLIENT_ERROR": "Førespurnaden feila.", + "MODULE_ERROR_UNSPECIFIED": "Vennligst sjekk loggfilene for meir informasjon.", + + "NEWSFEED_NO_ITEMS": "Ingen nyhende tilgjengeleg no.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artikkelen kan ikkje visast her.", + + "UPDATE_NOTIFICATION": "MagicMirror² oppdatering er tilgjengeleg.", + "UPDATE_NOTIFICATION_MODULE": "Oppdatering tilgjengeleg for modulen {MODULE_NAME}.", + "UPDATE_INFO_SINGLE": "noverande installasjon er {COMMIT_COUNT} commit bak {BRANCH_NAME} greinen.", + "UPDATE_INFO_MULTIPLE": "noverande installasjon er {COMMIT_COUNT} commits bak {BRANCH_NAME} greinen.", + "UPDATE_NOTIFICATION_DONE": "Oppdateringa av modulen {MODULE_NAME} er fullført.", + "UPDATE_NOTIFICATION_ERROR": "Det oppstod ein feil under oppdateringa av modulen {MODULE_NAME}.", + "UPDATE_NOTIFICATION_NEED-RESTART": "Det er nødvendig med ein omstart av MagicMirror." +} diff --git a/translations/pl.json b/translations/pl.json new file mode 100644 index 0000000..a74143b --- /dev/null +++ b/translations/pl.json @@ -0,0 +1,54 @@ +{ + "LOADING": "Ładowanie …", + + "DAYBEFOREYESTERDAY": "Wczoraj", + "YESTERDAY": "Przedwczoraj", + "TODAY": "Dziś", + "TOMORROW": "Jutro", + "DAYAFTERTOMORROW": "Pojutrze", + "RUNNING": "Koniec za", + "EMPTY": "Brak wydarzeń.", + "WEEK": "Tydzień {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", + + "FEELS": "Odczuwalna {DEGREE}", + "PRECIP_POP": "Szansa opadów", + "PRECIP_AMOUNT": "Ilość opadów", + + "MODULE_CONFIG_CHANGED": "Opcje konfiguracji modułu {MODULE_NAME} zostały zmienione.\nProszę sprawdzić dokumentację.", + "MODULE_CONFIG_ERROR": "Błąd w module {MODULE_NAME}. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "Nieprawidłowy adres URL.", + "MODULE_ERROR_NO_CONNECTION": "Brak połączenia z internetem.", + "MODULE_ERROR_UNAUTHORIZED": "Autoryzacja nie powiodła się.", + "MODULE_ERROR_RATE_LIMITED": "Zbyt wiele żądań. Ponowna próba później.", + "MODULE_ERROR_SERVER_ERROR": "Błąd serwera. Ponowna próba później.", + "MODULE_ERROR_CLIENT_ERROR": "Żądanie nie powiodło się.", + "MODULE_ERROR_UNSPECIFIED": "Sprawdź logi, aby uzyskać więcej informacji.", + + "NEWSFEED_NO_ITEMS": "Brak wiadomości w tej chwili.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artykuł nie może być wyświetlony tutaj.", + + "UPDATE_NOTIFICATION": "Dostępna jest aktualizacja MagicMirror².", + "UPDATE_NOTIFICATION_MODULE": "Dostępna jest aktualizacja modułu {MODULE_NAME}.", + "UPDATE_INFO_SINGLE": "Zainstalowana wersja odbiega o {COMMIT_COUNT} commit od gałęzi {BRANCH_NAME}.", + "UPDATE_INFO_MULTIPLE": "Zainstalowana wersja odbiega o {COMMIT_COUNT} commitów od gałęzi {BRANCH_NAME}.", + "UPDATE_NOTIFICATION_DONE": "Aktualizacja modułu {MODULE_NAME} zakończona.", + "UPDATE_NOTIFICATION_ERROR": "Błąd aktualizacji modułu {MODULE_NAME}.", + "UPDATE_NOTIFICATION_NEED-RESTART": "Wymagany jest restart MagicMirror." +} diff --git a/translations/ps.json b/translations/ps.json new file mode 100644 index 0000000..d9390dc --- /dev/null +++ b/translations/ps.json @@ -0,0 +1,41 @@ +{ + "LOADING": "پیلېدل", + + "DAYBEFOREYESTERDAY": "پرون ورځ", + "YESTERDAY": "پرون", + "TODAY": "نن", + "TOMORROW": "سبا", + "DAYAFTERTOMORROW": "بل سبا", + "RUNNING": "روان", + "EMPTY": "تش", + "WEEK": "{weekNumber}. اونۍ", + + "N": "N", + "NNE": "NNO", + "NE": "NO", + "ENE": "ONO", + "E": "O", + "ESE": "OSO", + "SE": "SO", + "SSE": "SSO", + "S": "S", + "SSW": "SSW", + "SW": "SW", + "WSW": "WSW", + "W": "W", + "WNW": "WNW", + "NW": "NW", + "NNW": "NNW", + + "FEELS": "حس کېږی {DEGREE}", + + "MODULE_CONFIG_CHANGED": "د {MODULE_NAME} بڼی تغیر کړی دی. \n هیله دی چی اسناد و ګوری!", + + "UPDATE_NOTIFICATION": "د MagicMirror² نوې نسخه سته ", + "UPDATE_NOTIFICATION_MODULE": "د {MODULE_NAME} نوی نسخه سته", + "UPDATE_INFO_SINGLE": "اوسنی برخه {COMMIT_COUNT} د {BRANCH_NAME} څخه وروسته پاته ده", + "UPDATE_INFO_MULTIPLE": "اوسنی برخه {COMMIT_COUNT} د {BRANCH_NAME} څخه وروسته پاته ده", + + "NEWSFEED_NO_ITEMS": "مقاله شتون نلري.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "مقاله دلته نه شي ښودل کیدی." +} diff --git a/translations/pt-br.json b/translations/pt-br.json new file mode 100644 index 0000000..4fdf604 --- /dev/null +++ b/translations/pt-br.json @@ -0,0 +1,53 @@ +{ + "LOADING": "Carregando …", + + "DAYBEFOREYESTERDAY": "Anteontem", + "YESTERDAY": "Ontem", + "TODAY": "Hoje", + "TOMORROW": "Amanhã", + "RUNNING": "Acaba em", + "EMPTY": "Nenhum evento novo.", + "WEEK": "Semana {weekNumber}", + + "N": "N", + "NNE": "NNE", + "NE": "NE", + "ENE": "ENE", + "E": "E", + "ESE": "ESE", + "SE": "SE", + "SSE": "SSE", + "S": "S", + "SSW": "SSO", + "SW": "SO", + "WSW": "OSO", + "W": "O", + "WNW": "ONO", + "NW": "NO", + "NNW": "NNO", + + "FEELS": "Percebida {DEGREE}", + "PRECIP_POP": "Probabilidade de precipitação", + "PRECIP_AMOUNT": "Quantidade de precipitação", + + "MODULE_CONFIG_CHANGED": "As opções de configuração do módulo {MODULE_NAME} foram alteradas.\nConsulte a documentação.", + "MODULE_CONFIG_ERROR": "Erro no módulo {MODULE_NAME}. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "URL inválido.", + "MODULE_ERROR_NO_CONNECTION": "Sem conexão com a Internet.", + "MODULE_ERROR_UNAUTHORIZED": "Falha na autenticação.", + "MODULE_ERROR_RATE_LIMITED": "Muitas requisições. Tentando novamente mais tarde.", + "MODULE_ERROR_SERVER_ERROR": "Erro do servidor. Tentando novamente mais tarde.", + "MODULE_ERROR_CLIENT_ERROR": "Requisição falhou.", + "MODULE_ERROR_UNSPECIFIED": "Verifique os logs para mais detalhes.", + + "NEWSFEED_NO_ITEMS": "Atualmente não há notícias.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "O artigo não pode ser exibido aqui.", + + "UPDATE_NOTIFICATION": "Nova atualização para MagicMirror² disponível.", + "UPDATE_NOTIFICATION_MODULE": "Atualização para o módulo {MODULE_NAME} disponível.", + "UPDATE_INFO_SINGLE": "Sua versão atual é a {COMMIT_COUNT} commit dentro do seguinte branch {BRANCH_NAME}.", + "UPDATE_INFO_MULTIPLE": "Sua versão atual é a {COMMIT_COUNT} commits dentro do seguinte branch {BRANCH_NAME}.", + "UPDATE_NOTIFICATION_DONE": "A atualização do módulo {MODULE_NAME} foi concluída.", + "UPDATE_NOTIFICATION_ERROR": "Ocorreu um erro ao atualizar o módulo {MODULE_NAME}.", + "UPDATE_NOTIFICATION_NEED-RESTART": "É necessário reiniciar o MagicMirror." +} diff --git a/translations/pt.json b/translations/pt.json new file mode 100644 index 0000000..44770c7 --- /dev/null +++ b/translations/pt.json @@ -0,0 +1,55 @@ +{ + "LOADING": "A carregar…", + + "DAYBEFOREYESTERDAY": "Anteontem", + "YESTERDAY": "Ontem", + "TODAY": "Hoje", + "TOMORROW": "Amanhã", + "DAYAFTERTOMORROW": "Depois de amanhã", + "RUNNING": "Termina em", + "EMPTY": "Sem eventos programados.", + "WEEK": "Semana {weekNumber}", + "WEEK_SHORT": "S{weekNumber}", + + "N": "N", + "NNE": "NNE", + "NE": "NE", + "ENE": "ENE", + "E": "E", + "ESE": "ESE", + "SE": "SE", + "SSE": "SSE", + "S": "S", + "SSW": "SSO", + "SW": "SO", + "WSW": "OSO", + "W": "O", + "WNW": "ONO", + "NW": "NO", + "NNW": "NNO", + + "FEELS": "Sensação térmica {DEGREE}", + "PRECIP_POP": "Probabilidade de precipitação", + "PRECIP_AMOUNT": "Quantidade de precipitação", + + "MODULE_CONFIG_CHANGED": "As opções de configuração do módulo {MODULE_NAME} foram alteradas.\nConsulta a documentação.", + "MODULE_CONFIG_ERROR": "Erro no módulo {MODULE_NAME}. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "URL inválido.", + "MODULE_ERROR_NO_CONNECTION": "Sem ligação à internet.", + "MODULE_ERROR_UNAUTHORIZED": "Falha na autorização.", + "MODULE_ERROR_RATE_LIMITED": "Demasiados pedidos. A tentar novamente mais tarde.", + "MODULE_ERROR_SERVER_ERROR": "Erro do servidor. A tentar novamente mais tarde.", + "MODULE_ERROR_CLIENT_ERROR": "Pedido falhou.", + "MODULE_ERROR_UNSPECIFIED": "Consulta os registos para mais detalhes.", + + "NEWSFEED_NO_ITEMS": "Sem notícias de momento.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "O artigo não pode ser apresentado aqui.", + + "UPDATE_NOTIFICATION": "Está disponível uma atualização do MagicMirror².", + "UPDATE_NOTIFICATION_MODULE": "Atualização disponível para o módulo {MODULE_NAME}.", + "UPDATE_INFO_SINGLE": "A instalação atual está {COMMIT_COUNT} commit atrás na ramificação {BRANCH_NAME}.", + "UPDATE_INFO_MULTIPLE": "A instalação atual está {COMMIT_COUNT} commits atrás na ramificação {BRANCH_NAME}.", + "UPDATE_NOTIFICATION_DONE": "Atualização concluída do módulo {MODULE_NAME}.", + "UPDATE_NOTIFICATION_ERROR": "Erro na atualização do módulo {MODULE_NAME}.", + "UPDATE_NOTIFICATION_NEED-RESTART": "É necessário reiniciar o MagicMirror." +} diff --git a/translations/ro.json b/translations/ro.json new file mode 100644 index 0000000..5119fec --- /dev/null +++ b/translations/ro.json @@ -0,0 +1,54 @@ +{ + "LOADING": "Se încarcă …", + + "DAYBEFOREYESTERDAY": "Alaltaieri", + "YESTERDAY": "Ieri", + "TODAY": "Astăzi", + "TOMORROW": "Mâine", + "DAYAFTERTOMORROW": "Poimâine", + "RUNNING": "Se termină în", + "EMPTY": "Nici un eveniment.", + "WEEK": "Săptămâna {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", + + "FEELS": "Se simte ca fiind {DEGREE}", + "PRECIP_POP": "Probabilitate de precipitații", + "PRECIP_AMOUNT": "Cantitate de precipitații", + + "MODULE_CONFIG_CHANGED": "Opțiunile de configurare pentru modulul {MODULE_NAME} s-au schimbat.\nVă rugăm să verificați documentația.", + "MODULE_CONFIG_ERROR": "Eroare în modulul {MODULE_NAME}. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "URL incorect.", + "MODULE_ERROR_NO_CONNECTION": "Fără conexiune la internet.", + "MODULE_ERROR_UNAUTHORIZED": "Autorizarea a eșuat.", + "MODULE_ERROR_RATE_LIMITED": "Prea multe cereri. Se reîncearcă mai târziu.", + "MODULE_ERROR_SERVER_ERROR": "Eroare de server. Se reîncearcă mai târziu.", + "MODULE_ERROR_CLIENT_ERROR": "Cererea a eșuat.", + "MODULE_ERROR_UNSPECIFIED": "Verificați jurnalele pentru mai multe detalii.", + + "NEWSFEED_NO_ITEMS": "Nu există știri în acest moment.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Articolul nu poate fi afișat aici.", + + "UPDATE_NOTIFICATION": "Un update este disponibil pentru MagicMirror².", + "UPDATE_NOTIFICATION_MODULE": "Un update este disponibil pentru modulul {MODULE_NAME}.", + "UPDATE_INFO_SINGLE": "Există {COMMIT_COUNT} commit-uri noi pe branch-ul {BRANCH_NAME}.", + "UPDATE_INFO_MULTIPLE": "Există {COMMIT_COUNT} commit-uri noi pe branch-ul {BRANCH_NAME}.", + "UPDATE_NOTIFICATION_DONE": "Update-ul a fost finalizat pentru modulul {MODULE_NAME}", + "UPDATE_NOTIFICATION_ERROR": "Eroare la update-ul modulului {MODULE_NAME}", + "UPDATE_NOTIFICATION_NEED-RESTART": "Este necesară repornirea MagicMirror." +} diff --git a/translations/ru.json b/translations/ru.json new file mode 100644 index 0000000..bf5c969 --- /dev/null +++ b/translations/ru.json @@ -0,0 +1,54 @@ +{ + "LOADING": "Загрузка …", + + "DAYBEFOREYESTERDAY": "Позавчера", + "YESTERDAY": "Вчера", + "TODAY": "Сегодня", + "TOMORROW": "Завтра", + "DAYAFTERTOMORROW": "Послезавтра", + "RUNNING": "Заканчивается через", + "EMPTY": "Нет предстоящих событий", + "WEEK": "Неделя {weekNumber}", + + "N": "С", + "NNE": "ССВ", + "NE": "СВ", + "ENE": "ВСВ", + "E": "В", + "ESE": "ВЮВ", + "SE": "ЮВ", + "SSE": "ЮЮВ", + "S": "Ю", + "SSW": "ЮЮЗ", + "SW": "ЮЗ", + "WSW": "ЗЮЗ", + "W": "З", + "WNW": "ЗСЗ", + "NW": "СЗ", + "NNW": "ССЗ", + + "FEELS": "По ощущению {DEGREE}", + "PRECIP_POP": "Вероятность осадков", + "PRECIP_AMOUNT": "Количество осадков", + + "MODULE_CONFIG_CHANGED": "Настройки модуля {MODULE_NAME} изменены.\nПожалуйста, проверьте документацию.", + "MODULE_CONFIG_ERROR": "Ошибка в модуле {MODULE_NAME}. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "Неверный URL.", + "MODULE_ERROR_NO_CONNECTION": "Нет интернет-соединения.", + "MODULE_ERROR_UNAUTHORIZED": "Не удалось авторизоваться.", + "MODULE_ERROR_RATE_LIMITED": "Слишком много запросов. Повторная попытка позже.", + "MODULE_ERROR_SERVER_ERROR": "Ошибка сервера. Повторная попытка позже.", + "MODULE_ERROR_CLIENT_ERROR": "Запрос не удался.", + "MODULE_ERROR_UNSPECIFIED": "Пожалуйста, проверьте логи для получения дополнительной информации.", + + "NEWSFEED_NO_ITEMS": "В данный момент нет новостей.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Статья не может быть отображена здесь.", + + "UPDATE_NOTIFICATION": "Есть обновление для MagicMirror².", + "UPDATE_NOTIFICATION_MODULE": "Есть обновление для {MODULE_NAME} модуля.", + "UPDATE_INFO_SINGLE": "Данная инсталляция позади {BRANCH_NAME} commit ветки на {COMMIT_COUNT} коммитов.", + "UPDATE_INFO_MULTIPLE": "Данная инсталляция позади {BRANCH_NAME} commits ветки на {COMMIT_COUNT} коммитов.", + "UPDATE_NOTIFICATION_DONE": "Обновление модуля {MODULE_NAME} завершено.", + "UPDATE_NOTIFICATION_ERROR": "Ошибка обновления модуля {MODULE_NAME}.", + "UPDATE_NOTIFICATION_NEED-RESTART": "Требуется перезапуск MagicMirror." +} diff --git a/translations/sk.json b/translations/sk.json new file mode 100644 index 0000000..a420a45 --- /dev/null +++ b/translations/sk.json @@ -0,0 +1,54 @@ +{ + "LOADING": "Načítanie …", + + "DAYBEFOREYESTERDAY": "Predvčerom", + "YESTERDAY": "Včera", + "TODAY": "Dnes", + "TOMORROW": "Zajtra", + "DAYAFTERTOMORROW": "Pozajtra", + "RUNNING": "Končí o", + "EMPTY": "Žiadne nadchádzajúce udalosti.", + "WEEK": "{weekNumber}. týždeň", + + "N": "S", + "NNE": "SSV", + "NE": "SV", + "ENE": "VSV", + "E": "V", + "ESE": "VJV", + "SE": "JV", + "SSE": "JJV", + "S": "J", + "SSW": "JJZ", + "SW": "JZ", + "WSW": "ZJZ", + "W": "Z", + "WNW": "ZSZ", + "NW": "SZ", + "NNW": "SSZ", + + "FEELS": "Pocit ako {DEGREE}", + "PRECIP_POP": "Pravdepodobnosť zrážok", + "PRECIP_AMOUNT": "Množstvo zrážok", + + "MODULE_CONFIG_CHANGED": "Konfiguračné možnosti modulu {MODULE_NAME} boli zmenené.\nProsím, skontrolujte dokumentáciu.", + "MODULE_CONFIG_ERROR": "Chyba v module {MODULE_NAME}. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "Nesprávna URL adresa.", + "MODULE_ERROR_NO_CONNECTION": "Nie je pripojenie k internetu.", + "MODULE_ERROR_UNAUTHORIZED": "Autorizácia zlyhala.", + "MODULE_ERROR_RATE_LIMITED": "Príliš veľa požiadaviek. Skúšam znovu neskôr.", + "MODULE_ERROR_SERVER_ERROR": "Chyba servera. Skúšam znovu neskôr.", + "MODULE_ERROR_CLIENT_ERROR": "Požiadavka zlyhala.", + "MODULE_ERROR_UNSPECIFIED": "Skontrolujte protokoly pre viac informácií.", + + "NEWSFEED_NO_ITEMS": "Momentálne žiadne správy.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Článok sa tu nedá zobraziť.", + + "UPDATE_NOTIFICATION": "Dostupná aktualizácia pre MagicMirror².", + "UPDATE_NOTIFICATION_MODULE": "Dostupná aktualizácia pre modul {MODULE_NAME}.", + "UPDATE_INFO_SINGLE": "Súčasná inštalácia je na vetve {BRANCH_NAME} pozadu o {COMMIT_COUNT} commit.", + "UPDATE_INFO_MULTIPLE": "Súčasná inštalácia je na vetve {BRANCH_NAME} pozadu o {COMMIT_COUNT} commitov.", + "UPDATE_NOTIFICATION_DONE": "Aktualizácia dokončená pre modul {MODULE_NAME}.", + "UPDATE_NOTIFICATION_ERROR": "Chyba aktualizácie modulu {MODULE_NAME}.", + "UPDATE_NOTIFICATION_NEED-RESTART": "Je potrebné reštartovať MagicMirror." +} diff --git a/translations/sv.json b/translations/sv.json new file mode 100644 index 0000000..a05c1b7 --- /dev/null +++ b/translations/sv.json @@ -0,0 +1,54 @@ +{ + "LOADING": "Laddar …", + + "DAYBEFOREYESTERDAY": "", + "YESTERDAY": "I går", + "TODAY": "I dag", + "TOMORROW": "I morgon", + "DAYAFTERTOMORROW": "I övermorgon", + "RUNNING": "Slutar", + "EMPTY": "Inga kommande händelser.", + "WEEK": "Vecka {weekNumber}", + + "N": "N", + "NNE": "NNO", + "NE": "NO", + "ENE": "ONO", + "E": "Ö", + "ESE": "OSO", + "SE": "SO", + "SSE": "SSO", + "S": "S", + "SSW": "SSV", + "SW": "SV", + "WSW": "VSV", + "W": "V", + "WNW": "VNV", + "NW": "NV", + "NNW": "NNV", + + "FEELS": "Känns som {DEGREE}", + "PRECIP_POP": "Nederbördsrisk", + "PRECIP_AMOUNT": "Nederbördsmängd", + + "MODULE_CONFIG_CHANGED": "Konfigurationsalternativen för modulen {MODULE_NAME} har ändrats.\nVänligen se dokumentationen.", + "MODULE_CONFIG_ERROR": "Fel i modulen {MODULE_NAME}. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "Felaktig URL.", + "MODULE_ERROR_NO_CONNECTION": "Ingen internetanslutning.", + "MODULE_ERROR_UNAUTHORIZED": "Autentisering misslyckades.", + "MODULE_ERROR_RATE_LIMITED": "För många förfrågningar. Försöker igen senare.", + "MODULE_ERROR_SERVER_ERROR": "Serverfel. Försöker igen senare.", + "MODULE_ERROR_CLIENT_ERROR": "Förfrågan misslyckades.", + "MODULE_ERROR_UNSPECIFIED": "Vänligen kontrollera loggarna för mer information.", + + "NEWSFEED_NO_ITEMS": "Inga nyheter för tillfället.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Artikeln kan inte visas här.", + + "UPDATE_NOTIFICATION": "MagicMirror² uppdatering finns tillgänglig.", + "UPDATE_NOTIFICATION_MODULE": "Uppdatering finns tillgänglig av {MODULE_NAME} modulen.", + "UPDATE_INFO_SINGLE": "Denna installation ligger {COMMIT_COUNT} commit steg bakom {BRANCH_NAME} grenen.", + "UPDATE_INFO_MULTIPLE": "Denna installation ligger {COMMIT_COUNT} commits steg bakom {BRANCH_NAME} grenen.", + "UPDATE_NOTIFICATION_DONE": "Uppdateringen av modulen {MODULE_NAME} har slutförts.", + "UPDATE_NOTIFICATION_ERROR": "Ett fel inträffade vid uppdateringen av modulen {MODULE_NAME}.", + "UPDATE_NOTIFICATION_NEED-RESTART": "Det krävs en omstart av MagicMirror." +} diff --git a/translations/th.json b/translations/th.json new file mode 100644 index 0000000..1a2bfd4 --- /dev/null +++ b/translations/th.json @@ -0,0 +1,54 @@ +{ + "LOADING": "กำลังโหลด …", + + "DAYBEFOREYESTERDAY": "เมื่อวานซืน", + "YESTERDAY": "เมื่อวานนี้", + "TODAY": "วันนี้", + "TOMORROW": "พรุ่งนี้", + "DAYAFTERTOMORROW": "มะรืนนี้", + "RUNNING": "สิ้นสุดใน", + "EMPTY": "ไม่มีกิจกรรมที่กำลังจะมาถึง", + "WEEK": "สัปดาห์ที่ {weekNumber}", + + "N": "น", + "NNE": "น.ต.อ.น.", + "NE": "ต.อ.น.", + "ENE": "ต.อ.ต.อ.น.", + "E": "ต.อ.", + "ESE": "ต.อ.ต.อ.ต.", + "SE": "ต.อ.ต.", + "SSE": "ต.ต.อ.ต.", + "S": "ต.", + "SSW": "ต.ต.ต.ต.", + "SW": "ต.ต.ต.", + "WSW": "ต.ต.ต.ต.ต.", + "W": "ต.ต.", + "WNW": "ต.ต.ต.ต.น.", + "NW": "ต.ต.น.", + "NNW": "น.ต.ต.น.", + + "FEELS": "รู้สึกเหมือน {DEGREE}", + "PRECIP_POP": "ความแม่นยำ", + "PRECIP_AMOUNT": "ปริมาณน้ำฝน", + + "MODULE_CONFIG_CHANGED": "ตัวเลือกการกำหนดค่าสำหรับโมดูล {MODULE_NAME} มีการเปลี่ยนแปลง\nโปรดตรวจสอบเอกสารประกอบ", + "MODULE_CONFIG_ERROR": "เกิดข้อผิดพลาดในโมดูล {MODULE_NAME} {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "URL ผิดรูปแบบ", + "MODULE_ERROR_NO_CONNECTION": "ไม่มีการเชื่อมต่ออินเทอร์เน็ต.", + "MODULE_ERROR_UNAUTHORIZED": "การอนุญาตล้มเหลว", + "MODULE_ERROR_RATE_LIMITED": "คำขอมากเกินไป กำลังลองใหม่ในภายหลัง", + "MODULE_ERROR_SERVER_ERROR": "ข้อผิดพลาดเซิร์ฟเวอร์ กำลังลองใหม่ในภายหลัง", + "MODULE_ERROR_CLIENT_ERROR": "คำขอล้มเหลว", + "MODULE_ERROR_UNSPECIFIED": "ตรวจสอบบันทึกสำหรับรายละเอียดเพิ่มเติม", + + "NEWSFEED_NO_ITEMS": "ไม่มีข่าวในขณะนี้", + "NEWSFEED_ARTICLE_UNAVAILABLE": "ไม่สามารถแสดงบทความที่นี่ได้", + + "UPDATE_NOTIFICATION": "MagicMirror² มีการอัปเดต", + "UPDATE_NOTIFICATION_MODULE": "มีการอัปเดตสำหรับโมดูล {MODULE_NAME}", + "UPDATE_INFO_SINGLE": "การติดตั้งปัจจุบันถูกคอมมิท {COMMIT_COUNT} รายการในสาขา {BRANCH_NAME}", + "UPDATE_INFO_MULTIPLE": "การติดตั้งปัจจุบันคือ {COMMIT_COUNT} เป็นคอมมิทที่อยู่เบื้องหลังในสาขา {BRANCH_NAME}", + "UPDATE_NOTIFICATION_DONE": "การอัปเดตเสร็จสิ้นสำหรับโมดูล {MODULE_NAME}", + "UPDATE_NOTIFICATION_ERROR": "เกิดข้อผิดพลาดในการอัปเดตโมดูล {MODULE_NAME}", + "UPDATE_NOTIFICATION_NEED-RESTART": "ต้องรีสตาร์ท MagicMirror" +} diff --git a/translations/tlh.json b/translations/tlh.json new file mode 100644 index 0000000..295d40d --- /dev/null +++ b/translations/tlh.json @@ -0,0 +1,54 @@ +{ + "LOADING": "loS …", + + "DAYBEFOREYESTERDAY": "cha'Hu'", + "YESTERDAY": "wa'Hu'", + "TODAY": "DaHjaj", + "TOMORROW": "wa'leS", + "DAYAFTERTOMORROW": "cha'leS", + "RUNNING": "Dor", + "EMPTY": "Sumbe' wanI'.", + "WEEK": "Hogh (terran) {weekNumber}", + + "N": "N", + "NNE": "NNE", + "NE": "NE", + "ENE": "ENE", + "E": "chan", + "ESE": "ESE", + "SE": "SE", + "SSE": "SSE", + "S": "S", + "SSW": "SSW", + "SW": "tIng", + "WSW": "WSW", + "W": "W", + "WNW": "WNW", + "NW": "'ev", + "NNW": "NNW", + + "FEELS": "jem {DEGREE}", + "PRECIP_POP": "nIHmey", + "PRECIP_AMOUNT": "nIHmey Daq", + + "MODULE_CONFIG_CHANGED": "mIghtaH {MODULE_NAME} mIghtaHmey lIjwI'pu'.\nDoHbe' yImev.", + "MODULE_CONFIG_ERROR": "{MODULE_NAME} mIghtaHmeyDaq ghobe' yImev. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "URL ghobe' yImev.", + "MODULE_ERROR_NO_CONNECTION": "Internet ghobe' yImev.", + "MODULE_ERROR_UNAUTHORIZED": "ghobe' yImev.", + "MODULE_ERROR_RATE_LIMITED": "tlhoy rurqu' lut. vaj ratlh.", + "MODULE_ERROR_SERVER_ERROR": "Qagh server. vaj ratlh.", + "MODULE_ERROR_CLIENT_ERROR": "lut Qagh.", + "MODULE_ERROR_UNSPECIFIED": "logmeyDaq yImev.", + + "NEWSFEED_NO_ITEMS": "DaHghachmey ghobe' yImev.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "QIngheb cha' tu'be'lu'.", + + "UPDATE_NOTIFICATION": " De'chu' MagicMirror² lI'laH.", + "UPDATE_NOTIFICATION_MODULE": "bobcho' {MODULE_NAME} lI'laH De'chu.", + "UPDATE_INFO_SINGLE": "{BRANCH_NAME} ghoghDaq {COMMIT_COUNT} commit lIjwI'pu'.", + "UPDATE_INFO_MULTIPLE": "{BRANCH_NAME} ghoghDaq {COMMIT_COUNT} commit lIjwI'pu'.", + "UPDATE_NOTIFICATION_DONE": "{MODULE_NAME} mIghtaHmeyDaq bobcho' lIjwI'pu'.", + "UPDATE_NOTIFICATION_ERROR": "{MODULE_NAME} mIghtaHmeyDaq bobcho' ghobe' yImev.", + "UPDATE_NOTIFICATION_NEED-RESTART": "MagicMirrorDaq yImev." +} diff --git a/translations/tr.json b/translations/tr.json new file mode 100644 index 0000000..6688add --- /dev/null +++ b/translations/tr.json @@ -0,0 +1,53 @@ +{ + "LOADING": "Yükleniyor …", + + "YESTERDAY": "Dün", + "TODAY": "Bugün", + "TOMORROW": "Yarın", + "DAYAFTERTOMORROW": "İki gün içinde", + "RUNNING": "Biten", + "EMPTY": "Yakında etkinlik yok.", + "WEEK": "Hafta {weekNumber}", + + "N": "K", + "NNE": "KKD", + "NE": "KD", + "ENE": "DKD", + "E": "D", + "ESE": "DGD", + "SE": "GD", + "SSE": "GGD", + "S": "G", + "SSW": "GGB", + "SW": "GB", + "WSW": "BGB", + "W": "B", + "WNW": "BKB", + "NW": "KB", + "NNW": "KKB", + + "FEELS": "Hissedilen {DEGREE}", + "PRECIP_POP": "Yağış", + "PRECIP_AMOUNT": "Yağış miktarı", + + "MODULE_CONFIG_CHANGED": "{MODULE_NAME} modülü için yapılandırma seçenekleri değiştirildi.\nLütfen belgeleri kontrol edin.", + "MODULE_CONFIG_ERROR": "{MODULE_NAME} modülünde hata. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "Hatalı URL.", + "MODULE_ERROR_NO_CONNECTION": "İnternet bağlantısı yok.", + "MODULE_ERROR_UNAUTHORIZED": "Yetkilendirme başarısız.", + "MODULE_ERROR_RATE_LIMITED": "Çok fazla istek. Daha sonra yeniden deneniyor.", + "MODULE_ERROR_SERVER_ERROR": "Sunucu hatası. Daha sonra yeniden deneniyor.", + "MODULE_ERROR_CLIENT_ERROR": "İstek başarısız oldu.", + "MODULE_ERROR_UNSPECIFIED": "Daha fazla ayrıntı için günlükleri kontrol edin.", + + "NEWSFEED_NO_ITEMS": "Şu anda haber yok.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Makale burada görüntülenemiyor.", + + "UPDATE_NOTIFICATION": "MagicMirror² güncellemesi mevcut.", + "UPDATE_NOTIFICATION_MODULE": "{MODULE_NAME} modulü için güncelleme mevcut.", + "UPDATE_INFO_SINGLE": "Sahip olduğunuz kurulum {BRANCH_NAME} branchinden {COMMIT_COUNT} commit geridedir.", + "UPDATE_INFO_MULTIPLE": "Sahip olduğunuz kurulum {BRANCH_NAME} branchinden {COMMIT_COUNT} commit geridedir.", + "UPDATE_NOTIFICATION_DONE": "Güncelleme tamamlandı.", + "UPDATE_NOTIFICATION_ERROR": "Güncelleme sırasında hata oluştu.", + "UPDATE_NOTIFICATION_NEED-RESTART": "MagicMirror'ün yeniden başlatılması gerekiyor." +} diff --git a/translations/translations.js b/translations/translations.js new file mode 100644 index 0000000..506f9ea --- /dev/null +++ b/translations/translations.js @@ -0,0 +1,51 @@ +let translations = { + en: "translations/en.json", // English + af: "translations/af.json", // Afrikaans + bg: "translations/bg.json", // Bulgarian + ca: "translations/ca.json", // Catalan + cs: "translations/cs.json", // Czech + cv: "translations/cv.json", // Chuvash + cy: "translations/cy.json", // Welsh (Cymraeg) + da: "translations/da.json", // Danish + de: "translations/de.json", // German + el: "translations/el.json", // Greek + eo: "translations/eo.json", // Esperanto + es: "translations/es.json", // Spanish + et: "translations/et.json", // Estonian + fi: "translations/fi.json", // Suomi + fr: "translations/fr.json", // French + fy: "translations/fy.json", // Frysk + gl: "translations/gl.json", // Galego + gu: "translations/gu.json", // Gujarati + he: "translations/he.json", // Hebrew + hi: "translations/hi.json", // Hindi + hr: "translations/hr.json", // Croatian + hu: "translations/hu.json", // Hungarian + id: "translations/id.json", // Indonesian + is: "translations/is.json", // Icelandic + it: "translations/it.json", // Italian + ja: "translations/ja.json", // Japanese + ko: "translations/ko.json", // Korean + lt: "translations/lt.json", // Lithuanian + "ms-my": "translations/ms-my.json", // Malay + nb: "translations/nb.json", // Norsk bokmål + nl: "translations/nl.json", // Dutch + nn: "translations/nn.json", // Norsk nynorsk + pl: "translations/pl.json", // Polish + pt: "translations/pt.json", // Português + "pt-br": "translations/pt-br.json", // Português Brasileiro + ro: "translations/ro.json", // Romanian + ru: "translations/ru.json", // Russian + sk: "translations/sk.json", // Slovak + sv: "translations/sv.json", // Svenska + th: "translations/th.json", // Thai + tlh: "translations/tlh.json", // Klingon + tr: "translations/tr.json", // Turkish + uk: "translations/uk.json", // Ukrainian + "zh-cn": "translations/zh-cn.json", // Simplified Chinese + "zh-tw": "translations/zh-tw.json" // Traditional Chinese +}; + +if (typeof module !== "undefined") { + module.exports = translations; +} diff --git a/translations/uk.json b/translations/uk.json new file mode 100644 index 0000000..1aad1ba --- /dev/null +++ b/translations/uk.json @@ -0,0 +1,54 @@ +{ + "LOADING": "Завантаження …", + + "DAYBEFOREYESTERDAY": "Позавчора", + "YESTERDAY": "вчора", + "TODAY": "Сьогодні", + "TOMORROW": "Завтра", + "DAYAFTERTOMORROW": "Післязавтра", + "RUNNING": "Закінчується через", + "EMPTY": "Немає найближчих подій", + "WEEK": "Тиждень {weekNumber}", + + "N": "Пн", + "NNE": "ПнПнСх", + "NE": "ПнСх", + "ENE": "СхПнСх", + "E": "Сх", + "ESE": "СхПдСх", + "SE": "СхПд", + "SSE": "СхСхПд", + "S": "Пд", + "SSW": "ПдПдЗх", + "SW": "ПдЗх", + "WSW": "ЗхПдЗх", + "W": "Зх", + "WNW": "ЗхПнЗх", + "NW": "ПнЗх", + "NNW": "ПнПнЗх", + + "FEELS": "Відчувається як {DEGREE}", + "PRECIP_POP": "Опади", + "PRECIP_AMOUNT": "Кількість опадів", + + "MODULE_CONFIG_CHANGED": "Змінились параметри конфігурації модуля {MODULE_NAME}.\nБудь ласка, перевірте документацію.", + "MODULE_CONFIG_ERROR": "Помилка в модулі {MODULE_NAME}. {ERROR}", + "MODULE_ERROR_MALFORMED_URL": "Неправильний URL.", + "MODULE_ERROR_NO_CONNECTION": "Немає підключення до Інтернету.", + "MODULE_ERROR_UNAUTHORIZED": "Авторизація не вдалася.", + "MODULE_ERROR_RATE_LIMITED": "Забагато запитів. Повторна спроба пізніше.", + "MODULE_ERROR_SERVER_ERROR": "Помилка сервера. Повторна спроба пізніше.", + "MODULE_ERROR_CLIENT_ERROR": "Запит не вдався.", + "MODULE_ERROR_UNSPECIFIED": "Перевірте журнали для отримання додаткової інформації.", + + "NEWSFEED_NO_ITEMS": "Немає новин на даний момент.", + "NEWSFEED_ARTICLE_UNAVAILABLE": "Статтю неможливо відобразити тут.", + + "UPDATE_NOTIFICATION": "Є оновлення для MagicMirror².", + "UPDATE_NOTIFICATION_MODULE": "Є оновлення для модуля {MODULE_NAME}.", + "UPDATE_INFO_SINGLE": "Поточна версія на {COMMIT_COUNT} комміт позаду від гілки {BRANCH_NAME}.", + "UPDATE_INFO_MULTIPLE": "Поточна інсталяція на {COMMIT_COUNT} комітів позаду від гілки {BRANCH_NAME}.", + "UPDATE_NOTIFICATION_DONE": "Оновлення завершено для модуля {MODULE_NAME}", + "UPDATE_NOTIFICATION_ERROR": "Помилка оновлення модуля {MODULE_NAME}", + "UPDATE_NOTIFICATION_NEED-RESTART": "Потрібен перезапуск MagicMirror." +} diff --git a/translations/zh-cn.json b/translations/zh-cn.json new file mode 100644 index 0000000..146a8a0 --- /dev/null +++ b/translations/zh-cn.json @@ -0,0 +1,54 @@ +{ + "LOADING": "正在加载 …", + + "DAYBEFOREYESTERDAY": "前天", + "YESTERDAY": "昨天", + "TODAY": "今天", + "TOMORROW": "明天", + "DAYAFTERTOMORROW": "后天", + "RUNNING": "结束日期", + "EMPTY": "无日程安排。", + "WEEK": "第{weekNumber}周", + + "N": "北风", + "NNE": "北偏东风", + "NE": "东北风", + "ENE": "东偏北风", + "E": "东风", + "ESE": "东偏南风", + "SE": "东南风", + "SSE": "南偏东风", + "S": "南风", + "SSW": "南偏西风", + "SW": "西南风", + "WSW": "西偏南风", + "W": "西风", + "WNW": "西偏北风", + "NW": "西北风", + "NNW": "北偏西风", + + "FEELS": "体感 {DEGREE}", + "PRECIP_POP": "降水概率", + "PRECIP_AMOUNT": "降水量", + + "MODULE_CONFIG_CHANGED": "模块{MODULE_NAME}的配置选项已更改。\n请查看文档。", + "MODULE_CONFIG_ERROR": "模块{MODULE_NAME}发生错误。{ERROR}", + "MODULE_ERROR_MALFORMED_URL": "URL格式错误。", + "MODULE_ERROR_NO_CONNECTION": "无网络连接。", + "MODULE_ERROR_UNAUTHORIZED": "授权失败。", + "MODULE_ERROR_RATE_LIMITED": "请求过多。稍后重试。", + "MODULE_ERROR_SERVER_ERROR": "服务器错误。稍后重试。", + "MODULE_ERROR_CLIENT_ERROR": "请求失败。", + "MODULE_ERROR_UNSPECIFIED": "请查看日志以获取更多详细信息。", + + "NEWSFEED_NO_ITEMS": "目前没有新闻。", + "NEWSFEED_ARTICLE_UNAVAILABLE": "文章无法在此处显示。", + + "UPDATE_NOTIFICATION": "MagicMirror²有新的版本。", + "UPDATE_NOTIFICATION_MODULE": "{MODULE_NAME}模块可更新。", + "UPDATE_INFO_SINGLE": "当前已安装版本比{BRANCH_NAME}分支落后{COMMIT_COUNT}次代码更新。", + "UPDATE_INFO_MULTIPLE": "当前已安装版本比{BRANCH_NAME}分支落后{COMMIT_COUNT}次代码更新。", + "UPDATE_NOTIFICATION_DONE": "{MODULE_NAME}模块更新完成。", + "UPDATE_NOTIFICATION_ERROR": "{MODULE_NAME}模块更新错误。", + "UPDATE_NOTIFICATION_NEED-RESTART": "需要重启MagicMirror。" +} diff --git a/translations/zh-tw.json b/translations/zh-tw.json new file mode 100644 index 0000000..1c21632 --- /dev/null +++ b/translations/zh-tw.json @@ -0,0 +1,54 @@ +{ + "LOADING": "正在載入 …", + + "DAYBEFOREYESTERDAY": "前天", + "YESTERDAY": "昨天", + "TODAY": "今天", + "TOMORROW": "明天", + "DAYAFTERTOMORROW": "後天", + "RUNNING": "結束日期", + "EMPTY": "沒有更多的活動。", + "WEEK": "第 {weekNumber} 週", + + "N": "北風", + "NNE": "北偏東風", + "NE": "東北風", + "ENE": "東偏北風", + "E": "東風", + "ESE": "東偏南風", + "SE": "東南風", + "SSE": "南偏東風", + "S": "南風", + "SSW": "南偏西風", + "SW": "西南風", + "WSW": "西偏南風", + "W": "西風", + "WNW": "西偏北風", + "NW": "西北風", + "NNW": "北偏西風", + + "FEELS": "體感溫度 {DEGREE}", + "PRECIP_POP": "降雨機率", + "PRECIP_AMOUNT": "降雨量", + + "MODULE_CONFIG_CHANGED": "模組 {MODULE_NAME} 的設定檔選項已更改。\n請參見說明文件。", + "MODULE_CONFIG_ERROR": "{MODULE_NAME} 模組發生錯誤。{ERROR}", + "MODULE_ERROR_MALFORMED_URL": "網址格式錯誤。", + "MODULE_ERROR_NO_CONNECTION": "無網路連線。", + "MODULE_ERROR_UNAUTHORIZED": "授權失敗。", + "MODULE_ERROR_RATE_LIMITED": "請求過多。稍後重試。", + "MODULE_ERROR_SERVER_ERROR": "伺服器錯誤。稍後重試。", + "MODULE_ERROR_CLIENT_ERROR": "請求失敗。", + "MODULE_ERROR_UNSPECIFIED": "查看日誌以了解詳情。", + + "NEWSFEED_NO_ITEMS": "目前沒有新聞。", + "NEWSFEED_ARTICLE_UNAVAILABLE": "文章無法在此處顯示。", + + "UPDATE_NOTIFICATION": "MagicMirror² 有可用更新。", + "UPDATE_NOTIFICATION_MODULE": "{MODULE_NAME} 模組有可用更新。", + "UPDATE_INFO_SINGLE": "目前版本在 {BRANCH_NAME} 分支上已落後了 {COMMIT_COUNT} 次 commit。", + "UPDATE_INFO_MULTIPLE": "目前版本在 {BRANCH_NAME} 分支上已落後了 {COMMIT_COUNT} 次 commit。", + "UPDATE_NOTIFICATION_DONE": "{MODULE_NAME} 模組更新完成。", + "UPDATE_NOTIFICATION_ERROR": "{MODULE_NAME} 模組更新錯誤。", + "UPDATE_NOTIFICATION_NEED-RESTART": "需要重新啟動 MagicMirror。" +} diff --git a/vitest.config.mjs b/vitest.config.mjs new file mode 100644 index 0000000..80c231f --- /dev/null +++ b/vitest.config.mjs @@ -0,0 +1,105 @@ +import {defineConfig} from "vitest/config"; + +/* + * Sequential execution keeps our shared test server stable: + * - All suites bind to port 8080 + * - Fixtures and temp paths are reused between tests + * - Debugging becomes predictable + * + * Parallel execution would require dynamic ports and isolated fixtures, + * so we intentionally cap Vitest at a single worker for now. + * + * Projects separate unit, e2e (Playwright), and electron tests with + * appropriate timeouts for each test type. + */ + +export default defineConfig({ + test: { + // Shared settings for all test types + globals: true, + environment: "node", + setupFiles: ["./tests/utils/vitest-setup.js"], + // Stop test execution on first failure + bail: 3, + // Automatically restore all mocks after each test to prevent leaks + restoreAllMocks: true, + + // Shared exclude patterns + exclude: [ + "**/node_modules/**", + "**/dist/**", + "tests/unit/mocks/**", + "tests/unit/helpers/**", + "tests/electron/helpers/**", + "tests/e2e/helpers/**", + "tests/e2e/mocks/**", + "tests/configs/**", + "tests/utils/**" + ], + + // Projects with specific configurations per test type + projects: [ + { + test: { + name: "unit", + globals: true, + environment: "node", + setupFiles: ["./tests/utils/vitest-setup.js"], + include: [ + "tests/unit/**/*_spec.js", + "tests/unit/defaultmodules/calendar/calendar_fetcher_utils_bad_rrule.js" + ], + testTimeout: 20000, + hookTimeout: 10000 + } + }, + { + test: { + name: "e2e", + globals: true, + environment: "node", + setupFiles: ["./tests/utils/vitest-setup.js"], + include: ["tests/e2e/**/*_spec.js"], + testTimeout: 60000, + hookTimeout: 30000 + } + }, + { + test: { + name: "electron", + globals: true, + environment: "node", + setupFiles: ["./tests/utils/vitest-setup.js"], + include: ["tests/electron/**/*_spec.js"], + testTimeout: 120000, + hookTimeout: 30000 + } + } + ], + + // Coverage configuration + coverage: { + provider: "v8", + reporter: ["lcov", "text"], + include: [ + "clientonly/**/*.js", + "js/**/*.js", + "defaultmodules/**/*.js", + "serveronly/**/*.js" + ], + exclude: [ + "**/node_modules/**", + "**/tests/**", + "**/dist/**" + ] + }, + + /* + * Pool settings for isolated test execution. Keep maxWorkers at 1 so + * port 8080 and shared fixtures remain safe across the full suite. + */ + pool: "forks", + maxWorkers: 1, + isolate: true + } +});