diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index be747af..40e12a0 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "1.40.2"
+ ".": "1.41.0"
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2fbeaa5..063d460 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## [1.41.0](https://github.com/YorikHansen/better-moodle/compare/1.40.2...1.41.0) (2024-09-10)
+
+
+### Features
+
+* **ninaIntegration:** Add NINA warn app integration ([#376](https://github.com/jxn-30/better-moodle/issues/376)) ([fa21640](https://github.com/YorikHansen/better-moodle/commit/fa21640686d18600c0e59de455b5241940123c7a))
+
## [1.40.2](https://github.com/YorikHansen/better-moodle/compare/1.40.1...1.40.2) (2024-09-10)
diff --git a/package.json b/package.json
index 0d5e9b6..6ef333a 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "uzl-better-moodle",
"description": "Improves the new weird Moodle 4 UI on UzL-Moodle",
- "version": "1.40.2",
+ "version": "1.41.0",
"packageManager": "yarn@4.4.1",
"type": "module",
"private": true,
diff --git a/redesign.user.js b/redesign.user.js
index bfd6324..32f9257 100644
--- a/redesign.user.js
+++ b/redesign.user.js
@@ -2,7 +2,7 @@
// @name 🎓️ CAU: better-moodle
// @namespace https://better-moodle.yorik.dev
// @ x-release-please-start-version
-// @version 1.40.2
+// @version 1.41.0
// @ x-release-please-end
// @author Jan (jxn_30), Yorik (YorikHansen)
// @description Improves Moodle by cool features and design improvements.
@@ -20,7 +20,9 @@
// @grant GM_listValues
// @grant GM_addValueChangeListener
// @grant GM_info
+// @grant GM_notification
// @grant GM_xmlhttpRequest
+// @connect nina.api.proxy.bund.dev
// @connect studentenwerk.sh
// @connect api.open-meteo.com
// @connect api.openweathermap.org
@@ -79,6 +81,73 @@ const TRANSLATIONS = {
myCoursesLink: 'Meine Kurse',
},
},
+ nina: {
+ activeWarnings: 'Aktuelle Warnmeldungen',
+ bbkLink: 'Meldung auf der Seite des BBK',
+ categories: 'Kategorien',
+ category: {
+ geo: 'Geophysikalisch (einschl. Erdrutsch)',
+ met: 'Meteorologisch (einschl. Hochwasser)',
+ safety: 'Allgemeine Notfälle und öffentliche Sicherheit',
+ security:
+ 'Strafverfolgung, Militär, Heimatschutz und lokale/private Sicherheit',
+ rescue: 'Rettung und Bergung',
+ fire: 'Brandbekämpfung und Rettung',
+ health: 'Medizinische und öffentliche Gesundheit',
+ env: 'Verschmutzung und andere Umweltprobleme',
+ transport: 'Öffentlicher und privater Transport',
+ infra: 'Versorgungs-, Telekommunikations- und andere Nicht-Verkehrsinfrastruktur',
+ cbrne: 'Chemisch, biologisch, radiologisch, nuklear und explosiv',
+ other: 'Andere Ereignisse',
+ },
+ close: 'Schließen',
+ description: 'Beschreibung',
+ instruction: 'Handlungsempfehlung',
+ notFound: {
+ title: 'MELDUNG NICHT MEHR VORHANDEN',
+ description:
+ 'Die von Ihnen abgerufene Warnmeldung ist nicht mehr vorhanden. Es liegt ggf. eine Entwarnung für diese Meldung vor.',
+ },
+ panic: 'PANIK!!!!',
+ providedBy: 'Herausgegeben von',
+ severity: {
+ name: 'Warnstufe',
+ 3: 'Extreme Gefahr',
+ 2: 'Gefahr',
+ 1: 'Gefahreninformation',
+ 0: 'Keine Gefahr',
+ [-1]: 'Unbekannt',
+ },
+ severityWeather: {
+ name: 'Warnstufe',
+ 3: 'Amtliche Warnung vor extremem Unwetter',
+ 2: 'Amtliche Unwetterwarnung',
+ 1: 'Amtliche Warnung vor markantem Wetter',
+ 0: 'Keine Gefahr',
+ [-1]: 'Unbekannt',
+ },
+ showMore: 'Mehr anzeigen',
+ status: {
+ name: 'Status',
+ actual: 'Echte Warnung',
+ exercise: 'Übung',
+ system: 'System',
+ test: 'Testwarnung',
+ draft: 'Entwurf',
+ },
+ testWarning: {
+ title: 'WARNUNG: Süßes Mammut gesichtet!',
+ description:
+ 'Ein süßes Mammut wurde in der Nähe der Uni gesichtet. Seien Sie wachsam!',
+ instruction:
+ 'Wenn du das Mammut siehst, streichle es bitte und gib ihm einen Keks.',
+ event: 'Süßes Mammut gesichtet',
+ web: 'https://moothel.pet/',
+ provider: 'Better-Moodle',
+ senderName: 'Moothel',
+ },
+ via: 'via',
+ },
speiseplan: {
title: 'Speiseplan der Mensa "{{canteen}}"',
close: 'Schließen',
@@ -643,6 +712,60 @@ Viele Grüße
},
},
},
+ nina: {
+ _title: 'NINA Warnungen',
+ _description:
+ 'Finde mehr über Warnungen heraus, indem du auf die Benachrichtigungen klickst.',
+ enabled: {
+ name: 'NINA Warnungen aktivieren',
+ description:
+ '⚠️ Zeigt Warnungen der NINA-App in der Navigationsleiste an.',
+ },
+ notification: {
+ name: 'Push Benachrichtigungen für NINA Warnungen',
+ description:
+ 'Zeigt Push Benachrichtigungen für NINA Warnungen an.',
+ },
+
+ civilProtectionWarnings: {
+ name: 'Benachrichtigungen für Bevölerungsschutz-Warnungen',
+ description:
+ 'Hier kann eingestellt werden, ab welcher Warnstufe eine Benachrichtigung angezeigt werden soll.',
+ labels: {
+ off: 'Nie',
+ extreme: 'Extreme Gefahr',
+ severe: 'Gefahr',
+ moderate: 'Gefahreninformation',
+ },
+ },
+ weatherWarnings: {
+ name: 'Benachrichtigungen für Wetterwarnungen',
+ description:
+ 'Hier kann eingestellt werden, ab welcher Warnstufe des Deutschen Wetterdienstes eine Benachrichtigung angezeigt werden soll.',
+ labels: {
+ off: 'Nie',
+ extreme: 'Extremes Unwetter',
+ severe: 'Unwetter',
+ moderate: 'Markantes Wetter',
+ },
+ },
+ floodWarnings: {
+ name: 'Hochwasserwarnungen',
+ description:
+ 'Benachrichtigungen zu Hochwasserereignissen an Binnengewässern (Flüsse, Kanöle, Binnenseen).',
+ },
+ megaAlarm: {
+ name: 'Erlaube den Mega-Alarm Modus',
+ description:
+ '🚨🚨🚨 DU WIRST WICHTIGE WARNUNGEN NIE WIEDER ÜBERSEHEN!!! 🚨🚨🚨',
+ },
+ test: {
+ name: 'Teste die NINA Warnungen',
+ description:
+ 'Sende eine Test-Warnung, um zu sehen, wie die Benachrichtigungen aussehen.',
+ btn: 'Test Warnung senden',
+ },
+ },
weatherDisplay: {
_title: 'Wetter-Moodle',
_description: `Um gute Wetterdaten zu erhalten, benötigst du bei einigen Anbietern einen API-Key.
@@ -761,6 +884,74 @@ Better-Moodle funktioniert bei allen angebotenen Anbiertern mit den jeweiligen k
myCoursesLink: 'My courses',
},
},
+ nina: {
+ activeWarnings: 'Current warnings',
+ bbkLink:
+ 'More information on the website of the Federal Office for Civil Protection and Disaster Assistance',
+ categories: 'Categories',
+ category: {
+ geo: 'Geophysical (inc. landslide)',
+ met: 'Meteorological (inc. flood)',
+ safety: 'General emergency and public safety',
+ security:
+ 'Law enforcement, military, homeland and local/private security',
+ rescue: 'Rescue and recovery',
+ fire: 'Fire suppression and rescue',
+ health: 'Medical and public health',
+ env: 'Pollution and other environmental threats',
+ transport: 'Public and private transportation',
+ infra: 'Utility, telecommunication, other non-transport infrastructure',
+ cbrne: 'Chemical, Biological, Radiological, Nuclear or High-Yield Explosive threat or attack',
+ other: 'Other events',
+ },
+ close: 'Close',
+ description: 'Description',
+ instruction: 'Instruction',
+ notFound: {
+ title: 'MESSAGE NO LONGER AVAILABLE',
+ description:
+ 'The warning message you have called up no longer exists. There may be an all-clear for this message.',
+ },
+ panic: 'PANIC!!!!',
+ providedBy: 'Provided by',
+ severity: {
+ name: 'Severity',
+ 3: 'Extreme',
+ 2: 'Severe',
+ 1: 'Moderate',
+ 0: 'Minor',
+ [-1]: 'Unknown',
+ },
+ severityWeather: {
+ name: 'Severity',
+ 3: 'Extreme storm',
+ 2: 'Storm',
+ 1: 'Significant weather',
+ 0: 'Minor',
+ [-1]: 'Unknown',
+ },
+ showMore: 'Show more',
+ status: {
+ name: 'Status',
+ actual: 'Actual',
+ exercise: 'Exercise',
+ system: 'System',
+ test: 'Test',
+ draft: 'Draft',
+ },
+ testWarning: {
+ title: 'WARNING: Cute mammoth spotted!',
+ description:
+ 'A cute mammoth has been spotted near the university. Be vigilant!',
+ instruction:
+ 'If you see the mammoth, please pet it and give it a cookie.',
+ event: 'Cute mammoth sighting',
+ web: 'https://moothel.pet/',
+ provider: 'Better-Moodle',
+ senderName: 'Moothel',
+ },
+ via: 'via',
+ },
speiseplan: {
title: 'Menu of the canteen "{{canteen}}"',
close: 'Close',
@@ -1320,6 +1511,62 @@ Best regards
},
},
},
+ nina: {
+ _title: 'NINA Warnings',
+ _description:
+ '
Find out more about warnings by clicking on the notifications.
Note: This feature might not always work in english as the BBK often only provides warnings in german.
',
+ enabled: {
+ name: 'Enable NINA warnings',
+ description:
+ '⚠️ Displays warnings from the NINA app in the navigation bar.',
+ },
+
+ civilProtectionWarnings: {
+ name: 'Civil protection warnings',
+ description:
+ 'Here you can set the warning lebel from which you want to receive a notification.',
+ labels: {
+ off: 'Never',
+ extreme: 'Extreme danger',
+ severe: 'Danger',
+ moderate: 'Hazard information',
+ },
+ },
+ weatherWarnings: {
+ name: 'Weather warnings',
+ description:
+ 'Here you can set the Deutscher Wetterdienst warning level from which you want to receive a notification.',
+ labels: {
+ off: 'Never',
+ extreme: 'Extreme storm',
+ severe: 'Storm',
+ moderate: 'Severe weather',
+ },
+ },
+
+ floodWarnings: {
+ name: 'Flood warnings',
+ description:
+ 'Notifications about flooding events on inland waters (rivers, canals, inland lakes).',
+ },
+
+ notification: {
+ name: 'Push notifications for NINA warnings',
+ description:
+ 'Displays push notifications for NINA warnings.',
+ },
+ megaAlarm: {
+ name: 'Allow Mega-Alarm mode',
+ description:
+ '🚨🚨🚨 YOU WILL NEVER MISS IMPORTANT WARNINGS AGAIN!!! 🚨🚨🚨',
+ },
+ test: {
+ name: 'Test the NINA warnings',
+ description:
+ 'Send a test warning to see what the notifications look like.',
+ btn: 'Send test warning',
+ },
+ },
weatherDisplay: {
_title: 'Weather-Moodle',
_description: `To get good weather data, you need an API key for some providers.
@@ -3320,6 +3567,61 @@ const SETTINGS = [
'shiftEnter',
'ctrlEnter',
]),
+ 'nina',
+ $t('settings.nina._description'),
+ new BooleanSetting('nina.enabled', true),
+ new SliderSetting('nina.civilProtectionWarnings', 2, 0, 3, 1, [
+ 'off',
+ 'extreme',
+ 'severe',
+ 'moderate',
+ ]).setDisabledFn(settings => !settings['nina.enabled'].inputValue),
+ new SliderSetting('nina.weatherWarnings', 2, 0, 3, 1, [
+ 'off',
+ 'extreme',
+ 'severe',
+ 'moderate',
+ ]).setDisabledFn(settings => !settings['nina.enabled'].inputValue),
+ new BooleanSetting('nina.floodWarnings', false).setDisabledFn(
+ settings => !settings['nina.enabled'].inputValue
+ ),
+ new BooleanSetting('nina.notification', true).setDisabledFn(
+ settings => !settings['nina.enabled'].inputValue
+ ),
+ new BooleanSetting('nina.megaAlarm', false).setDisabledFn(
+ settings => !settings['nina.enabled'].inputValue
+ ),
+ new BtnActionSetting('nina.test')
+ .setContent($t('settings.nina.test.btn'))
+ .setDisabledFn(settings => !settings['nina.enabled'].inputValue)
+ .setAction(() => {
+ const betterMoodleTestWarning = `better-moodle:${Math.ceil(Math.random() * 1000)}`;
+ NINA.addWarning(
+ betterMoodleTestWarning,
+ {
+ title: $t('nina.testWarning.title'),
+ description: $t('nina.testWarning.description'),
+ instruction: $t('nina.testWarning.instruction'),
+ categories: [CommonAlertingProtocol.Category.OTHER],
+ severity: CommonAlertingProtocol.Severity.EXTREME,
+ urgency: CommonAlertingProtocol.Urgency.IMMEDIATE,
+ certainty: CommonAlertingProtocol.Certainty.OBSERVED,
+ event: $t('nina.testWarning.event'),
+ web: $t('nina.testWarning.web'),
+ msgType: CommonAlertingProtocol.MsgType.ALERT,
+ provider: $t('nina.testWarning.provider'),
+ senderName: $t('nina.testWarning.senderName'),
+ valid: true,
+ sent: new Date(),
+ effective: new Date(),
+ onset: new Date(),
+ expires: new Date(Date.now() + 1000 * 30),
+ status: CommonAlertingProtocol.Status.TEST,
+ },
+ Date.now() + 1000 * 30
+ );
+ NINA.pushWarning(betterMoodleTestWarning);
+ }),
'speiseplan',
new SelectSetting('speiseplan.canteen', '1', ['1', '2', '3']),
new SelectSetting('speiseplan.language', 'auto', [
@@ -3327,6 +3629,7 @@ const SETTINGS = [
...Object.keys(TRANSLATIONS),
]),
];
+
const settingsById = Object.fromEntries(
SETTINGS.filter(s => typeof s !== 'string').map(s => [s.id, s])
);
@@ -7521,6 +7824,694 @@ if (messagesSendHotkey) {
}
// endregion
+// region Feature: NINA integration
+const alarmBtnWrapperId = PREFIX('alarm-button');
+const alarmBackgroundClass = PREFIX('modal-backdrop-alarming');
+const keepUntilVar = PREFIX('keepUntil');
+const seenVar = PREFIX('seen');
+const unseenClass = PREFIX('unseen');
+const warningId = PREFIX('warning');
+
+GM_addStyle(css`
+ .${alarmBackgroundClass} {
+ background-color: rgba(255, 0, 0, 0.5);
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 1040;
+ pointer-events: none;
+ }
+
+ @media (prefers-reduced-motion: no-preference) {
+ .${alarmBackgroundClass} {
+ animation: blink 1s infinite;
+ }
+ }
+
+ @keyframes blink {
+ 0% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+ }
+
+ .${unseenClass}::before {
+ content: '';
+ position: absolute;
+ left: -8px;
+ top: 0;
+ bottom: 0;
+ border-left: var(--primary) 3px solid;
+ }
+`);
+// Common Alerting Protocol
+const CommonAlertingProtocol = {
+ Severity: {
+ MINOR: 0,
+ MODERATE: 1,
+ SEVERE: 2,
+ EXTREME: 3,
+ UNKNOWN: -1,
+ },
+ Urgency: {
+ IMMEDIATE: 3,
+ EXPECTED: 2,
+ FUTURE: 1,
+ PAST: 0,
+ UNKNOWN: -1,
+ },
+ Certainty: {
+ OBSERVED: 3,
+ LIKELY: 2,
+ POSSIBLE: 1,
+ UNLIKELY: 0,
+ UNKNOWN: -1,
+ },
+ MsgType: {
+ ACK: 'ack',
+ ALERT: 'alert',
+ CANCEL: 'cancel',
+ ERROR: 'error',
+ UPDATE: 'update',
+ },
+ Category: {
+ GEO: 'geo',
+ MET: 'met',
+ SAFETY: 'safety',
+ SECURITY: 'security',
+ RESCUE: 'rescue',
+ FIRE: 'fire',
+ HEALTH: 'health',
+ ENV: 'env',
+ TRANSPORT: 'transport',
+ INFRA: 'infra',
+ CBRNE: 'cbrne',
+ OTHER: 'other',
+ },
+ Status: {
+ ACTUAL: 'actual',
+ EXERCISE: 'exercise',
+ SYSTEM: 'system',
+ TEST: 'test',
+ DRAFT: 'draft',
+ },
+};
+
+const severityEmojis = {
+ [CommonAlertingProtocol.Severity.EXTREME]: '🟣',
+ [CommonAlertingProtocol.Severity.SEVERE]: '🔴',
+ [CommonAlertingProtocol.Severity.MODERATE]: '🟠',
+ [CommonAlertingProtocol.Severity.MINOR]: '⚪',
+ [CommonAlertingProtocol.Severity.UNKNOWN]: '⚪',
+};
+
+const NINA = {
+ defaultValues: {
+ title: $t('nina.notFound.title'),
+ description: $t('nina.notFound.description'),
+ instruction: undefined,
+ categories: [],
+ severity: CommonAlertingProtocol.Severity.UNKNOWN,
+ urgency: CommonAlertingProtocol.Urgency.UNKNOWN,
+ certainty: CommonAlertingProtocol.Certainty.UNKNOWN,
+ event: undefined,
+ web: undefined,
+ msgType: CommonAlertingProtocol.MsgType.ERROR,
+ provider: undefined,
+ senderName: undefined,
+ valid: false,
+ sent: new Date(),
+ effective: new Date(),
+ onset: undefined,
+ expires: undefined,
+ status: CommonAlertingProtocol.Status.SYSTEM,
+ },
+ /**
+ * Returns the warning with the given ID.
+ *
+ * @param {*} str
+ * @returns
+ */
+ settingPrefix: str => `${PREFIX('nina')}.${str}`,
+ /**
+ * Sends a push notification for a warning (if the settings allow for that).
+ *
+ * @param {string} id The ID of the warning
+ * @param {object} options Additional options
+ */
+ pushWarning: (id, options = {}) => {
+ const {
+ title,
+ description,
+ severity,
+ provider,
+ expires,
+ valid,
+ status,
+ } = NINA.getWarning(id) ?? NINA.defaultValues;
+
+ if (
+ (expires && new Date(expires) < new Date()) ||
+ !valid ||
+ status === CommonAlertingProtocol.Status.DRAFT
+ ) {
+ return;
+ }
+
+ const {
+ // Options
+ image,
+ } = Object.assign({}, options, {
+ image: 'https://www.bbk.bund.de/SiteGlobals/Frontend/Images/favicons/android-chrome-256x256.png?__blob=normal&v=1',
+ });
+
+ if (
+ (getSetting('nina.notification') &&
+ provider !== 'DWD' &&
+ provider !== 'LHP' &&
+ getSetting('nina.civilProtectionWarnings') -
+ severity +
+ CommonAlertingProtocol.Severity.EXTREME >
+ 0) ||
+ (provider === 'DWD' &&
+ severity -
+ getSetting('nina.weatherWarnings') +
+ CommonAlertingProtocol.Severity.EXTREME >
+ 0) ||
+ (provider === 'LHP' && getSetting('nina.floodWarnings'))
+ ) {
+ GM_notification({
+ title,
+ text: description,
+ image,
+ onclick: () => NINA.showWarning(id),
+ });
+ }
+ },
+ /**
+ * Shows detailed information about a warning in a modal.
+ *
+ * @param {string} id The ID of the warning
+ */
+ showWarning: id => {
+ const {
+ title,
+ description,
+ instruction,
+ categories,
+ severity,
+ web,
+ provider,
+ senderName,
+ onset,
+ expires,
+ status,
+ } = NINA.getWarning(id) ?? NINA.defaultValues;
+
+ const severityEmoji =
+ severityEmojis[severity ?? CommonAlertingProtocol.Severity.UNKNOWN];
+
+ const modalTitle = `${severityEmoji} ${
+ title
+ } ${$t(
+ `nina.status.${status}`
+ )}`;
+
+ let modalBody = '';
+ if (onset && expires) {
+ modalBody += `${onset.toLocaleString(
+ BETTER_MOODLE_LANG
+ )} - ${expires.toLocaleString(BETTER_MOODLE_LANG)}
`;
+ }
+ if (description) {
+ modalBody += `${$t('nina.description')}
${description}
`;
+ }
+ if (instruction) {
+ modalBody += `${$t('nina.instruction')}
${instruction}
`;
+ }
+ if (categories.length) {
+ modalBody += `${$t('nina.categories')}: ${categories
+ .map(
+ category =>
+ `${$t(`nina.category.${category}`)}`
+ )
+ .join(', ')}
`;
+ }
+
+ let modalFooter = '';
+ if (web) {
+ modalFooter += ``;
+ }
+ modalFooter += '';
+ if (senderName || provider) {
+ modalFooter += `
${$t('nina.providedBy')} ${
+ senderName ?
+ `${senderName} ${$t('nina.via')} ${provider}`
+ : provider
+ }`;
+ }
+ modalFooter += `
${$t(
+ `nina.bbkLink`
+ )} `;
+
+ require(['core/modal_factory', 'core/modal_events'], (
+ { create, types },
+ ModalEvents
+ ) =>
+ create({
+ type: types.ALERT,
+ large: true,
+ scrollable: true,
+ title: modalTitle,
+ body: modalBody,
+ })
+ .then(modal => {
+ modal.setButtonText(
+ 'cancel',
+ NINA.inMegaAlarm() ? $t('nina.panic') : $t('nina.close')
+ );
+ const source = document.createElement('span');
+ source.classList.add(
+ 'd-flex',
+ 'align-items-center',
+ 'text-muted',
+ 'mr-auto'
+ );
+ source.innerHTML = modalFooter;
+ modal.getFooter().prepend(source);
+
+ modal
+ .getRoot()
+ .on(ModalEvents.hidden, () => NINA.markAsSeen(id));
+
+ return modal;
+ })
+ .then(modal => modal.show()));
+ },
+ /**
+ * Shows the alarm button if there are active warnings.
+ */
+ showAlarmButton: () => {
+ const alarmButtonWrapper = document.getElementById(alarmBtnWrapperId);
+ const alarmBackground = document.querySelector(
+ `.${alarmBackgroundClass}`
+ );
+ if (NINA.hasActiveWarnings()) {
+ alarmButtonWrapper?.classList.remove('d-none');
+ } else {
+ alarmButtonWrapper?.classList.add('d-none');
+ }
+ const alarmButton = alarmButtonWrapper?.querySelector('a');
+ if (NINA.inMegaAlarm()) {
+ if (alarmButton) {
+ alarmButton.innerText = '🚨';
+ }
+ alarmBackground?.classList.remove('d-none');
+ } else {
+ if (alarmButton) {
+ alarmButton.innerText = '⚠️';
+ }
+ alarmBackground?.classList.add('d-none');
+ }
+ },
+ /**
+ * Returns the active warnings
+ *
+ * @returns {object} The active warnings
+ */
+ getActiveWarnings: () => {
+ const dateTimeKeys = ['sent', 'effective', 'onset', 'expires'];
+
+ const activeWarnings = GM_getValue(
+ NINA.settingPrefix('activeWarnings'),
+ {}
+ );
+ Object.keys(activeWarnings).forEach(id => {
+ activeWarnings[id] = JSON.parse(activeWarnings[id]);
+ dateTimeKeys.forEach(
+ key =>
+ (activeWarnings[id][key] =
+ activeWarnings[id][key] ?
+ new Date(activeWarnings[id][key])
+ : undefined)
+ );
+ });
+ return activeWarnings;
+ },
+ /**
+ * Saves the active warnings
+ *
+ * @param {object} activeWarnings The active warnings to save
+ */
+ saveActiveWarnings: activeWarnings => {
+ Object.keys(activeWarnings).forEach(
+ id => (activeWarnings[id] = JSON.stringify(activeWarnings[id]))
+ );
+ GM_setValue(NINA.settingPrefix('activeWarnings'), activeWarnings);
+ NINA.showAlarmButton();
+ },
+ /**
+ * Returns the warning with the given ID
+ *
+ * @param {string} id The ID of the warning
+ * @returns {object} The warning with the given ID
+ */
+ getWarning: id => NINA.getActiveWarnings()[id],
+ /**
+ * Adds a warning to the active warnings
+ *
+ * @param {string} id The ID of the warning
+ * @param {object} warning The warning to add
+ * @param {int} keepUntil The timestamp until the warning should be kept (even if it is not in the response anymore)
+ */
+ addWarning: (id, warning, keepUntil = 0) => {
+ const activeWarnings = NINA.getActiveWarnings();
+ activeWarnings[id] = warning;
+ activeWarnings[id][keepUntilVar] = keepUntil;
+ activeWarnings[id][seenVar] = false;
+ NINA.saveActiveWarnings(activeWarnings);
+ },
+ /**
+ * Removes a warning from the active warnings
+ *
+ * @param {string} id The ID of the warning to remove
+ */
+ removeWarning: id => {
+ const activeWarnings = NINA.getActiveWarnings();
+ delete activeWarnings[id];
+ NINA.saveActiveWarnings(activeWarnings);
+ },
+ /**
+ * Removes all warnings (except those, that are marked with a keepUntil timestamp in the future)
+ */
+ clearWarnings: () => {
+ const activeWarnings = NINA.getActiveWarnings();
+ Object.keys(activeWarnings)
+ .filter(id => activeWarnings[id][keepUntilVar] < Date.now())
+ .forEach(id => delete activeWarnings[id]);
+ NINA.saveActiveWarnings(activeWarnings);
+ },
+ /**
+ * Marks a warning as seen
+ *
+ * @param {string} id The ID of the warning to mark as seen
+ */
+ markAsSeen: id => {
+ document
+ .querySelector(`.${unseenClass}:has([data-${warningId}="${id}"])`)
+ ?.classList.remove(unseenClass);
+
+ const activeWarnings = NINA.getActiveWarnings();
+ if (!activeWarnings[id]) return;
+ activeWarnings[id][seenVar] = true;
+ NINA.saveActiveWarnings(activeWarnings);
+ },
+ /**
+ * Checks if there are active warnings
+ *
+ * @returns {boolean} Whether there are active warnings
+ */
+ hasActiveWarnings: () => {
+ return Object.keys(NINA.getActiveWarnings()).length > 0;
+ },
+ /**
+ * Checks if there are unseen warnings
+ *
+ * @returns {boolean} Whether there are unseen warnings
+ */
+ hasUnseenWarnings: () => {
+ return Object.keys(NINA.getActiveWarnings()).some(
+ id => !NINA.getActiveWarnings()[id][seenVar]
+ );
+ },
+ /**
+ * Checks if there are active warnings that are in a mega alarm state
+ *
+ * @returns {boolean} Whether there are active warnings that are in a mega alarm state
+ */
+ inMegaAlarm: () =>
+ getSetting('nina.megaAlarm') &&
+ Object.values(NINA.getActiveWarnings()).some(
+ ({ provider, severity, [seenVar]: seen }) =>
+ !seen &&
+ ((getSetting('nina.notification') &&
+ provider !== 'DWD' &&
+ provider !== 'LHP' &&
+ getSetting('nina.civilProtectionWarnings') -
+ severity +
+ CommonAlertingProtocol.Severity.EXTREME >
+ 0) ||
+ (provider === 'DWD' &&
+ severity -
+ getSetting('nina.weatherWarnings') +
+ CommonAlertingProtocol.Severity.EXTREME >
+ 0) ||
+ (provider === 'LHP' && getSetting('nina.floodWarnings')))
+ ),
+};
+if (getSetting('nina.enabled')) {
+ const ONE_SECOND = 1000;
+ const THIRTY_SECONDS = 30 * ONE_SECOND;
+ const ARS = '010020000000'; // Kiel
+
+ const api = 'https://nina.api.proxy.bund.dev/api31';
+
+ /* Note: This is not a full replacement for fetch, but should be compatible to GM_fetch */
+ const GM_fetch = url =>
+ new Promise((resolve, reject) => {
+ GM_xmlhttpRequest({
+ method: 'GET',
+ url,
+ onload: ({ status, responseText }) => {
+ resolve({
+ status,
+ ok: status >= 200 && status < 300,
+ json: () => JSON.parse(responseText),
+ text: () => responseText,
+ });
+ },
+ onerror: () => reject(new TypeError('Network request failed')),
+ });
+ });
+
+ const checkForWarnings = () => {
+ // Handle multiple tabs
+ const ninaLastUpdate = GM_getValue('nina.lastUpdate', 0);
+ if (Date.now() - ninaLastUpdate < THIRTY_SECONDS) return;
+ GM_setValue(NINA.settingPrefix('lastUpdate'), Date.now());
+
+ const activeWarnings = NINA.getActiveWarnings();
+ NINA.clearWarnings();
+
+ GM_fetch(`${api}/dashboard/${ARS}.json`)
+ .then(resp => resp.json())
+ .then(data =>
+ data.forEach(({ id, payload, sent }) => {
+ if (
+ activeWarnings[id] &&
+ CommonAlertingProtocol.Severity[
+ payload.data.severity.toUpperCase()
+ ] === activeWarnings[id].severity &&
+ CommonAlertingProtocol.Urgency[
+ payload.data.urgency.toUpperCase()
+ ] === activeWarnings[id].urgency &&
+ CommonAlertingProtocol.MsgType[
+ payload.data.msgType.toUpperCase()
+ ] === activeWarnings[id].msgType &&
+ payload.data.valid === activeWarnings[id].valid
+ ) {
+ NINA.addWarning(id, activeWarnings[id]);
+ return;
+ }
+
+ GM_fetch(`${api}/warnings/${id}.json`)
+ .then(resp => resp.json())
+ .then(({ status, msgType, info }) => {
+ const langInfo = Object.assign(
+ {},
+ info.find(
+ ({ language }) =>
+ language === BETTER_MOODLE_LANG
+ ),
+ info[0]
+ );
+
+ const warnData = {
+ title: langInfo.headline,
+ description: langInfo.description,
+ instruction: langInfo.instruction,
+ categories: langInfo.category.map(
+ cat =>
+ CommonAlertingProtocol.Category[
+ cat.toUpperCase()
+ ]
+ ),
+ severity:
+ CommonAlertingProtocol.Severity[
+ langInfo.severity.toUpperCase()
+ ],
+ urgency:
+ CommonAlertingProtocol.Urgency[
+ langInfo.urgency.toUpperCase()
+ ],
+ certainty:
+ CommonAlertingProtocol.Certainty[
+ langInfo.certainty.toUpperCase()
+ ],
+ event: langInfo.event,
+ web: langInfo.web,
+ msgType:
+ CommonAlertingProtocol.MsgType[
+ msgType.toUpperCase()
+ ],
+ provider: payload.data.provider,
+ senderName: langInfo.senderName,
+ valid: payload.data.valid,
+ sent,
+ effective: new Date(langInfo.effective),
+ onset: new Date(langInfo.onset),
+ expires: new Date(langInfo.expires),
+ status: CommonAlertingProtocol.Status[
+ status.toUpperCase()
+ ],
+ };
+ NINA.addWarning(id, warnData);
+ NINA.pushWarning(id);
+ });
+ })
+ );
+ };
+ checkForWarnings();
+ setInterval(checkForWarnings, THIRTY_SECONDS);
+
+ const alarmBtnWrapper = document.createElement('div');
+ alarmBtnWrapper.id = alarmBtnWrapperId;
+ const alarmBtn = document.createElement('a');
+ alarmBtn.innerText = '⚠️';
+ alarmBtn.title = $t('nina.activeWarnings');
+ alarmBtn.classList.add('nav-link', 'position-relative');
+ alarmBtn.href = '#';
+ alarmBtn.role = 'button';
+ alarmBtn.addEventListener('click', () => {
+ require(['core/modal_factory', 'core/modal_events'], (
+ { create, types },
+ ModalEvents
+ ) =>
+ create({
+ type: types.ALERT,
+ large: true,
+ scrollable: true,
+ title: $t('nina.activeWarnings'),
+ body: '',
+ })
+ .then(modal => {
+ modal.setButtonText('cancel', $t('nina.close'));
+ modal.getBody()[0].addEventListener('click', e => {
+ const target = e.target.closest(`a[data-${warningId}]`);
+ if (!target) return;
+ e.preventDefault();
+ NINA.showWarning(
+ target.getAttribute(`data-${warningId}`)
+ );
+ });
+
+ modal
+ .getRoot()
+ .on(ModalEvents.hidden, () =>
+ Object.keys(NINA.getActiveWarnings()).forEach(
+ NINA.markAsSeen
+ )
+ );
+
+ modal.getRoot().on(
+ ModalEvents.shown,
+ () =>
+ (modal.getBody()[0].innerHTML = Object.keys(
+ NINA.getActiveWarnings()
+ )
+ .filter(
+ id => NINA.getActiveWarnings()[id].valid
+ )
+ .sort(
+ id =>
+ 100 *
+ !NINA.getActiveWarnings()[id].seen -
+ NINA.getActiveWarnings()[id].severity
+ )
+ .map(id => {
+ const {
+ title,
+ description,
+ severity,
+ status,
+ provider,
+ [seenVar]: seen,
+ } = NINA.getWarning(id);
+ const severityEmoji =
+ severityEmojis[
+ severity ??
+ CommonAlertingProtocol.Severity
+ .UNKNOWN
+ ];
+
+ const warnTitle = `${severityEmoji} ${
+ title
+ } ${$t(
+ `nina.status.${status}`
+ )}`;
+ return `
+
${warnTitle ?? ''}
+
${description ?? ''}
+
+
`;
+ })
+ .join('
'))
+ );
+ return modal;
+ })
+ .then(modal => modal.show()));
+ });
+ alarmBtnWrapper.append(alarmBtn);
+
+ const alarmBackground = document.createElement('div');
+ alarmBackground.classList.add(alarmBackgroundClass, 'd-none');
+
+ ready(() => {
+ document
+ .querySelector('#usernavigation .usermenu-container')
+ ?.before(alarmBtnWrapper);
+
+ if (getSetting('nina.megaAlarm')) {
+ document.body.append(alarmBackground);
+ }
+ NINA.showAlarmButton();
+ });
+}
+// endregion
+
// region Settings modal
// A settings modal
ready(() => {