";
+ this.ntf.innerHTML = strinner;
+
+ // append to body or the element specified in options.wrapper
+ this.options.wrapper.insertBefore(this.ntf, this.options.wrapper.nextSibling);
+
+ // dismiss after [options.ttl]ms
+ if (this.options.ttl) {
+ this.dismissttl = setTimeout(() => {
+ if (this.active) {
+ this.dismiss();
+ }
+ }, this.options.ttl);
+ }
+
+ // init events
+ this._initEvents();
+ };
+
+ /**
+ * Init events
+ */
+ NotificationFx.prototype._initEvents = function () {
+ // dismiss notification by tapping on it if someone has a touchscreen
+ this.ntf.querySelector(".ns-box-inner").addEventListener("click", () => {
+ this.dismiss();
+ });
+ };
+
+ /**
+ * Show the notification
+ */
+ NotificationFx.prototype.show = function () {
+ this.active = true;
+ this.ntf.classList.remove("ns-hide");
+ this.ntf.classList.add("ns-show");
+ this.options.onOpen();
+ };
+
+ /**
+ * Dismiss the notification
+ * @param {boolean} [close] call the onClose callback at the end
+ */
+ NotificationFx.prototype.dismiss = function (close = true) {
+ this.active = false;
+ clearTimeout(this.dismissttl);
+ this.ntf.classList.remove("ns-show");
+ setTimeout(() => {
+ this.ntf.classList.add("ns-hide");
+
+ // callback
+ if (close) this.options.onClose();
+ }, 25);
+
+ // after animation ends remove ntf from the DOM
+ const onEndAnimationFn = (ev) => {
+ if (ev.target !== this.ntf) {
+ return false;
+ }
+ this.ntf.removeEventListener("animationend", onEndAnimationFn);
+
+ if (ev.target.parentNode === this.options.wrapper) {
+ this.options.wrapper.removeChild(this.ntf);
+ }
+ };
+
+ this.ntf.addEventListener("animationend", onEndAnimationFn);
+ };
+
+ /**
+ * Add to global namespace
+ */
+ window.NotificationFx = NotificationFx;
+})(window);
diff --git a/mm/mounts/modules/default/alert/styles/center.css b/mm/mounts/modules/default/alert/styles/center.css
new file mode 100644
index 0000000..4e8f5e1
--- /dev/null
+++ b/mm/mounts/modules/default/alert/styles/center.css
@@ -0,0 +1,5 @@
+.ns-box {
+ margin-left: auto;
+ margin-right: auto;
+ text-align: center;
+}
diff --git a/mm/mounts/modules/default/alert/styles/left.css b/mm/mounts/modules/default/alert/styles/left.css
new file mode 100644
index 0000000..86d2746
--- /dev/null
+++ b/mm/mounts/modules/default/alert/styles/left.css
@@ -0,0 +1,4 @@
+.ns-box {
+ margin-right: auto;
+ text-align: left;
+}
diff --git a/mm/mounts/modules/default/alert/styles/notificationFx.css b/mm/mounts/modules/default/alert/styles/notificationFx.css
new file mode 100644
index 0000000..df34075
--- /dev/null
+++ b/mm/mounts/modules/default/alert/styles/notificationFx.css
@@ -0,0 +1,929 @@
+/* Based on work by https://tympanus.net/codrops/licensing/ */
+
+.ns-box {
+ background-color: rgb(0 0 0 / 93%);
+ padding: 17px;
+ line-height: 1.4;
+ margin-bottom: 10px;
+ z-index: 1;
+ font-size: 70%;
+ position: relative;
+ display: table;
+ word-wrap: break-word;
+ max-width: 100%;
+ border-width: 1px;
+ border-radius: 5px;
+ border-style: solid;
+ border-color: var(--color-text-dimmed);
+}
+
+.ns-alert {
+ border-style: solid;
+ border-color: var(--color-text-bright);
+ padding: 17px;
+ line-height: 1.4;
+ margin-bottom: 10px;
+ z-index: 3;
+ color: var(--color-text-bright);
+ font-size: 70%;
+ position: fixed;
+ text-align: center;
+ right: 0;
+ left: 0;
+ margin-right: auto;
+ margin-left: auto;
+ top: 40%;
+ width: 40%;
+ height: auto;
+ word-wrap: break-word;
+ border-radius: 20px;
+}
+
+.alert-blur {
+ filter: blur(2px) brightness(50%);
+}
+
+[class^="ns-effect-"].ns-growl.ns-hide,
+[class*=" ns-effect-"].ns-growl.ns-hide {
+ animation-direction: reverse;
+}
+
+.ns-effect-flip {
+ transform-origin: 50% 100%;
+ backface-visibility: hidden;
+}
+
+.ns-effect-flip.ns-show,
+.ns-effect-flip.ns-hide {
+ animation-name: anim-flip-front;
+ animation-duration: 0.3s;
+}
+
+.ns-effect-flip.ns-hide {
+ animation-name: anim-flip-back;
+}
+
+@keyframes anim-flip-front {
+ 0% {
+ transform: perspective(1000px) rotate3d(1, 0, 0, -90deg);
+ }
+
+ 100% {
+ transform: perspective(1000px);
+ }
+}
+
+@keyframes anim-flip-back {
+ 0% {
+ transform: perspective(1000px) rotate3d(1, 0, 0, 90deg);
+ }
+
+ 100% {
+ transform: perspective(1000px);
+ }
+}
+
+.ns-effect-bouncyflip.ns-show,
+.ns-effect-bouncyflip.ns-hide {
+ animation-name: flip-in-x;
+ animation-duration: 0.8s;
+}
+
+@keyframes flip-in-x {
+ 0% {
+ transform: perspective(400px) rotate3d(1, 0, 0, -90deg);
+ transition-timing-function: ease-in;
+ }
+
+ 40% {
+ transform: perspective(400px) rotate3d(1, 0, 0, 20deg);
+ transition-timing-function: ease-out;
+ }
+
+ 60% {
+ transform: perspective(400px) rotate3d(1, 0, 0, -10deg);
+ transition-timing-function: ease-in;
+ opacity: 1;
+ }
+
+ 80% {
+ transform: perspective(400px) rotate3d(1, 0, 0, 5deg);
+ transition-timing-function: ease-out;
+ }
+
+ 100% {
+ transform: perspective(400px);
+ }
+}
+
+.ns-effect-bouncyflip.ns-hide {
+ animation-name: flip-in-x-simple;
+ animation-duration: 0.3s;
+}
+
+@keyframes flip-in-x-simple {
+ 0% {
+ transform: perspective(400px) rotate3d(1, 0, 0, -90deg);
+ transition-timing-function: ease-in;
+ }
+
+ 100% {
+ transform: perspective(400px);
+ }
+}
+
+.ns-effect-exploader {
+ transform-origin: 0 0;
+}
+
+.ns-effect-exploader p {
+ padding: 0.25em 2em 0.25em 3em;
+}
+
+.ns-effect-exploader.ns-show {
+ animation-name: anim-load;
+ animation-duration: 1s;
+}
+
+@keyframes anim-load {
+ 0% {
+ opacity: 1;
+ transform: scale3d(0, 0.3, 1);
+ }
+
+ 100% {
+ opacity: 1;
+ transform: scale3d(1, 1, 1);
+ }
+}
+
+.ns-effect-exploader.ns-hide {
+ animation-name: anim-fade;
+ animation-duration: 0.3s;
+}
+
+.ns-effect-exploader.ns-show .ns-box-inner,
+.ns-effect-exploader.ns-show .ns-close {
+ animation-fill-mode: both;
+ animation-duration: 0.3s;
+ animation-delay: 0.6s;
+}
+
+.ns-effect-exploader.ns-show .ns-close {
+ animation-name: anim-fade;
+}
+
+.ns-effect-exploader.ns-show .ns-box-inner {
+ animation-name: anim-fade-move;
+ animation-timing-function: ease-out;
+}
+
+@keyframes anim-fade-move {
+ 0% {
+ opacity: 0;
+ transform: translate3d(0, 10px, 0);
+ }
+
+ 100% {
+ opacity: 1;
+ transform: translate3d(0, 0, 0);
+ }
+}
+
+@keyframes anim-fade {
+ 0% {
+ opacity: 0;
+ }
+
+ 100% {
+ opacity: 1;
+ }
+}
+
+.ns-effect-scale.ns-show,
+.ns-effect-scale.ns-hide {
+ animation-name: anim-scale;
+ animation-duration: 0.25s;
+}
+
+@keyframes anim-scale {
+ 0% {
+ opacity: 0;
+ transform: translate3d(0, 40px, 0) scale3d(0.1, 0.6, 1);
+ }
+
+ 100% {
+ opacity: 1;
+ transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
+ }
+}
+
+.ns-effect-jelly.ns-show {
+ animation-name: anim-jelly;
+ animation-duration: 1s;
+ animation-timing-function: linear;
+}
+
+.ns-effect-jelly.ns-hide {
+ animation-name: anim-fade;
+ animation-duration: 0.3s;
+}
+
+@keyframes anim-fade {
+ 0% {
+ opacity: 0;
+ }
+
+ 100% {
+ opacity: 1;
+ }
+}
+
+@keyframes anim-jelly {
+ 0% {
+ transform: matrix3d(0.7, 0, 0, 0, 0, 0.7, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 2.083333% {
+ transform: matrix3d(0.7527, 0, 0, 0, 0, 0.7634, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 4.166667% {
+ transform: matrix3d(0.8107, 0, 0, 0, 0, 0.8454, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 6.25% {
+ transform: matrix3d(0.8681, 0, 0, 0, 0, 0.929, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 8.333333% {
+ transform: matrix3d(0.9204, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 10.416667% {
+ transform: matrix3d(0.9648, 0, 0, 0, 0, 1.052, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 12.5% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1.082, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 14.583333% {
+ transform: matrix3d(1.0256, 0, 0, 0, 0, 1.0915, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 16.666667% {
+ transform: matrix3d(1.0423, 0, 0, 0, 0, 1.0845, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 18.75% {
+ transform: matrix3d(1.051, 0, 0, 0, 0, 1.0667, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 20.833333% {
+ transform: matrix3d(1.0533, 0, 0, 0, 0, 1.0436, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 22.916667% {
+ transform: matrix3d(1.0508, 0, 0, 0, 0, 1.0201, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 25% {
+ transform: matrix3d(1.0449, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 27.083333% {
+ transform: matrix3d(1.037, 0, 0, 0, 0, 0.9853, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 29.166667% {
+ transform: matrix3d(1.0283, 0, 0, 0, 0, 0.9769, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 31.25% {
+ transform: matrix3d(1.0197, 0, 0, 0, 0, 0.9742, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 33.333333% {
+ transform: matrix3d(1.0119, 0, 0, 0, 0, 0.9762, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 35.416667% {
+ transform: matrix3d(1.0053, 0, 0, 0, 0, 0.9812, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 37.5% {
+ transform: matrix3d(1, 0, 0, 0, 0, 0.9877, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 39.583333% {
+ transform: matrix3d(0.9962, 0, 0, 0, 0, 0.9943, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 41.666667% {
+ transform: matrix3d(0.9937, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 43.75% {
+ transform: matrix3d(0.9924, 0, 0, 0, 0, 1.0041, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 45.833333% {
+ transform: matrix3d(0.992, 0, 0, 0, 0, 1.0065, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 47.916667% {
+ transform: matrix3d(0.9924, 0, 0, 0, 0, 1.0073, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 50% {
+ transform: matrix3d(0.9933, 0, 0, 0, 0, 1.0067, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 52.083333% {
+ transform: matrix3d(0.9945, 0, 0, 0, 0, 1.0053, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 54.166667% {
+ transform: matrix3d(0.9958, 0, 0, 0, 0, 1.0035, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 56.25% {
+ transform: matrix3d(0.997, 0, 0, 0, 0, 1.002, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 58.333333% {
+ transform: matrix3d(0.9982, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 60.416667% {
+ transform: matrix3d(0.9992, 0, 0, 0, 0, 0.9989, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 62.5% {
+ transform: matrix3d(1, 0, 0, 0, 0, 0.9982, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 64.583333% {
+ transform: matrix3d(1.0006, 0, 0, 0, 0, 0.998, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 66.666667% {
+ transform: matrix3d(1.001, 0, 0, 0, 0, 0.9981, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 68.75% {
+ transform: matrix3d(1.0011, 0, 0, 0, 0, 0.9985, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 70.833333% {
+ transform: matrix3d(1.0012, 0, 0, 0, 0, 0.999, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 72.916667% {
+ transform: matrix3d(1.0011, 0, 0, 0, 0, 0.9996, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 75% {
+ transform: matrix3d(1.001, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 77.083333% {
+ transform: matrix3d(1.0008, 0, 0, 0, 0, 1.0003, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 79.166667% {
+ transform: matrix3d(1.0006, 0, 0, 0, 0, 1.0005, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 81.25% {
+ transform: matrix3d(1.0004, 0, 0, 0, 0, 1.0006, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 83.333333% {
+ transform: matrix3d(1.0003, 0, 0, 0, 0, 1.0005, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 85.416667% {
+ transform: matrix3d(1.0001, 0, 0, 0, 0, 1.0004, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 87.5% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1.0003, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 89.583333% {
+ transform: matrix3d(0.9999, 0, 0, 0, 0, 1.0001, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 91.666667% {
+ transform: matrix3d(0.9999, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 93.75% {
+ transform: matrix3d(0.9998, 0, 0, 0, 0, 0.9999, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 95.833333% {
+ transform: matrix3d(0.9998, 0, 0, 0, 0, 0.9999, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 97.916667% {
+ transform: matrix3d(0.9998, 0, 0, 0, 0, 0.9998, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 100% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+}
+
+.ns-effect-slide-left.ns-show {
+ animation-name: anim-slide-elastic-left;
+ animation-duration: 1s;
+ animation-timing-function: linear;
+}
+
+@keyframes anim-slide-elastic-left {
+ 0% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -1000, 0, 0, 1);
+ }
+
+ 1.666667% {
+ transform: matrix3d(1.9293, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -739.2681, 0, 0, 1);
+ }
+
+ 3.333333% {
+ transform: matrix3d(1.9699, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -521.8255, 0, 0, 1);
+ }
+
+ 5% {
+ transform: matrix3d(1.709, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -349.2612, 0, 0, 1);
+ }
+
+ 6.666667% {
+ transform: matrix3d(1.424, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -218.324, 0, 0, 1);
+ }
+
+ 8.333333% {
+ transform: matrix3d(1.2107, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -123.2985, 0, 0, 1);
+ }
+
+ 10% {
+ transform: matrix3d(1.0817, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -57.5927, 0, 0, 1);
+ }
+
+ 11.666667% {
+ transform: matrix3d(1.017, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -14.7237, 0, 0, 1);
+ }
+
+ 13.333333% {
+ transform: matrix3d(0.9906, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 11.1279, 0, 0, 1);
+ }
+
+ 15% {
+ transform: matrix3d(0.9848, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 24.8634, 0, 0, 1);
+ }
+
+ 16.666667% {
+ transform: matrix3d(0.9872, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 30.405, 0, 0, 1);
+ }
+
+ 18.333333% {
+ transform: matrix3d(0.992, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 30.7528, 0, 0, 1);
+ }
+
+ 20% {
+ transform: matrix3d(0.9954, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 28.1014, 0, 0, 1);
+ }
+
+ 21.666667% {
+ transform: matrix3d(0.998, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 23.9827, 0, 0, 1);
+ }
+
+ 23.333333% {
+ transform: matrix3d(0.9994, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 19.4075, 0, 0, 1);
+ }
+
+ 25% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 14.9956, 0, 0, 1);
+ }
+
+ 26.666667% {
+ transform: matrix3d(1.0002, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 11.0858, 0, 0, 1);
+ }
+
+ 28.333333% {
+ transform: matrix3d(1.0002, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 7.8251, 0, 0, 1);
+ }
+
+ 30% {
+ transform: matrix3d(1.0002, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 5.2374, 0, 0, 1);
+ }
+
+ 31.666667% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 3.2739, 0, 0, 1);
+ }
+
+ 33.333333% {
+ transform: matrix3d(1.0001, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1.8489, 0, 0, 1);
+ }
+
+ 35% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.8636, 0, 0, 1);
+ }
+
+ 36.666667% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.2208, 0, 0, 1);
+ }
+
+ 38.333333% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.1669, 0, 0, 1);
+ }
+
+ 40% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.3728, 0, 0, 1);
+ }
+
+ 41.666667% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.4559, 0, 0, 1);
+ }
+
+ 43.333333% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.4612, 0, 0, 1);
+ }
+
+ 45% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.421, 0, 0, 1);
+ }
+
+ 46.666667% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.3596, 0, 0, 1);
+ }
+
+ 48.333333% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.291, 0, 0, 1);
+ }
+
+ 50% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.2249, 0, 0, 1);
+ }
+
+ 51.666667% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.1662, 0, 0, 1);
+ }
+
+ 53.333333% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.1173, 0, 0, 1);
+ }
+
+ 55% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0785, 0, 0, 1);
+ }
+
+ 56.666667% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0491, 0, 0, 1);
+ }
+
+ 58.333333% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0277, 0, 0, 1);
+ }
+
+ 60% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.013, 0, 0, 1);
+ }
+
+ 61.666667% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0033, 0, 0, 1);
+ }
+
+ 63.333333% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0025, 0, 0, 1);
+ }
+
+ 65% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0056, 0, 0, 1);
+ }
+
+ 66.666667% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0068, 0, 0, 1);
+ }
+
+ 68.333333% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0069, 0, 0, 1);
+ }
+
+ 70% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0063, 0, 0, 1);
+ }
+
+ 71.666667% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0054, 0, 0, 1);
+ }
+
+ 73.333333% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0044, 0, 0, 1);
+ }
+
+ 75% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0034, 0, 0, 1);
+ }
+
+ 76.666667% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0025, 0, 0, 1);
+ }
+
+ 78.333333% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0018, 0, 0, 1);
+ }
+
+ 80% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0012, 0, 0, 1);
+ }
+
+ 81.666667% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0007, 0, 0, 1);
+ }
+
+ 83.333333% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0004, 0, 0, 1);
+ }
+
+ 85% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0002, 0, 0, 1);
+ }
+
+ 86.666667% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.0001, 0, 0, 1);
+ }
+
+ 88.333333% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1);
+ }
+
+ 90% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1);
+ }
+
+ 91.666667% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1);
+ }
+
+ 93.333333% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1);
+ }
+
+ 95% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1);
+ }
+
+ 96.666667% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1);
+ }
+
+ 98.333333% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.0001, 0, 0, 1);
+ }
+
+ 100% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+}
+
+.ns-effect-slide-left.ns-hide {
+ animation-name: anim-slide-left;
+ animation-duration: 0.25s;
+}
+
+@keyframes anim-slide-left {
+ 0% {
+ transform: translate3d(-30px, 0, 0) translate3d(-100%, 0, 0);
+ }
+
+ 100% {
+ transform: translate3d(0, 0, 0);
+ }
+}
+
+.ns-effect-slide-right.ns-show {
+ animation: anim-slide-elastic-right 2000ms linear both;
+}
+
+@keyframes anim-slide-elastic-right {
+ 0% {
+ transform: matrix3d(2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1000, 0, 0, 1);
+ }
+
+ 2.15% {
+ transform: matrix3d(1.486, 0, 0, 0, 0, 0.514, 0, 0, 0, 0, 1, 0, 664.594, 0, 0, 1);
+ }
+
+ 4.1% {
+ transform: matrix3d(1.147, 0, 0, 0, 0, 0.853, 0, 0, 0, 0, 1, 0, 419.708, 0, 0, 1);
+ }
+
+ 4.3% {
+ transform: matrix3d(1.121, 0, 0, 0, 0, 0.879, 0, 0, 0, 0, 1, 0, 398.136, 0, 0, 1);
+ }
+
+ 6.46% {
+ transform: matrix3d(0.948, 0, 0, 0, 0, 1.052, 0, 0, 0, 0, 1, 0, 206.714, 0, 0, 1);
+ }
+
+ 8.11% {
+ transform: matrix3d(0.908, 0, 0, 0, 0, 1.092, 0, 0, 0, 0, 1, 0, 105.491, 0, 0, 1);
+ }
+
+ 8.61% {
+ transform: matrix3d(0.907, 0, 0, 0, 0, 1.093, 0, 0, 0, 0, 1, 0, 81.572, 0, 0, 1);
+ }
+
+ 12.11% {
+ transform: matrix3d(0.95, 0, 0, 0, 0, 1.05, 0, 0, 0, 0, 1, 0, -18.434, 0, 0, 1);
+ }
+
+ 14.16% {
+ transform: matrix3d(0.979, 0, 0, 0, 0, 1.021, 0, 0, 0, 0, 1, 0, -38.734, 0, 0, 1);
+ }
+
+ 16.12% {
+ transform: matrix3d(0.997, 0, 0, 0, 0, 1.003, 0, 0, 0, 0, 1, 0, -43.356, 0, 0, 1);
+ }
+
+ 19.72% {
+ transform: matrix3d(1.006, 0, 0, 0, 0, 0.994, 0, 0, 0, 0, 1, 0, -34.155, 0, 0, 1);
+ }
+
+ 27.23% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -7.839, 0, 0, 1);
+ }
+
+ 30.83% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -1.951, 0, 0, 1);
+ }
+
+ 38.34% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1.037, 0, 0, 1);
+ }
+
+ 41.99% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.812, 0, 0, 1);
+ }
+
+ 50% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.159, 0, 0, 1);
+ }
+
+ 60.56% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.025, 0, 0, 1);
+ }
+
+ 82.78% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.001, 0, 0, 1);
+ }
+
+ 100% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+}
+
+.ns-effect-slide-right.ns-hide {
+ animation-name: anim-slide-right;
+ animation-duration: 0.25s;
+}
+
+@keyframes anim-slide-right {
+ 0% {
+ transform: translate3d(30px, 0, 0) translate3d(100%, 0, 0);
+ }
+
+ 100% {
+ transform: translate3d(0, 0, 0);
+ }
+}
+
+.ns-effect-slide-center.ns-show {
+ animation: anim-slide-elastic-center 2000ms linear both;
+}
+
+@keyframes anim-slide-elastic-center {
+ 0% {
+ transform: matrix3d(1, 0, 0, 0, 0, 3, 0, 0, 0, 0, 1, 0, 0, -300, 0, 1);
+ }
+
+ 2.15% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1.971, 0, 0, 0, 0, 1, 0, 0, -199.378, 0, 1);
+ }
+
+ 4.1% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1.294, 0, 0, 0, 0, 1, 0, 0, -125.912, 0, 1);
+ }
+
+ 4.3% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1.243, 0, 0, 0, 0, 1, 0, 0, -119.441, 0, 1);
+ }
+
+ 6.46% {
+ transform: matrix3d(1, 0, 0, 0, 0, 0.895, 0, 0, 0, 0, 1, 0, 0, -62.014, 0, 1);
+ }
+
+ 8.11% {
+ transform: matrix3d(1, 0, 0, 0, 0, 0.817, 0, 0, 0, 0, 1, 0, 0, -31.647, 0, 1);
+ }
+
+ 8.61% {
+ transform: matrix3d(1, 0, 0, 0, 0, 0.813, 0, 0, 0, 0, 1, 0, 0, -24.472, 0, 1);
+ }
+
+ 12.11% {
+ transform: matrix3d(1, 0, 0, 0, 0, 0.9, 0, 0, 0, 0, 1, 0, 0, 5.53, 0, 1);
+ }
+
+ 14.16% {
+ transform: matrix3d(1, 0, 0, 0, 0, 0.959, 0, 0, 0, 0, 1, 0, 0, 11.62, 0, 1);
+ }
+
+ 16.12% {
+ transform: matrix3d(1, 0, 0, 0, 0, 0.994, 0, 0, 0, 0, 1, 0, 0, 13.007, 0, 1);
+ }
+
+ 19.72% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1.012, 0, 0, 0, 0, 1, 0, 0, 10.247, 0, 1);
+ }
+
+ 27.23% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 2.352, 0, 1);
+ }
+
+ 30.83% {
+ transform: matrix3d(1, 0, 0, 0, 0, 0.999, 0, 0, 0, 0, 1, 0, 0, 0.585, 0, 1);
+ }
+
+ 38.34% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, -0.311, 0, 1);
+ }
+
+ 41.99% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, -0.244, 0, 1);
+ }
+
+ 50% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, -0.048, 0, 1);
+ }
+
+ 60.56% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0.007, 0, 1);
+ }
+
+ 82.78% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+
+ 100% {
+ transform: matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1);
+ }
+}
+
+.ns-effect-slide-center.ns-hide {
+ animation-name: anim-slide-center;
+ animation-duration: 0.25s;
+}
+
+@keyframes anim-slide-center {
+ 0% {
+ transform: translate3d(0, -30px, 0) translate3d(0, -100%, 0);
+ }
+
+ 100% {
+ transform: translate3d(0, 0, 0);
+ }
+}
+
+.ns-effect-genie.ns-show,
+.ns-effect-genie.ns-hide {
+ animation-name: anim-genie;
+ animation-duration: 0.4s;
+}
+
+@keyframes anim-genie {
+ 0% {
+ opacity: 0;
+ transform: translate3d(0, calc(200% + 30px), 0) scale3d(0, 1, 1);
+ animation-timing-function: ease-in;
+ }
+
+ 40% {
+ opacity: 0.5;
+ transform: translate3d(0, 0, 0) scale3d(0.02, 1.1, 1);
+ animation-timing-function: ease-out;
+ }
+
+ 70% {
+ opacity: 0.6;
+ transform: translate3d(0, -40px, 0) scale3d(0.8, 1.1, 1);
+ }
+
+ 100% {
+ opacity: 1;
+ transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
+ }
+}
diff --git a/mm/mounts/modules/default/alert/styles/right.css b/mm/mounts/modules/default/alert/styles/right.css
new file mode 100644
index 0000000..add9b6f
--- /dev/null
+++ b/mm/mounts/modules/default/alert/styles/right.css
@@ -0,0 +1,4 @@
+.ns-box {
+ margin-left: auto;
+ text-align: right;
+}
diff --git a/mm/mounts/modules/default/alert/templates/alert.njk b/mm/mounts/modules/default/alert/templates/alert.njk
new file mode 100644
index 0000000..7349a7a
--- /dev/null
+++ b/mm/mounts/modules/default/alert/templates/alert.njk
@@ -0,0 +1,18 @@
+{% if imageUrl or imageFA %}
+ {% set imageHeight = imageHeight if imageHeight else "80px" %}
+ {% if imageUrl %}
+
+ {% else %}
+
+ {% endif %}
+
+{% endif %}
+{% if title %}
+ {{ title if titleType == 'text' else title | safe }}
+{% endif %}
+{% if message %}
+ {% if title %}
+
+ {% endif %}
+ {{ message if messageType == 'text' else message | safe }}
+{% endif %}
diff --git a/mm/mounts/modules/default/alert/templates/notification.njk b/mm/mounts/modules/default/alert/templates/notification.njk
new file mode 100644
index 0000000..1594ad4
--- /dev/null
+++ b/mm/mounts/modules/default/alert/templates/notification.njk
@@ -0,0 +1,9 @@
+{% if title %}
+ {{ title if titleType == 'text' else title | safe }}
+{% endif %}
+{% if message %}
+ {% if title %}
+
+ {% endif %}
+ {{ message if messageType == 'text' else message | safe }}
+{% endif %}
diff --git a/mm/mounts/modules/default/alert/translations/bg.json b/mm/mounts/modules/default/alert/translations/bg.json
new file mode 100644
index 0000000..102a0b1
--- /dev/null
+++ b/mm/mounts/modules/default/alert/translations/bg.json
@@ -0,0 +1,4 @@
+{
+ "sysTitle": "MagicMirror² нотификация",
+ "welcome": "Добре дошли, стартирането беше успешно"
+}
diff --git a/mm/mounts/modules/default/alert/translations/da.json b/mm/mounts/modules/default/alert/translations/da.json
new file mode 100644
index 0000000..406d2ff
--- /dev/null
+++ b/mm/mounts/modules/default/alert/translations/da.json
@@ -0,0 +1,4 @@
+{
+ "sysTitle": "MagicMirror² Notifikation",
+ "welcome": "Velkommen, modulet er succesfuldt startet!"
+}
diff --git a/mm/mounts/modules/default/alert/translations/de.json b/mm/mounts/modules/default/alert/translations/de.json
new file mode 100644
index 0000000..3ae5238
--- /dev/null
+++ b/mm/mounts/modules/default/alert/translations/de.json
@@ -0,0 +1,4 @@
+{
+ "sysTitle": "MagicMirror² Benachrichtigung",
+ "welcome": "Willkommen, Start war erfolgreich!"
+}
diff --git a/mm/mounts/modules/default/alert/translations/en.json b/mm/mounts/modules/default/alert/translations/en.json
new file mode 100644
index 0000000..ea3319b
--- /dev/null
+++ b/mm/mounts/modules/default/alert/translations/en.json
@@ -0,0 +1,4 @@
+{
+ "sysTitle": "MagicMirror² Notification",
+ "welcome": "Welcome, start was successful!"
+}
diff --git a/mm/mounts/modules/default/alert/translations/es.json b/mm/mounts/modules/default/alert/translations/es.json
new file mode 100644
index 0000000..a2f7472
--- /dev/null
+++ b/mm/mounts/modules/default/alert/translations/es.json
@@ -0,0 +1,4 @@
+{
+ "sysTitle": "MagicMirror² Notificaciones",
+ "welcome": "Bienvenido, ¡se iniciado correctamente!"
+}
diff --git a/mm/mounts/modules/default/alert/translations/fr.json b/mm/mounts/modules/default/alert/translations/fr.json
new file mode 100644
index 0000000..a6d3c10
--- /dev/null
+++ b/mm/mounts/modules/default/alert/translations/fr.json
@@ -0,0 +1,4 @@
+{
+ "sysTitle": "MagicMirror² Notification",
+ "welcome": "Bienvenue, le démarrage a été un succès!"
+}
diff --git a/mm/mounts/modules/default/alert/translations/hu.json b/mm/mounts/modules/default/alert/translations/hu.json
new file mode 100644
index 0000000..90ab35d
--- /dev/null
+++ b/mm/mounts/modules/default/alert/translations/hu.json
@@ -0,0 +1,4 @@
+{
+ "sysTitle": "MagicMirror² értesítés",
+ "welcome": "Üdvözöljük, indulás sikeres!"
+}
diff --git a/mm/mounts/modules/default/alert/translations/nl.json b/mm/mounts/modules/default/alert/translations/nl.json
new file mode 100644
index 0000000..59a480b
--- /dev/null
+++ b/mm/mounts/modules/default/alert/translations/nl.json
@@ -0,0 +1,4 @@
+{
+ "sysTitle": "MagicMirror² Notificatie",
+ "welcome": "Welkom, Succesvol gestart!"
+}
diff --git a/mm/mounts/modules/default/alert/translations/ru.json b/mm/mounts/modules/default/alert/translations/ru.json
new file mode 100644
index 0000000..4ddc86c
--- /dev/null
+++ b/mm/mounts/modules/default/alert/translations/ru.json
@@ -0,0 +1,4 @@
+{
+ "sysTitle": "MagicMirror² Уведомление",
+ "welcome": "Добро пожаловать, старт был успешным!"
+}
diff --git a/mm/mounts/modules/default/alert/translations/th.json b/mm/mounts/modules/default/alert/translations/th.json
new file mode 100644
index 0000000..a1894bf
--- /dev/null
+++ b/mm/mounts/modules/default/alert/translations/th.json
@@ -0,0 +1,4 @@
+{
+ "sysTitle": "การแจ้งเตือน MagicMirror²",
+ "welcome": "ยินดีต้อนรับ การเริ่มต้นสำเร็จแล้ว!"
+}
diff --git a/mm/mounts/modules/default/calendar/README.md b/mm/mounts/modules/default/calendar/README.md
new file mode 100644
index 0000000..1527595
--- /dev/null
+++ b/mm/mounts/modules/default/calendar/README.md
@@ -0,0 +1,6 @@
+# Module: Calendar
+
+The `calendar` module is one of the default modules of the MagicMirror².
+This module displays events from a public .ical calendar. It can combine multiple calendars.
+
+For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/calendar.html).
diff --git a/mm/mounts/modules/default/calendar/calendar.css b/mm/mounts/modules/default/calendar/calendar.css
new file mode 100644
index 0000000..f8e3bd7
--- /dev/null
+++ b/mm/mounts/modules/default/calendar/calendar.css
@@ -0,0 +1,24 @@
+.calendar .symbol {
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-end;
+ padding-left: 0;
+ padding-right: 10px;
+ font-size: var(--font-size-small);
+}
+
+.calendar .symbol span {
+ padding-top: 4px;
+}
+
+.calendar .title {
+ padding-left: 0;
+ padding-right: 0;
+ vertical-align: top;
+}
+
+.calendar .time {
+ padding-left: 30px;
+ text-align: right;
+ vertical-align: top;
+}
diff --git a/mm/mounts/modules/default/calendar/calendar.js b/mm/mounts/modules/default/calendar/calendar.js
new file mode 100644
index 0000000..3343d34
--- /dev/null
+++ b/mm/mounts/modules/default/calendar/calendar.js
@@ -0,0 +1,856 @@
+/* global CalendarUtils, cloneObject */
+
+/* MagicMirror²
+ * Module: Calendar
+ *
+ * By Michael Teeuw https://michaelteeuw.nl
+ * MIT Licensed.
+ */
+Module.register("calendar", {
+ // Define module defaults
+ defaults: {
+ maximumEntries: 10, // Total Maximum Entries
+ maximumNumberOfDays: 365,
+ limitDays: 0, // Limit the number of days shown, 0 = no limit
+ pastDaysCount: 0,
+ displaySymbol: true,
+ defaultSymbol: "calendar-alt", // Fontawesome Symbol see https://fontawesome.com/cheatsheet?from=io
+ defaultSymbolClassName: "fas fa-fw fa-",
+ showLocation: false,
+ displayRepeatingCountTitle: false,
+ defaultRepeatingCountTitle: "",
+ maxTitleLength: 25,
+ maxLocationTitleLength: 25,
+ wrapEvents: false, // Wrap events to multiple lines breaking at maxTitleLength
+ wrapLocationEvents: false,
+ maxTitleLines: 3,
+ maxEventTitleLines: 3,
+ fetchInterval: 60 * 60 * 1000, // Update every hour
+ animationSpeed: 2000,
+ fade: true,
+ urgency: 7,
+ timeFormat: "relative",
+ dateFormat: "MMM Do",
+ dateEndFormat: "LT",
+ fullDayEventDateFormat: "MMM Do",
+ showEnd: false,
+ getRelative: 6,
+ fadePoint: 0.25, // Start on 1/4th of the list.
+ hidePrivate: false,
+ hideOngoing: false,
+ hideTime: false,
+ showTimeToday: false,
+ colored: false,
+ customEvents: [], // Array of {keyword: "", symbol: "", color: ""} where Keyword is a regexp and symbol/color are to be applied for matched
+ tableClass: "small",
+ calendars: [
+ {
+ symbol: "calendar-alt",
+ url: "https://www.calendarlabs.com/templates/ical/US-Holidays.ics"
+ }
+ ],
+ titleReplace: {
+ "De verjaardag van ": "",
+ "'s birthday": ""
+ },
+ locationTitleReplace: {
+ "street ": ""
+ },
+ broadcastEvents: true,
+ excludedEvents: [],
+ sliceMultiDayEvents: false,
+ broadcastPastEvents: false,
+ nextDaysRelative: false,
+ selfSignedCert: false,
+ coloredText: false,
+ coloredBorder: false,
+ coloredSymbol: false,
+ coloredBackground: false,
+ limitDaysNeverSkip: false,
+ flipDateHeaderTitle: false
+ },
+
+ requiresVersion: "2.1.0",
+
+ // Define required scripts.
+ getStyles: function () {
+ return ["calendar.css", "font-awesome.css"];
+ },
+
+ // Define required scripts.
+ getScripts: function () {
+ return ["calendarutils.js", "moment.js"];
+ },
+
+ // Define required translations.
+ getTranslations: function () {
+ // The translations for the default modules are defined in the core translation files.
+ // Therefore we can just return false. Otherwise we should have returned a dictionary.
+ // If you're trying to build your own module including translations, check out the documentation.
+ return false;
+ },
+
+ // Override start method.
+ start: function () {
+ const ONE_MINUTE = 60 * 1000;
+
+ Log.info(`Starting module: ${this.name}`);
+
+ if (this.config.colored) {
+ Log.warn("Your are using the deprecated config values 'colored'. Please switch to 'coloredSymbol' & 'coloredText'!");
+ this.config.coloredText = true;
+ this.config.coloredSymbol = true;
+ }
+ if (this.config.coloredSymbolOnly) {
+ Log.warn("Your are using the deprecated config values 'coloredSymbolOnly'. Please switch to 'coloredSymbol' & 'coloredText'!");
+ this.config.coloredText = false;
+ this.config.coloredSymbol = true;
+ }
+
+ // Set locale.
+ moment.updateLocale(config.language, CalendarUtils.getLocaleSpecification(config.timeFormat));
+
+ // clear data holder before start
+ this.calendarData = {};
+
+ // indicate no data available yet
+ this.loaded = false;
+
+ this.config.calendars.forEach((calendar) => {
+ calendar.url = calendar.url.replace("webcal://", "http://");
+
+ const calendarConfig = {
+ maximumEntries: calendar.maximumEntries,
+ maximumNumberOfDays: calendar.maximumNumberOfDays,
+ pastDaysCount: calendar.pastDaysCount,
+ broadcastPastEvents: calendar.broadcastPastEvents,
+ selfSignedCert: calendar.selfSignedCert
+ };
+
+ if (calendar.symbolClass === "undefined" || calendar.symbolClass === null) {
+ calendarConfig.symbolClass = "";
+ }
+ if (calendar.titleClass === "undefined" || calendar.titleClass === null) {
+ calendarConfig.titleClass = "";
+ }
+ if (calendar.timeClass === "undefined" || calendar.timeClass === null) {
+ calendarConfig.timeClass = "";
+ }
+
+ // we check user and password here for backwards compatibility with old configs
+ if (calendar.user && calendar.pass) {
+ Log.warn("Deprecation warning: Please update your calendar authentication configuration.");
+ Log.warn("https://github.com/MichMich/MagicMirror/tree/v2.1.2/modules/default/calendar#calendar-authentication-options");
+ calendar.auth = {
+ user: calendar.user,
+ pass: calendar.pass
+ };
+ }
+
+ // tell helper to start a fetcher for this calendar
+ // fetcher till cycle
+ this.addCalendar(calendar.url, calendar.auth, calendarConfig);
+ });
+
+ // Refresh the DOM every minute if needed: When using relative date format for events that start
+ // or end in less than an hour, the date shows minute granularity and we want to keep that accurate.
+ setTimeout(() => {
+ setInterval(() => {
+ this.updateDom(1);
+ }, ONE_MINUTE);
+ }, ONE_MINUTE - (new Date() % ONE_MINUTE));
+ },
+
+ // Override socket notification handler.
+ socketNotificationReceived: function (notification, payload) {
+ if (notification === "FETCH_CALENDAR") {
+ this.sendSocketNotification(notification, { url: payload.url, id: this.identifier });
+ }
+
+ if (this.identifier !== payload.id) {
+ return;
+ }
+
+ if (notification === "CALENDAR_EVENTS") {
+ if (this.hasCalendarURL(payload.url)) {
+ this.calendarData[payload.url] = payload.events;
+ this.error = null;
+ this.loaded = true;
+
+ if (this.config.broadcastEvents) {
+ this.broadcastEvents();
+ }
+ }
+ } else if (notification === "CALENDAR_ERROR") {
+ let error_message = this.translate(payload.error_type);
+ this.error = this.translate("MODULE_CONFIG_ERROR", { MODULE_NAME: this.name, ERROR: error_message });
+ this.loaded = true;
+ }
+
+ this.updateDom(this.config.animationSpeed);
+ },
+
+ // Override dom generator.
+ getDom: function () {
+ const ONE_SECOND = 1000; // 1,000 milliseconds
+ const ONE_MINUTE = ONE_SECOND * 60;
+ const ONE_HOUR = ONE_MINUTE * 60;
+ const ONE_DAY = ONE_HOUR * 24;
+
+ const events = this.createEventList(true);
+ const wrapper = document.createElement("table");
+ wrapper.className = this.config.tableClass;
+
+ if (this.error) {
+ wrapper.innerHTML = this.error;
+ wrapper.className = `${this.config.tableClass} dimmed`;
+ return wrapper;
+ }
+
+ if (events.length === 0) {
+ wrapper.innerHTML = this.loaded ? this.translate("EMPTY") : this.translate("LOADING");
+ wrapper.className = `${this.config.tableClass} dimmed`;
+ return wrapper;
+ }
+
+ let currentFadeStep = 0;
+ let startFade;
+ let fadeSteps;
+
+ if (this.config.fade && this.config.fadePoint < 1) {
+ if (this.config.fadePoint < 0) {
+ this.config.fadePoint = 0;
+ }
+ startFade = events.length * this.config.fadePoint;
+ fadeSteps = events.length - startFade;
+ }
+
+ let lastSeenDate = "";
+
+ events.forEach((event, index) => {
+ const dateAsString = moment(event.startDate, "x").format(this.config.dateFormat);
+ if (this.config.timeFormat === "dateheaders") {
+ if (lastSeenDate !== dateAsString) {
+ const dateRow = document.createElement("tr");
+ dateRow.className = "dateheader normal";
+ if (event.today) dateRow.className += " today";
+ else if (event.dayBeforeYesterday) dateRow.className += " dayBeforeYesterday";
+ else if (event.yesterday) dateRow.className += " yesterday";
+ else if (event.tomorrow) dateRow.className += " tomorrow";
+ else if (event.dayAfterTomorrow) dateRow.className += " dayAfterTomorrow";
+
+ const dateCell = document.createElement("td");
+ dateCell.colSpan = "3";
+ dateCell.innerHTML = dateAsString;
+ dateCell.style.paddingTop = "10px";
+ dateRow.appendChild(dateCell);
+ wrapper.appendChild(dateRow);
+
+ if (this.config.fade && index >= startFade) {
+ //fading
+ currentFadeStep = index - startFade;
+ dateRow.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep;
+ }
+
+ lastSeenDate = dateAsString;
+ }
+ }
+
+ const eventWrapper = document.createElement("tr");
+
+ if (this.config.coloredText) {
+ eventWrapper.style.cssText = `color:${this.colorForUrl(event.url, false)}`;
+ }
+
+ if (this.config.coloredBackground) {
+ eventWrapper.style.backgroundColor = this.colorForUrl(event.url, true);
+ }
+
+ if (this.config.coloredBorder) {
+ eventWrapper.style.borderColor = this.colorForUrl(event.url, false);
+ }
+
+ eventWrapper.className = "event-wrapper normal event";
+ if (event.today) eventWrapper.className += " today";
+ else if (event.dayBeforeYesterday) eventWrapper.className += " dayBeforeYesterday";
+ else if (event.yesterday) eventWrapper.className += " yesterday";
+ else if (event.tomorrow) eventWrapper.className += " tomorrow";
+ else if (event.dayAfterTomorrow) eventWrapper.className += " dayAfterTomorrow";
+
+ const symbolWrapper = document.createElement("td");
+
+ if (this.config.displaySymbol) {
+ if (this.config.coloredSymbol) {
+ symbolWrapper.style.cssText = `color:${this.colorForUrl(event.url, false)}`;
+ }
+
+ const symbolClass = this.symbolClassForUrl(event.url);
+ symbolWrapper.className = `symbol align-right ${symbolClass}`;
+
+ const symbols = this.symbolsForEvent(event);
+ symbols.forEach((s, index) => {
+ const symbol = document.createElement("span");
+ symbol.className = s;
+ if (index > 0) {
+ symbol.style.paddingLeft = "5px";
+ }
+ symbolWrapper.appendChild(symbol);
+ });
+ eventWrapper.appendChild(symbolWrapper);
+ } else if (this.config.timeFormat === "dateheaders") {
+ const blankCell = document.createElement("td");
+ blankCell.innerHTML = " ";
+ eventWrapper.appendChild(blankCell);
+ }
+
+ const titleWrapper = document.createElement("td");
+ let repeatingCountTitle = "";
+
+ if (this.config.displayRepeatingCountTitle && event.firstYear !== undefined) {
+ repeatingCountTitle = this.countTitleForUrl(event.url);
+
+ if (repeatingCountTitle !== "") {
+ const thisYear = new Date(parseInt(event.startDate)).getFullYear(),
+ yearDiff = thisYear - event.firstYear;
+
+ repeatingCountTitle = `, ${yearDiff} ${repeatingCountTitle}`;
+ }
+ }
+
+ // Color events if custom color is specified
+ if (this.config.customEvents.length > 0) {
+ for (let ev in this.config.customEvents) {
+ if (typeof this.config.customEvents[ev].color !== "undefined" && this.config.customEvents[ev].color !== "") {
+ let needle = new RegExp(this.config.customEvents[ev].keyword, "gi");
+ if (needle.test(event.title)) {
+ // Respect parameter ColoredSymbolOnly also for custom events
+ if (this.config.coloredText) {
+ eventWrapper.style.cssText = `color:${this.config.customEvents[ev].color}`;
+ titleWrapper.style.cssText = `color:${this.config.customEvents[ev].color}`;
+ }
+ if (this.config.displaySymbol && this.config.coloredSymbol) {
+ symbolWrapper.style.cssText = `color:${this.config.customEvents[ev].color}`;
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ const transformedTitle = CalendarUtils.titleTransform(event.title, this.config.titleReplace);
+ titleWrapper.innerHTML = CalendarUtils.shorten(transformedTitle, this.config.maxTitleLength, this.config.wrapEvents, this.config.maxTitleLines) + repeatingCountTitle;
+
+ const titleClass = this.titleClassForUrl(event.url);
+
+ if (!this.config.coloredText) {
+ titleWrapper.className = `title bright ${titleClass}`;
+ } else {
+ titleWrapper.className = `title ${titleClass}`;
+ }
+
+ if (this.config.timeFormat === "dateheaders") {
+ if (this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper);
+
+ if (event.fullDayEvent) {
+ titleWrapper.colSpan = "2";
+ titleWrapper.classList.add("align-left");
+ } else {
+ const timeWrapper = document.createElement("td");
+ timeWrapper.className = `time light ${this.config.flipDateHeaderTitle ? "align-right " : "align-left "}${this.timeClassForUrl(event.url)}`;
+ timeWrapper.style.paddingLeft = "2px";
+ timeWrapper.style.textAlign = this.config.flipDateHeaderTitle ? "right" : "left";
+ timeWrapper.innerHTML = moment(event.startDate, "x").format("LT");
+
+ // Add endDate to dataheaders if showEnd is enabled
+ if (this.config.showEnd) {
+ timeWrapper.innerHTML += ` - ${CalendarUtils.capFirst(moment(event.endDate, "x").format("LT"))}`;
+ }
+
+ eventWrapper.appendChild(timeWrapper);
+
+ if (!this.config.flipDateHeaderTitle) titleWrapper.classList.add("align-right");
+ }
+ if (!this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper);
+ } else {
+ const timeWrapper = document.createElement("td");
+
+ eventWrapper.appendChild(titleWrapper);
+ const now = new Date();
+
+ if (this.config.timeFormat === "absolute") {
+ // Use dateFormat
+ timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").format(this.config.dateFormat));
+ // Add end time if showEnd
+ if (this.config.showEnd) {
+ timeWrapper.innerHTML += "-";
+ timeWrapper.innerHTML += CalendarUtils.capFirst(moment(event.endDate, "x").format(this.config.dateEndFormat));
+ }
+ // For full day events we use the fullDayEventDateFormat
+ if (event.fullDayEvent) {
+ //subtract one second so that fullDayEvents end at 23:59:59, and not at 0:00:00 one the next day
+ event.endDate -= ONE_SECOND;
+ timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").format(this.config.fullDayEventDateFormat));
+ } else if (this.config.getRelative > 0 && event.startDate < now) {
+ // Ongoing and getRelative is set
+ timeWrapper.innerHTML = CalendarUtils.capFirst(
+ this.translate("RUNNING", {
+ fallback: `${this.translate("RUNNING")} {timeUntilEnd}`,
+ timeUntilEnd: moment(event.endDate, "x").fromNow(true)
+ })
+ );
+ } else if (this.config.urgency > 0 && event.startDate - now < this.config.urgency * ONE_DAY) {
+ // Within urgency days
+ timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").fromNow());
+ }
+ if (event.fullDayEvent && this.config.nextDaysRelative) {
+ // Full days events within the next two days
+ if (event.today) {
+ timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY"));
+ } else if (event.yesterday) {
+ timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY"));
+ } else if (event.startDate - now < ONE_DAY && event.startDate - now > 0) {
+ timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW"));
+ } else if (event.startDate - now < 2 * ONE_DAY && event.startDate - now > 0) {
+ if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") {
+ timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW"));
+ }
+ }
+ }
+ } else {
+ // Show relative times
+ if (event.startDate >= now || (event.fullDayEvent && event.today)) {
+ // Use relative time
+ if (!this.config.hideTime && !event.fullDayEvent) {
+ timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").calendar(null, { sameElse: this.config.dateFormat }));
+ } else {
+ timeWrapper.innerHTML = CalendarUtils.capFirst(
+ moment(event.startDate, "x").calendar(null, {
+ sameDay: this.config.showTimeToday ? "LT" : `[${this.translate("TODAY")}]`,
+ nextDay: `[${this.translate("TOMORROW")}]`,
+ nextWeek: "dddd",
+ sameElse: event.fullDayEvent ? this.config.fullDayEventDateFormat : this.config.dateFormat
+ })
+ );
+ }
+ if (event.fullDayEvent) {
+ // Full days events within the next two days
+ if (event.today) {
+ timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY"));
+ } else if (event.dayBeforeYesterday) {
+ if (this.translate("DAYBEFOREYESTERDAY") !== "DAYBEFOREYESTERDAY") {
+ timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYBEFOREYESTERDAY"));
+ }
+ } else if (event.yesterday) {
+ timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY"));
+ } else if (event.startDate - now < ONE_DAY && event.startDate - now > 0) {
+ timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW"));
+ } else if (event.startDate - now < 2 * ONE_DAY && event.startDate - now > 0) {
+ if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") {
+ timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW"));
+ }
+ }
+ } else if (event.startDate - now < this.config.getRelative * ONE_HOUR) {
+ // If event is within getRelative hours, display 'in xxx' time format or moment.fromNow()
+ timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").fromNow());
+ }
+ } else {
+ // Ongoing event
+ timeWrapper.innerHTML = CalendarUtils.capFirst(
+ this.translate("RUNNING", {
+ fallback: `${this.translate("RUNNING")} {timeUntilEnd}`,
+ timeUntilEnd: moment(event.endDate, "x").fromNow(true)
+ })
+ );
+ }
+ }
+ timeWrapper.className = `time light ${this.timeClassForUrl(event.url)}`;
+ eventWrapper.appendChild(timeWrapper);
+ }
+
+ // Create fade effect.
+ if (index >= startFade) {
+ currentFadeStep = index - startFade;
+ eventWrapper.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep;
+ }
+ wrapper.appendChild(eventWrapper);
+
+ if (this.config.showLocation) {
+ if (event.location !== false) {
+ const locationRow = document.createElement("tr");
+ locationRow.className = "event-wrapper-location normal xsmall light";
+ if (event.today) locationRow.className += " today";
+ else if (event.dayBeforeYesterday) locationRow.className += " dayBeforeYesterday";
+ else if (event.yesterday) locationRow.className += " yesterday";
+ else if (event.tomorrow) locationRow.className += " tomorrow";
+ else if (event.dayAfterTomorrow) locationRow.className += " dayAfterTomorrow";
+
+ if (this.config.displaySymbol) {
+ const symbolCell = document.createElement("td");
+ locationRow.appendChild(symbolCell);
+ }
+
+ if (this.config.coloredText) {
+ locationRow.style.cssText = `color:${this.colorForUrl(event.url, false)}`;
+ }
+
+ if (this.config.coloredBackground) {
+ locationRow.style.backgroundColor = this.colorForUrl(event.url, true);
+ }
+
+ if (this.config.coloredBorder) {
+ locationRow.style.borderColor = this.colorForUrl(event.url, false);
+ }
+
+ const descCell = document.createElement("td");
+ descCell.className = "location";
+ descCell.colSpan = "2";
+
+ const transformedTitle = CalendarUtils.titleTransform(event.location, this.config.locationTitleReplace);
+ descCell.innerHTML = CalendarUtils.shorten(transformedTitle, this.config.maxLocationTitleLength, this.config.wrapLocationEvents, this.config.maxEventTitleLines);
+ locationRow.appendChild(descCell);
+
+ wrapper.appendChild(locationRow);
+
+ if (index >= startFade) {
+ currentFadeStep = index - startFade;
+ locationRow.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep;
+ }
+ }
+ }
+ });
+
+ return wrapper;
+ },
+
+ /**
+ * Checks if this config contains the calendar url.
+ * @param {string} url The calendar url
+ * @returns {boolean} True if the calendar config contains the url, False otherwise
+ */
+ hasCalendarURL: function (url) {
+ for (const calendar of this.config.calendars) {
+ if (calendar.url === url) {
+ return true;
+ }
+ }
+
+ return false;
+ },
+
+ /**
+ * Creates the sorted list of all events.
+ * @param {boolean} limitNumberOfEntries Whether to filter returned events for display.
+ * @returns {object[]} Array with events.
+ */
+ createEventList: function (limitNumberOfEntries) {
+ const ONE_SECOND = 1000; // 1,000 milliseconds
+ const ONE_MINUTE = ONE_SECOND * 60;
+ const ONE_HOUR = ONE_MINUTE * 60;
+ const ONE_DAY = ONE_HOUR * 24;
+
+ const now = new Date();
+ const today = moment().startOf("day");
+ const future = moment().startOf("day").add(this.config.maximumNumberOfDays, "days").toDate();
+ let events = [];
+
+ for (const calendarUrl in this.calendarData) {
+ const calendar = this.calendarData[calendarUrl];
+ let remainingEntries = this.maximumEntriesForUrl(calendarUrl);
+ let maxPastDaysCompare = now - this.maximumPastDaysForUrl(calendarUrl) * ONE_DAY;
+ for (const e in calendar) {
+ const event = JSON.parse(JSON.stringify(calendar[e])); // clone object
+
+ if (this.config.hidePrivate && event.class === "PRIVATE") {
+ // do not add the current event, skip it
+ continue;
+ }
+ if (limitNumberOfEntries) {
+ if (event.endDate < maxPastDaysCompare) {
+ continue;
+ }
+ if (this.config.hideOngoing && event.startDate < now) {
+ continue;
+ }
+ if (this.listContainsEvent(events, event)) {
+ continue;
+ }
+ if (--remainingEntries < 0) {
+ break;
+ }
+ }
+ event.url = calendarUrl;
+ event.today = event.startDate >= today && event.startDate < today + ONE_DAY;
+ event.dayBeforeYesterday = event.startDate >= today - ONE_DAY * 2 && event.startDate < today - ONE_DAY;
+ event.yesterday = event.startDate >= today - ONE_DAY && event.startDate < today;
+ event.tomorrow = !event.today && event.startDate >= today + ONE_DAY && event.startDate < today + 2 * ONE_DAY;
+ event.dayAfterTomorrow = !event.tomorrow && event.startDate >= today + ONE_DAY * 2 && event.startDate < today + 3 * ONE_DAY;
+
+ /* if sliceMultiDayEvents is set to true, multiday events (events exceeding at least one midnight) are sliced into days,
+ * otherwise, esp. in dateheaders mode it is not clear how long these events are.
+ */
+ const maxCount = Math.ceil((event.endDate - 1 - moment(event.startDate, "x").endOf("day").format("x")) / ONE_DAY) + 1;
+ if (this.config.sliceMultiDayEvents && maxCount > 1) {
+ const splitEvents = [];
+ let midnight = moment(event.startDate, "x").clone().startOf("day").add(1, "day").format("x");
+ let count = 1;
+ while (event.endDate > midnight) {
+ const thisEvent = JSON.parse(JSON.stringify(event)); // clone object
+ thisEvent.today = thisEvent.startDate >= today && thisEvent.startDate < today + ONE_DAY;
+ thisEvent.tomorrow = !thisEvent.today && thisEvent.startDate >= today + ONE_DAY && thisEvent.startDate < today + 2 * ONE_DAY;
+ thisEvent.endDate = midnight;
+ thisEvent.title += ` (${count}/${maxCount})`;
+ splitEvents.push(thisEvent);
+
+ event.startDate = midnight;
+ count += 1;
+ midnight = moment(midnight, "x").add(1, "day").format("x"); // next day
+ }
+ // Last day
+ event.title += ` (${count}/${maxCount})`;
+ event.today += event.startDate >= today && event.startDate < today + ONE_DAY;
+ event.tomorrow = !event.today && event.startDate >= today + ONE_DAY && event.startDate < today + 2 * ONE_DAY;
+ splitEvents.push(event);
+
+ for (let splitEvent of splitEvents) {
+ if (splitEvent.endDate > now && splitEvent.endDate <= future) {
+ events.push(splitEvent);
+ }
+ }
+ } else {
+ events.push(event);
+ }
+ }
+ }
+
+ events.sort(function (a, b) {
+ return a.startDate - b.startDate;
+ });
+
+ if (!limitNumberOfEntries) {
+ return events;
+ }
+
+ // Limit the number of days displayed
+ // If limitDays is set > 0, limit display to that number of days
+ if (this.config.limitDays > 0) {
+ let newEvents = [];
+ let lastDate = today.clone().subtract(1, "days").format("YYYYMMDD");
+ let days = 0;
+ for (const ev of events) {
+ let eventDate = moment(ev.startDate, "x").format("YYYYMMDD");
+ // if date of event is later than lastdate
+ // check if we already are showing max unique days
+ if (eventDate > lastDate) {
+ // if the only entry in the first day is a full day event that day is not counted as unique
+ if (!this.config.limitDaysNeverSkip && newEvents.length === 1 && days === 1 && newEvents[0].fullDayEvent) {
+ days--;
+ }
+ days++;
+ if (days > this.config.limitDays) {
+ continue;
+ } else {
+ lastDate = eventDate;
+ }
+ }
+ newEvents.push(ev);
+ }
+ events = newEvents;
+ }
+
+ return events.slice(0, this.config.maximumEntries);
+ },
+
+ listContainsEvent: function (eventList, event) {
+ for (const evt of eventList) {
+ if (evt.title === event.title && parseInt(evt.startDate) === parseInt(event.startDate)) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Requests node helper to add calendar url.
+ * @param {string} url The calendar url to add
+ * @param {object} auth The authentication method and credentials
+ * @param {object} calendarConfig The config of the specific calendar
+ */
+ addCalendar: function (url, auth, calendarConfig) {
+ this.sendSocketNotification("ADD_CALENDAR", {
+ id: this.identifier,
+ url: url,
+ excludedEvents: calendarConfig.excludedEvents || this.config.excludedEvents,
+ maximumEntries: calendarConfig.maximumEntries || this.config.maximumEntries,
+ maximumNumberOfDays: calendarConfig.maximumNumberOfDays || this.config.maximumNumberOfDays,
+ pastDaysCount: calendarConfig.pastDaysCount || this.config.pastDaysCount,
+ fetchInterval: calendarConfig.fetchInterval || this.config.fetchInterval,
+ symbolClass: calendarConfig.symbolClass,
+ titleClass: calendarConfig.titleClass,
+ timeClass: calendarConfig.timeClass,
+ auth: auth,
+ broadcastPastEvents: calendarConfig.broadcastPastEvents || this.config.broadcastPastEvents,
+ selfSignedCert: calendarConfig.selfSignedCert || this.config.selfSignedCert
+ });
+ },
+
+ /**
+ * Retrieves the symbols for a specific event.
+ * @param {object} event Event to look for.
+ * @returns {string[]} The symbols
+ */
+ symbolsForEvent: function (event) {
+ let symbols = this.getCalendarPropertyAsArray(event.url, "symbol", this.config.defaultSymbol);
+
+ if (event.recurringEvent === true && this.hasCalendarProperty(event.url, "recurringSymbol")) {
+ symbols = this.mergeUnique(this.getCalendarPropertyAsArray(event.url, "recurringSymbol", this.config.defaultSymbol), symbols);
+ }
+
+ if (event.fullDayEvent === true && this.hasCalendarProperty(event.url, "fullDaySymbol")) {
+ symbols = this.mergeUnique(this.getCalendarPropertyAsArray(event.url, "fullDaySymbol", this.config.defaultSymbol), symbols);
+ }
+
+ // If custom symbol is set, replace event symbol
+ for (let ev of this.config.customEvents) {
+ if (typeof ev.symbol !== "undefined" && ev.symbol !== "") {
+ let needle = new RegExp(ev.keyword, "gi");
+ if (needle.test(event.title)) {
+ // Get the default prefix for this class name and add to the custom symbol provided
+ const className = this.getCalendarProperty(event.url, "symbolClassName", this.config.defaultSymbolClassName);
+ symbols[0] = className + ev.symbol;
+ break;
+ }
+ }
+ }
+
+ return symbols;
+ },
+
+ mergeUnique: function (arr1, arr2) {
+ return arr1.concat(
+ arr2.filter(function (item) {
+ return arr1.indexOf(item) === -1;
+ })
+ );
+ },
+
+ /**
+ * Retrieves the symbolClass for a specific calendar url.
+ * @param {string} url The calendar url
+ * @returns {string} The class to be used for the symbols of the calendar
+ */
+ symbolClassForUrl: function (url) {
+ return this.getCalendarProperty(url, "symbolClass", "");
+ },
+
+ /**
+ * Retrieves the titleClass for a specific calendar url.
+ * @param {string} url The calendar url
+ * @returns {string} The class to be used for the title of the calendar
+ */
+ titleClassForUrl: function (url) {
+ return this.getCalendarProperty(url, "titleClass", "");
+ },
+
+ /**
+ * Retrieves the timeClass for a specific calendar url.
+ * @param {string} url The calendar url
+ * @returns {string} The class to be used for the time of the calendar
+ */
+ timeClassForUrl: function (url) {
+ return this.getCalendarProperty(url, "timeClass", "");
+ },
+
+ /**
+ * Retrieves the calendar name for a specific calendar url.
+ * @param {string} url The calendar url
+ * @returns {string} The name of the calendar
+ */
+ calendarNameForUrl: function (url) {
+ return this.getCalendarProperty(url, "name", "");
+ },
+
+ /**
+ * Retrieves the color for a specific calendar url.
+ * @param {string} url The calendar url
+ * @param {boolean} isBg Determines if we fetch the bgColor or not
+ * @returns {string} The color
+ */
+ colorForUrl: function (url, isBg) {
+ return this.getCalendarProperty(url, isBg ? "bgColor" : "color", "#fff");
+ },
+
+ /**
+ * Retrieves the count title for a specific calendar url.
+ * @param {string} url The calendar url
+ * @returns {string} The title
+ */
+ countTitleForUrl: function (url) {
+ return this.getCalendarProperty(url, "repeatingCountTitle", this.config.defaultRepeatingCountTitle);
+ },
+
+ /**
+ * Retrieves the maximum entry count for a specific calendar url.
+ * @param {string} url The calendar url
+ * @returns {number} The maximum entry count
+ */
+ maximumEntriesForUrl: function (url) {
+ return this.getCalendarProperty(url, "maximumEntries", this.config.maximumEntries);
+ },
+
+ /**
+ * Retrieves the maximum count of past days which events of should be displayed for a specific calendar url.
+ * @param {string} url The calendar url
+ * @returns {number} The maximum past days count
+ */
+ maximumPastDaysForUrl: function (url) {
+ return this.getCalendarProperty(url, "pastDaysCount", this.config.pastDaysCount);
+ },
+
+ /**
+ * Helper method to retrieve the property for a specific calendar url.
+ * @param {string} url The calendar url
+ * @param {string} property The property to look for
+ * @param {string} defaultValue The value if the property is not found
+ * @returns {*} The property
+ */
+ getCalendarProperty: function (url, property, defaultValue) {
+ for (const calendar of this.config.calendars) {
+ if (calendar.url === url && calendar.hasOwnProperty(property)) {
+ return calendar[property];
+ }
+ }
+
+ return defaultValue;
+ },
+
+ getCalendarPropertyAsArray: function (url, property, defaultValue) {
+ let p = this.getCalendarProperty(url, property, defaultValue);
+ if (property === "symbol" || property === "recurringSymbol" || property === "fullDaySymbol") {
+ const className = this.getCalendarProperty(url, "symbolClassName", this.config.defaultSymbolClassName);
+ p = className + p;
+ }
+
+ if (!(p instanceof Array)) p = [p];
+ return p;
+ },
+
+ hasCalendarProperty: function (url, property) {
+ return !!this.getCalendarProperty(url, property, undefined);
+ },
+
+ /**
+ * Broadcasts the events to all other modules for reuse.
+ * The all events available in one array, sorted on startdate.
+ */
+ broadcastEvents: function () {
+ const eventList = this.createEventList(false);
+ for (const event of eventList) {
+ event.symbol = this.symbolsForEvent(event);
+ event.calendarName = this.calendarNameForUrl(event.url);
+ event.color = this.colorForUrl(event.url, false);
+ delete event.url;
+ }
+
+ this.sendNotification("CALENDAR_EVENTS", eventList);
+ }
+});
diff --git a/mm/mounts/modules/default/calendar/calendarfetcher.js b/mm/mounts/modules/default/calendar/calendarfetcher.js
new file mode 100644
index 0000000..c7b6296
--- /dev/null
+++ b/mm/mounts/modules/default/calendar/calendarfetcher.js
@@ -0,0 +1,155 @@
+/* MagicMirror²
+ * Node Helper: Calendar - CalendarFetcher
+ *
+ * By Michael Teeuw https://michaelteeuw.nl
+ * MIT Licensed.
+ */
+
+const https = require("https");
+const digest = require("digest-fetch");
+const ical = require("node-ical");
+const fetch = require("fetch");
+const Log = require("logger");
+const NodeHelper = require("node_helper");
+const CalendarFetcherUtils = require("./calendarfetcherutils");
+
+/**
+ *
+ * @param {string} url The url of the calendar to fetch
+ * @param {number} reloadInterval Time in ms the calendar is fetched again
+ * @param {string[]} excludedEvents An array of words / phrases from event titles that will be excluded from being shown.
+ * @param {number} maximumEntries The maximum number of events fetched.
+ * @param {number} maximumNumberOfDays The maximum number of days an event should be in the future.
+ * @param {object} auth The object containing options for authentication against the calendar.
+ * @param {boolean} includePastEvents If true events from the past maximumNumberOfDays will be fetched too
+ * @param {boolean} selfSignedCert If true, the server certificate is not verified against the list of supplied CAs.
+ * @class
+ */
+const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, includePastEvents, selfSignedCert) {
+ let reloadTimer = null;
+ let events = [];
+
+ let fetchFailedCallback = function () {};
+ let eventsReceivedCallback = function () {};
+
+ /**
+ * Initiates calendar fetch.
+ */
+ const fetchCalendar = () => {
+ clearTimeout(reloadTimer);
+ reloadTimer = null;
+ const nodeVersion = Number(process.version.match(/^v(\d+\.\d+)/)[1]);
+ let fetcher = null;
+ let httpsAgent = null;
+ let headers = {
+ "User-Agent": `Mozilla/5.0 (Node.js ${nodeVersion}) MagicMirror/${global.version}`
+ };
+
+ if (selfSignedCert) {
+ httpsAgent = new https.Agent({
+ rejectUnauthorized: false
+ });
+ }
+ if (auth) {
+ if (auth.method === "bearer") {
+ headers.Authorization = `Bearer ${auth.pass}`;
+ } else if (auth.method === "digest") {
+ fetcher = new digest(auth.user, auth.pass).fetch(url, { headers: headers, agent: httpsAgent });
+ } else {
+ headers.Authorization = `Basic ${Buffer.from(`${auth.user}:${auth.pass}`).toString("base64")}`;
+ }
+ }
+ if (fetcher === null) {
+ fetcher = fetch(url, { headers: headers, agent: httpsAgent });
+ }
+
+ fetcher
+ .then(NodeHelper.checkFetchStatus)
+ .then((response) => response.text())
+ .then((responseData) => {
+ let data = [];
+
+ try {
+ data = ical.parseICS(responseData);
+ Log.debug(`parsed data=${JSON.stringify(data)}`);
+ events = CalendarFetcherUtils.filterEvents(data, {
+ excludedEvents,
+ includePastEvents,
+ maximumEntries,
+ maximumNumberOfDays
+ });
+ } catch (error) {
+ fetchFailedCallback(this, error);
+ scheduleTimer();
+ return;
+ }
+ this.broadcastEvents();
+ scheduleTimer();
+ })
+ .catch((error) => {
+ fetchFailedCallback(this, error);
+ scheduleTimer();
+ });
+ };
+
+ /**
+ * Schedule the timer for the next update.
+ */
+ const scheduleTimer = function () {
+ clearTimeout(reloadTimer);
+ reloadTimer = setTimeout(function () {
+ fetchCalendar();
+ }, reloadInterval);
+ };
+
+ /* public methods */
+
+ /**
+ * Initiate fetchCalendar();
+ */
+ this.startFetch = function () {
+ fetchCalendar();
+ };
+
+ /**
+ * Broadcast the existing events.
+ */
+ this.broadcastEvents = function () {
+ Log.info(`Calendar-Fetcher: Broadcasting ${events.length} events.`);
+ eventsReceivedCallback(this);
+ };
+
+ /**
+ * Sets the on success callback
+ * @param {Function} callback The on success callback.
+ */
+ this.onReceive = function (callback) {
+ eventsReceivedCallback = callback;
+ };
+
+ /**
+ * Sets the on error callback
+ * @param {Function} callback The on error callback.
+ */
+ this.onError = function (callback) {
+ fetchFailedCallback = callback;
+ };
+
+ /**
+ * Returns the url of this fetcher.
+ * @returns {string} The url of this fetcher.
+ */
+ this.url = function () {
+ return url;
+ };
+
+ /**
+ * Returns current available events for this fetcher.
+ * @returns {object[]} The current available events for this fetcher.
+ */
+ this.events = function () {
+ return events;
+ };
+};
+
+module.exports = CalendarFetcher;
diff --git a/mm/mounts/modules/default/calendar/calendarfetcherutils.js b/mm/mounts/modules/default/calendar/calendarfetcherutils.js
new file mode 100644
index 0000000..d425f0a
--- /dev/null
+++ b/mm/mounts/modules/default/calendar/calendarfetcherutils.js
@@ -0,0 +1,605 @@
+/* MagicMirror²
+ * Calendar Fetcher Util Methods
+ *
+ * By Michael Teeuw https://michaelteeuw.nl
+ * MIT Licensed.
+ */
+
+/**
+ * @external Moment
+ */
+const path = require("path");
+const moment = require("moment");
+const zoneTable = require(path.join(__dirname, "windowsZones.json"));
+const Log = require("../../../js/logger");
+
+const CalendarFetcherUtils = {
+ /**
+ * Calculate the time correction, either dst/std or full day in cases where
+ * utc time is day before plus offset
+ * @param {object} event the event which needs adjustment
+ * @param {Date} date the date on which this event happens
+ * @returns {number} the necessary adjustment in hours
+ */
+ calculateTimezoneAdjustment: function (event, date) {
+ let adjustHours = 0;
+ // if a timezone was specified
+ if (!event.start.tz) {
+ Log.debug(" if no tz, guess based on now");
+ event.start.tz = moment.tz.guess();
+ }
+ Log.debug(`initial tz=${event.start.tz}`);
+
+ // if there is a start date specified
+ if (event.start.tz) {
+ // if this is a windows timezone
+ if (event.start.tz.includes(" ")) {
+ // use the lookup table to get theIANA name as moment and date don't know MS timezones
+ let tz = CalendarFetcherUtils.getIanaTZFromMS(event.start.tz);
+ Log.debug(`corrected TZ=${tz}`);
+ // watch out for unregistered windows timezone names
+ // if we had a successful lookup
+ if (tz) {
+ // change the timezone to the IANA name
+ event.start.tz = tz;
+ // Log.debug("corrected timezone="+event.start.tz)
+ }
+ }
+ Log.debug(`corrected tz=${event.start.tz}`);
+ let current_offset = 0; // offset from TZ string or calculated
+ let mm = 0; // date with tz or offset
+ let start_offset = 0; // utc offset of created with tz
+ // if there is still an offset, lookup failed, use it
+ if (event.start.tz.startsWith("(")) {
+ const regex = /[+|-]\d*:\d*/;
+ const start_offsetString = event.start.tz.match(regex).toString().split(":");
+ let start_offset = parseInt(start_offsetString[0]);
+ start_offset *= event.start.tz[1] === "-" ? -1 : 1;
+ adjustHours = start_offset;
+ Log.debug(`defined offset=${start_offset} hours`);
+ current_offset = start_offset;
+ event.start.tz = "";
+ Log.debug(`ical offset=${current_offset} date=${date}`);
+ mm = moment(date);
+ let x = parseInt(moment(new Date()).utcOffset());
+ Log.debug(`net mins=${current_offset * 60 - x}`);
+
+ mm = mm.add(x - current_offset * 60, "minutes");
+ adjustHours = (current_offset * 60 - x) / 60;
+ event.start = mm.toDate();
+ Log.debug(`adjusted date=${event.start}`);
+ } else {
+ // get the start time in that timezone
+ let es = moment(event.start);
+ // check for start date prior to start of daylight changing date
+ if (es.format("YYYY") < 2007) {
+ es.set("year", 2013); // if so, use a closer date
+ }
+ Log.debug(`start date/time=${es.toDate()}`);
+ start_offset = moment.tz(es, event.start.tz).utcOffset();
+ Log.debug(`start offset=${start_offset}`);
+
+ Log.debug(`start date/time w tz =${moment.tz(moment(event.start), event.start.tz).toDate()}`);
+
+ // get the specified date in that timezone
+ mm = moment.tz(moment(date), event.start.tz);
+ Log.debug(`event date=${mm.toDate()}`);
+ current_offset = mm.utcOffset();
+ }
+ Log.debug(`event offset=${current_offset} hour=${mm.format("H")} event date=${mm.toDate()}`);
+
+ // if the offset is greater than 0, east of london
+ if (current_offset !== start_offset) {
+ // big offset
+ Log.debug("offset");
+ let h = parseInt(mm.format("H"));
+ // check if the event time is less than the offset
+ if (h > 0 && h < Math.abs(current_offset) / 60) {
+ // if so, rrule created a wrong date (utc day, oops, with utc yesterday adjusted time)
+ // we need to fix that
+ //adjustHours = 24;
+ // Log.debug("adjusting date")
+ }
+ //-300 > -240
+ //if (Math.abs(current_offset) > Math.abs(start_offset)){
+ if (current_offset > start_offset) {
+ adjustHours -= 1;
+ Log.debug("adjust down 1 hour dst change");
+ //} else if (Math.abs(current_offset) < Math.abs(start_offset)) {
+ } else if (current_offset < start_offset) {
+ adjustHours += 1;
+ Log.debug("adjust up 1 hour dst change");
+ }
+ }
+ }
+ Log.debug(`adjustHours=${adjustHours}`);
+ return adjustHours;
+ },
+
+ /**
+ * Filter the events from ical according to the given config
+ * @param {object} data the calendar data from ical
+ * @param {object} config The configuration object
+ * @returns {string[]} the filtered events
+ */
+ filterEvents: function (data, config) {
+ const newEvents = [];
+
+ // limitFunction doesn't do much limiting, see comment re: the dates
+ // array in rrule section below as to why we need to do the filtering
+ // ourselves
+ const limitFunction = function (date, i) {
+ return true;
+ };
+
+ const eventDate = function (event, time) {
+ return CalendarFetcherUtils.isFullDayEvent(event) ? moment(event[time], "YYYYMMDD") : moment(new Date(event[time]));
+ };
+
+ Log.debug(`There are ${Object.entries(data).length} calendar entries.`);
+ Object.entries(data).forEach(([key, event]) => {
+ Log.debug("Processing entry...");
+ const now = new Date();
+ const today = moment().startOf("day").toDate();
+ const future = moment().startOf("day").add(config.maximumNumberOfDays, "days").subtract(1, "seconds").toDate(); // Subtract 1 second so that events that start on the middle of the night will not repeat.
+ let past = today;
+
+ if (config.includePastEvents) {
+ past = moment().startOf("day").subtract(config.maximumNumberOfDays, "days").toDate();
+ }
+
+ // FIXME: Ugly fix to solve the facebook birthday issue.
+ // Otherwise, the recurring events only show the birthday for next year.
+ let isFacebookBirthday = false;
+ if (typeof event.uid !== "undefined") {
+ if (event.uid.indexOf("@facebook.com") !== -1) {
+ isFacebookBirthday = true;
+ }
+ }
+
+ if (event.type === "VEVENT") {
+ Log.debug(`Event:\n${JSON.stringify(event)}`);
+ let startDate = eventDate(event, "start");
+ let endDate;
+
+ if (typeof event.end !== "undefined") {
+ endDate = eventDate(event, "end");
+ } else if (typeof event.duration !== "undefined") {
+ endDate = startDate.clone().add(moment.duration(event.duration));
+ } else {
+ if (!isFacebookBirthday) {
+ // make copy of start date, separate storage area
+ endDate = moment(startDate.format("x"), "x");
+ } else {
+ endDate = moment(startDate).add(1, "days");
+ }
+ }
+
+ Log.debug(`start: ${startDate.toDate()}`);
+ Log.debug(`end:: ${endDate.toDate()}`);
+
+ // Calculate the duration of the event for use with recurring events.
+ let duration = parseInt(endDate.format("x")) - parseInt(startDate.format("x"));
+ Log.debug(`duration: ${duration}`);
+
+ // FIXME: Since the parsed json object from node-ical comes with time information
+ // this check could be removed (?)
+ if (event.start.length === 8) {
+ startDate = startDate.startOf("day");
+ }
+
+ const title = CalendarFetcherUtils.getTitleFromEvent(event);
+ Log.debug(`title: ${title}`);
+
+ let excluded = false,
+ dateFilter = null;
+
+ for (let f in config.excludedEvents) {
+ let filter = config.excludedEvents[f],
+ testTitle = title.toLowerCase(),
+ until = null,
+ useRegex = false,
+ regexFlags = "g";
+
+ if (filter instanceof Object) {
+ if (typeof filter.until !== "undefined") {
+ until = filter.until;
+ }
+
+ if (typeof filter.regex !== "undefined") {
+ useRegex = filter.regex;
+ }
+
+ // If additional advanced filtering is added in, this section
+ // must remain last as we overwrite the filter object with the
+ // filterBy string
+ if (filter.caseSensitive) {
+ filter = filter.filterBy;
+ testTitle = title;
+ } else if (useRegex) {
+ filter = filter.filterBy;
+ testTitle = title;
+ regexFlags += "i";
+ } else {
+ filter = filter.filterBy.toLowerCase();
+ }
+ } else {
+ filter = filter.toLowerCase();
+ }
+
+ if (CalendarFetcherUtils.titleFilterApplies(testTitle, filter, useRegex, regexFlags)) {
+ if (until) {
+ dateFilter = until;
+ } else {
+ excluded = true;
+ }
+ break;
+ }
+ }
+
+ if (excluded) {
+ return;
+ }
+
+ const location = event.location || false;
+ const geo = event.geo || false;
+ const description = event.description || false;
+
+ if (typeof event.rrule !== "undefined" && event.rrule !== null && !isFacebookBirthday) {
+ const rule = event.rrule;
+ let addedEvents = 0;
+
+ const pastMoment = moment(past);
+ const futureMoment = moment(future);
+
+ // can cause problems with e.g. birthdays before 1900
+ if ((rule.options && rule.origOptions && rule.origOptions.dtstart && rule.origOptions.dtstart.getFullYear() < 1900) || (rule.options && rule.options.dtstart && rule.options.dtstart.getFullYear() < 1900)) {
+ rule.origOptions.dtstart.setYear(1900);
+ rule.options.dtstart.setYear(1900);
+ }
+
+ // For recurring events, get the set of start dates that fall within the range
+ // of dates we're looking for.
+ // kblankenship1989 - to fix issue #1798, converting all dates to locale time first, then converting back to UTC time
+ let pastLocal = 0;
+ let futureLocal = 0;
+ if (CalendarFetcherUtils.isFullDayEvent(event)) {
+ Log.debug("fullday");
+ // if full day event, only use the date part of the ranges
+ pastLocal = pastMoment.toDate();
+ futureLocal = futureMoment.toDate();
+
+ Log.debug(`pastLocal: ${pastLocal}`);
+ Log.debug(`futureLocal: ${futureLocal}`);
+ } else {
+ // if we want past events
+ if (config.includePastEvents) {
+ // use the calculated past time for the between from
+ pastLocal = pastMoment.toDate();
+ } else {
+ // otherwise use NOW.. cause we shouldn't use any before now
+ pastLocal = moment().toDate(); //now
+ }
+ futureLocal = futureMoment.toDate(); // future
+ }
+ Log.debug(`Search for recurring events between: ${pastLocal} and ${futureLocal}`);
+ const dates = rule.between(pastLocal, futureLocal, true, limitFunction);
+ Log.debug(`Title: ${event.summary}, with dates: ${JSON.stringify(dates)}`);
+ // The "dates" array contains the set of dates within our desired date range range that are valid
+ // for the recurrence rule. *However*, it's possible for us to have a specific recurrence that
+ // had its date changed from outside the range to inside the range. For the time being,
+ // we'll handle this by adding *all* recurrence entries into the set of dates that we check,
+ // because the logic below will filter out any recurrences that don't actually belong within
+ // our display range.
+ // Would be great if there was a better way to handle this.
+ Log.debug(`event.recurrences: ${event.recurrences}`);
+ if (event.recurrences !== undefined) {
+ for (let r in event.recurrences) {
+ // Only add dates that weren't already in the range we added from the rrule so that
+ // we don"t double-add those events.
+ if (moment(new Date(r)).isBetween(pastMoment, futureMoment) !== true) {
+ dates.push(new Date(r));
+ }
+ }
+ }
+ // Loop through the set of date entries to see which recurrences should be added to our event list.
+ for (let d in dates) {
+ let date = dates[d];
+ // Remove the time information of each date by using its substring, using the following method:
+ // .toISOString().substring(0,10).
+ // since the date is given as ISOString with YYYY-MM-DDTHH:MM:SS.SSSZ
+ // (see https://momentjs.com/docs/#/displaying/as-iso-string/).
+ const dateKey = date.toISOString().substring(0, 10);
+ let curEvent = event;
+ let showRecurrence = true;
+
+ // Get the offset of today where we are processing
+ // This will be the correction, we need to apply.
+ let nowOffset = new Date().getTimezoneOffset();
+ // For full day events, the time might be off from RRULE/Luxon problem
+ // Get time zone offset of the rule calculated event
+ let dateoffset = date.getTimezoneOffset();
+
+ // Reduce the time by the following offset.
+ Log.debug(` recurring date is ${date} offset is ${dateoffset}`);
+
+ let dh = moment(date).format("HH");
+ Log.debug(` recurring date is ${date} offset is ${dateoffset / 60} Hour is ${dh}`);
+
+ if (CalendarFetcherUtils.isFullDayEvent(event)) {
+ Log.debug("Fullday");
+ // If the offset is negative (east of GMT), where the problem is
+ if (dateoffset < 0) {
+ if (dh < Math.abs(dateoffset / 60)) {
+ // if the rrule byweekday WAS explicitly set , correct it
+ // reduce the time by the offset
+ if (curEvent.rrule.origOptions.byweekday !== undefined) {
+ // Apply the correction to the date/time to get it UTC relative
+ date = new Date(date.getTime() - Math.abs(24 * 60) * 60000);
+ }
+ // the duration was calculated way back at the top before we could correct the start time..
+ // fix it for this event entry
+ //duration = 24 * 60 * 60 * 1000;
+ Log.debug(`new recurring date1 fulldate is ${date}`);
+ }
+ } else {
+ // if the timezones are the same, correct date if needed
+ //if (event.start.tz === moment.tz.guess()) {
+ // if the date hour is less than the offset
+ if (24 - dh <= Math.abs(dateoffset / 60)) {
+ // if the rrule byweekday WAS explicitly set , correct it
+ if (curEvent.rrule.origOptions.byweekday !== undefined) {
+ // apply the correction to the date/time back to right day
+ date = new Date(date.getTime() + Math.abs(24 * 60) * 60000);
+ }
+ // the duration was calculated way back at the top before we could correct the start time..
+ // fix it for this event entry
+ //duration = 24 * 60 * 60 * 1000;
+ Log.debug(`new recurring date2 fulldate is ${date}`);
+ }
+ //}
+ }
+ } else {
+ // not full day, but luxon can still screw up the date on the rule processing
+ // we need to correct the date to get back to the right event for
+ if (dateoffset < 0) {
+ // if the date hour is less than the offset
+ if (dh <= Math.abs(dateoffset / 60)) {
+ // if the rrule byweekday WAS explicitly set , correct it
+ if (curEvent.rrule.origOptions.byweekday !== undefined) {
+ // Reduce the time by t:
+ // Apply the correction to the date/time to get it UTC relative
+ date = new Date(date.getTime() - Math.abs(24 * 60) * 60000);
+ }
+ // the duration was calculated way back at the top before we could correct the start time..
+ // fix it for this event entry
+ //duration = 24 * 60 * 60 * 1000;
+ Log.debug(`new recurring date1 is ${date}`);
+ }
+ } else {
+ // if the timezones are the same, correct date if needed
+ //if (event.start.tz === moment.tz.guess()) {
+ // if the date hour is less than the offset
+ if (24 - dh <= Math.abs(dateoffset / 60)) {
+ // if the rrule byweekday WAS explicitly set , correct it
+ if (curEvent.rrule.origOptions.byweekday !== undefined) {
+ // apply the correction to the date/time back to right day
+ date = new Date(date.getTime() + Math.abs(24 * 60) * 60000);
+ }
+ // the duration was calculated way back at the top before we could correct the start time..
+ // fix it for this event entry
+ //duration = 24 * 60 * 60 * 1000;
+ Log.debug(`new recurring date2 is ${date}`);
+ }
+ //}
+ }
+ }
+ startDate = moment(date);
+ Log.debug(`Corrected startDate: ${startDate.toDate()}`);
+
+ let adjustDays = CalendarFetcherUtils.calculateTimezoneAdjustment(event, date);
+
+ // For each date that we're checking, it's possible that there is a recurrence override for that one day.
+ if (curEvent.recurrences !== undefined && curEvent.recurrences[dateKey] !== undefined) {
+ // We found an override, so for this recurrence, use a potentially different title, start date, and duration.
+ curEvent = curEvent.recurrences[dateKey];
+ startDate = moment(curEvent.start);
+ duration = parseInt(moment(curEvent.end).format("x")) - parseInt(startDate.format("x"));
+ }
+ // If there's no recurrence override, check for an exception date. Exception dates represent exceptions to the rule.
+ else if (curEvent.exdate !== undefined && curEvent.exdate[dateKey] !== undefined) {
+ // This date is an exception date, which means we should skip it in the recurrence pattern.
+ showRecurrence = false;
+ }
+ Log.debug(`duration: ${duration}`);
+
+ endDate = moment(parseInt(startDate.format("x")) + duration, "x");
+ if (startDate.format("x") === endDate.format("x")) {
+ endDate = endDate.endOf("day");
+ }
+
+ const recurrenceTitle = CalendarFetcherUtils.getTitleFromEvent(curEvent);
+
+ // If this recurrence ends before the start of the date range, or starts after the end of the date range, don"t add
+ // it to the event list.
+ if (endDate.isBefore(past) || startDate.isAfter(future)) {
+ showRecurrence = false;
+ }
+
+ if (CalendarFetcherUtils.timeFilterApplies(now, endDate, dateFilter)) {
+ showRecurrence = false;
+ }
+
+ if (showRecurrence === true) {
+ Log.debug(`saving event: ${description}`);
+ addedEvents++;
+ newEvents.push({
+ title: recurrenceTitle,
+ startDate: (adjustDays ? (adjustDays > 0 ? startDate.add(adjustDays, "hours") : startDate.subtract(Math.abs(adjustDays), "hours")) : startDate).format("x"),
+ endDate: (adjustDays ? (adjustDays > 0 ? endDate.add(adjustDays, "hours") : endDate.subtract(Math.abs(adjustDays), "hours")) : endDate).format("x"),
+ fullDayEvent: CalendarFetcherUtils.isFullDayEvent(event),
+ recurringEvent: true,
+ class: event.class,
+ firstYear: event.start.getFullYear(),
+ location: location,
+ geo: geo,
+ description: description
+ });
+ }
+ }
+ // End recurring event parsing.
+ } else {
+ // Single event.
+ const fullDayEvent = isFacebookBirthday ? true : CalendarFetcherUtils.isFullDayEvent(event);
+ // Log.debug("full day event")
+
+ // if the start and end are the same, then make end the 'end of day' value (start is at 00:00:00)
+ if (fullDayEvent && startDate.format("x") === endDate.format("x")) {
+ endDate = endDate.endOf("day");
+ }
+
+ if (config.includePastEvents) {
+ // Past event is too far in the past, so skip.
+ if (endDate < past) {
+ return;
+ }
+ } else {
+ // It's not a fullday event, and it is in the past, so skip.
+ if (!fullDayEvent && endDate < new Date()) {
+ return;
+ }
+
+ // It's a fullday event, and it is before today, So skip.
+ if (fullDayEvent && endDate <= today) {
+ return;
+ }
+ }
+
+ // It exceeds the maximumNumberOfDays limit, so skip.
+ if (startDate > future) {
+ return;
+ }
+
+ if (CalendarFetcherUtils.timeFilterApplies(now, endDate, dateFilter)) {
+ return;
+ }
+
+ // get correction for date saving and dst change between now and then
+ let adjustDays = CalendarFetcherUtils.calculateTimezoneAdjustment(event, startDate.toDate());
+ // Every thing is good. Add it to the list.
+ newEvents.push({
+ title: title,
+ startDate: (adjustDays ? (adjustDays > 0 ? startDate.add(adjustDays, "hours") : startDate.subtract(Math.abs(adjustDays), "hours")) : startDate).format("x"),
+ endDate: (adjustDays ? (adjustDays > 0 ? endDate.add(adjustDays, "hours") : endDate.subtract(Math.abs(adjustDays), "hours")) : endDate).format("x"),
+ fullDayEvent: fullDayEvent,
+ class: event.class,
+ location: location,
+ geo: geo,
+ description: description
+ });
+ }
+ }
+ });
+
+ newEvents.sort(function (a, b) {
+ return a.startDate - b.startDate;
+ });
+
+ return newEvents;
+ },
+
+ /**
+ * Lookup iana tz from windows
+ * @param {string} msTZName the timezone name to lookup
+ * @returns {string|null} the iana name or null of none is found
+ */
+ getIanaTZFromMS: function (msTZName) {
+ // Get hash entry
+ const he = zoneTable[msTZName];
+ // If found return iana name, else null
+ return he ? he.iana[0] : null;
+ },
+
+ /**
+ * Gets the title from the event.
+ * @param {object} event The event object to check.
+ * @returns {string} The title of the event, or "Event" if no title is found.
+ */
+ getTitleFromEvent: function (event) {
+ let title = "Event";
+ if (event.summary) {
+ title = typeof event.summary.val !== "undefined" ? event.summary.val : event.summary;
+ } else if (event.description) {
+ title = event.description;
+ }
+
+ return title;
+ },
+
+ /**
+ * Checks if an event is a fullday event.
+ * @param {object} event The event object to check.
+ * @returns {boolean} True if the event is a fullday event, false otherwise
+ */
+ isFullDayEvent: function (event) {
+ if (event.start.length === 8 || event.start.dateOnly || event.datetype === "date") {
+ return true;
+ }
+
+ const start = event.start || 0;
+ const startDate = new Date(start);
+ const end = event.end || 0;
+ if ((end - start) % (24 * 60 * 60 * 1000) === 0 && startDate.getHours() === 0 && startDate.getMinutes() === 0) {
+ // Is 24 hours, and starts on the middle of the night.
+ return true;
+ }
+
+ return false;
+ },
+
+ /**
+ * Determines if the user defined time filter should apply
+ * @param {Date} now Date object using previously created object for consistency
+ * @param {Moment} endDate Moment object representing the event end date
+ * @param {string} filter The time to subtract from the end date to determine if an event should be shown
+ * @returns {boolean} True if the event should be filtered out, false otherwise
+ */
+ timeFilterApplies: function (now, endDate, filter) {
+ if (filter) {
+ const until = filter.split(" "),
+ value = parseInt(until[0]),
+ increment = until[1].slice(-1) === "s" ? until[1] : `${until[1]}s`, // Massage the data for moment js
+ filterUntil = moment(endDate.format()).subtract(value, increment);
+
+ return now < filterUntil.format("x");
+ }
+
+ return false;
+ },
+
+ /**
+ * Determines if the user defined title filter should apply
+ * @param {string} title the title of the event
+ * @param {string} filter the string to look for, can be a regex also
+ * @param {boolean} useRegex true if a regex should be used, otherwise it just looks for the filter as a string
+ * @param {string} regexFlags flags that should be applied to the regex
+ * @returns {boolean} True if the title should be filtered out, false otherwise
+ */
+ titleFilterApplies: function (title, filter, useRegex, regexFlags) {
+ if (useRegex) {
+ let regexFilter = filter;
+ // Assume if leading slash, there is also trailing slash
+ if (filter[0] === "/") {
+ // Strip leading and trailing slashes
+ regexFilter = filter.substr(1).slice(0, -1);
+ }
+ return new RegExp(regexFilter, regexFlags).test(title);
+ } else {
+ return title.includes(filter);
+ }
+ }
+};
+
+if (typeof module !== "undefined") {
+ module.exports = CalendarFetcherUtils;
+}
diff --git a/mm/mounts/modules/default/calendar/calendarutils.js b/mm/mounts/modules/default/calendar/calendarutils.js
new file mode 100644
index 0000000..e953b63
--- /dev/null
+++ b/mm/mounts/modules/default/calendar/calendarutils.js
@@ -0,0 +1,117 @@
+/* MagicMirror²
+ * Calendar Util Methods
+ *
+ * By Rejas
+ * MIT Licensed.
+ */
+const CalendarUtils = {
+ /**
+ * Capitalize the first letter of a string
+ * @param {string} string The string to capitalize
+ * @returns {string} The capitalized string
+ */
+ capFirst: function (string) {
+ return string.charAt(0).toUpperCase() + string.slice(1);
+ },
+
+ /**
+ * This function accepts a number (either 12 or 24) and returns a moment.js LocaleSpecification with the
+ * corresponding time-format to be used in the calendar display. If no number is given (or otherwise invalid input)
+ * it will a localeSpecification object with the system locale time format.
+ * @param {number} timeFormat Specifies either 12 or 24-hour time format
+ * @returns {moment.LocaleSpecification} formatted time
+ */
+ getLocaleSpecification: function (timeFormat) {
+ switch (timeFormat) {
+ case 12: {
+ return { longDateFormat: { LT: "h:mm A" } };
+ }
+ case 24: {
+ return { longDateFormat: { LT: "HH:mm" } };
+ }
+ default: {
+ return { longDateFormat: { LT: moment.localeData().longDateFormat("LT") } };
+ }
+ }
+ },
+
+ /**
+ * Shortens a string if it's longer than maxLength and add an ellipsis to the end
+ * @param {string} string Text string to shorten
+ * @param {number} maxLength The max length of the string
+ * @param {boolean} wrapEvents Wrap the text after the line has reached maxLength
+ * @param {number} maxTitleLines The max number of vertical lines before cutting event title
+ * @returns {string} The shortened string
+ */
+ shorten: function (string, maxLength, wrapEvents, maxTitleLines) {
+ if (typeof string !== "string") {
+ return "";
+ }
+
+ if (wrapEvents === true) {
+ const words = string.split(" ");
+ let temp = "";
+ let currentLine = "";
+ let line = 0;
+
+ for (let i = 0; i < words.length; i++) {
+ const word = words[i];
+ if (currentLine.length + word.length < (typeof maxLength === "number" ? maxLength : 25) - 1) {
+ // max - 1 to account for a space
+ currentLine += `${word} `;
+ } else {
+ line++;
+ if (line > maxTitleLines - 1) {
+ if (i < words.length) {
+ currentLine += "…";
+ }
+ break;
+ }
+
+ if (currentLine.length > 0) {
+ temp += `${currentLine} ${word} `;
+ } else {
+ temp += `${word} `;
+ }
+ currentLine = "";
+ }
+ }
+
+ return (temp + currentLine).trim();
+ } else {
+ if (maxLength && typeof maxLength === "number" && string.length > maxLength) {
+ return `${string.trim().slice(0, maxLength)}…`;
+ } else {
+ return string.trim();
+ }
+ }
+ },
+
+ /**
+ * Transforms the title of an event for usage.
+ * Replaces parts of the text as defined in config.titleReplace.
+ * Shortens title based on config.maxTitleLength and config.wrapEvents
+ * @param {string} title The title to transform.
+ * @param {object} titleReplace Pairs of strings to be replaced in the title
+ * @returns {string} The transformed title.
+ */
+ titleTransform: function (title, titleReplace) {
+ let transformedTitle = title;
+ for (let needle in titleReplace) {
+ const replacement = titleReplace[needle];
+
+ const regParts = needle.match(/^\/(.+)\/([gim]*)$/);
+ if (regParts) {
+ // the parsed pattern is a regexp.
+ needle = new RegExp(regParts[1], regParts[2]);
+ }
+
+ transformedTitle = transformedTitle.replace(needle, replacement);
+ }
+ return transformedTitle;
+ }
+};
+
+if (typeof module !== "undefined") {
+ module.exports = CalendarUtils;
+}
diff --git a/mm/mounts/modules/default/calendar/debug.js b/mm/mounts/modules/default/calendar/debug.js
new file mode 100644
index 0000000..5e19e13
--- /dev/null
+++ b/mm/mounts/modules/default/calendar/debug.js
@@ -0,0 +1,43 @@
+/* CalendarFetcher Tester
+ * use this script with `node debug.js` to test the fetcher without the need
+ * of starting the MagicMirror² core. Adjust the values below to your desire.
+ *
+ * By Michael Teeuw https://michaelteeuw.nl
+ * MIT Licensed.
+ */
+// Alias modules mentioned in package.js under _moduleAliases.
+require("module-alias/register");
+
+const CalendarFetcher = require("./calendarfetcher");
+
+const url = "https://calendar.google.com/calendar/ical/pkm1t2uedjbp0uvq1o7oj1jouo%40group.calendar.google.com/private-08ba559f89eec70dd74bbd887d0a3598/basic.ics"; // Standard test URL
+//const url = "https://www.googleapis.com/calendar/v3/calendars/primary/events/"; // URL for Bearer auth (must be configured in Google OAuth2 first)
+const fetchInterval = 60 * 60 * 1000;
+const maximumEntries = 10;
+const maximumNumberOfDays = 365;
+const user = "magicmirror";
+const pass = "MyStrongPass";
+const auth = {
+ user: user,
+ pass: pass
+};
+
+console.log("Create fetcher ...");
+
+const fetcher = new CalendarFetcher(url, fetchInterval, [], maximumEntries, maximumNumberOfDays, auth);
+
+fetcher.onReceive(function (fetcher) {
+ console.log(fetcher.events());
+ console.log("------------------------------------------------------------");
+ process.exit(0);
+});
+
+fetcher.onError(function (fetcher, error) {
+ console.log("Fetcher error:");
+ console.log(error);
+ process.exit(1);
+});
+
+fetcher.startFetch();
+
+console.log("Create fetcher done! ");
diff --git a/mm/mounts/modules/default/calendar/node_helper.js b/mm/mounts/modules/default/calendar/node_helper.js
new file mode 100644
index 0000000..05d4d45
--- /dev/null
+++ b/mm/mounts/modules/default/calendar/node_helper.js
@@ -0,0 +1,95 @@
+/* MagicMirror²
+ * Node Helper: Calendar
+ *
+ * By Michael Teeuw https://michaelteeuw.nl
+ * MIT Licensed.
+ */
+const NodeHelper = require("node_helper");
+const Log = require("logger");
+const CalendarFetcher = require("./calendarfetcher");
+
+module.exports = NodeHelper.create({
+ // Override start method.
+ start: function () {
+ Log.log(`Starting node helper for: ${this.name}`);
+ this.fetchers = [];
+ },
+
+ // Override socketNotificationReceived method.
+ socketNotificationReceived: function (notification, payload) {
+ if (notification === "ADD_CALENDAR") {
+ this.createFetcher(payload.url, payload.fetchInterval, payload.excludedEvents, payload.maximumEntries, payload.maximumNumberOfDays, payload.auth, payload.broadcastPastEvents, payload.selfSignedCert, payload.id);
+ } else if (notification === "FETCH_CALENDAR") {
+ const key = payload.id + payload.url;
+ if (typeof this.fetchers[key] === "undefined") {
+ Log.error("Calendar Error. No fetcher exists with key: ", key);
+ this.sendSocketNotification("CALENDAR_ERROR", { error_type: "MODULE_ERROR_UNSPECIFIED" });
+ return;
+ }
+ this.fetchers[key].startFetch();
+ }
+ },
+
+ /**
+ * Creates a fetcher for a new url if it doesn't exist yet.
+ * Otherwise it reuses the existing one.
+ * @param {string} url The url of the calendar
+ * @param {number} fetchInterval How often does the calendar needs to be fetched in ms
+ * @param {string[]} excludedEvents An array of words / phrases from event titles that will be excluded from being shown.
+ * @param {number} maximumEntries The maximum number of events fetched.
+ * @param {number} maximumNumberOfDays The maximum number of days an event should be in the future.
+ * @param {object} auth The object containing options for authentication against the calendar.
+ * @param {boolean} broadcastPastEvents If true events from the past maximumNumberOfDays will be included in event broadcasts
+ * @param {boolean} selfSignedCert If true, the server certificate is not verified against the list of supplied CAs.
+ * @param {string} identifier ID of the module
+ */
+ createFetcher: function (url, fetchInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, broadcastPastEvents, selfSignedCert, identifier) {
+ try {
+ new URL(url);
+ } catch (error) {
+ Log.error("Calendar Error. Malformed calendar url: ", url, error);
+ this.sendSocketNotification("CALENDAR_ERROR", { error_type: "MODULE_ERROR_MALFORMED_URL" });
+ return;
+ }
+
+ let fetcher;
+ if (typeof this.fetchers[identifier + url] === "undefined") {
+ Log.log(`Create new calendarfetcher for url: ${url} - Interval: ${fetchInterval}`);
+ fetcher = new CalendarFetcher(url, fetchInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, broadcastPastEvents, selfSignedCert);
+
+ fetcher.onReceive((fetcher) => {
+ this.broadcastEvents(fetcher, identifier);
+ });
+
+ fetcher.onError((fetcher, error) => {
+ Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url(), error);
+ let error_type = NodeHelper.checkFetchError(error);
+ this.sendSocketNotification("CALENDAR_ERROR", {
+ id: identifier,
+ error_type
+ });
+ });
+
+ this.fetchers[identifier + url] = fetcher;
+ } else {
+ Log.log(`Use existing calendarfetcher for url: ${url}`);
+ fetcher = this.fetchers[identifier + url];
+ fetcher.broadcastEvents();
+ }
+
+ fetcher.startFetch();
+ },
+
+ /**
+ *
+ * @param {object} fetcher the fetcher associated with the calendar
+ * @param {string} identifier the identifier of the calendar
+ */
+ broadcastEvents: function (fetcher, identifier) {
+ this.sendSocketNotification("CALENDAR_EVENTS", {
+ id: identifier,
+ url: fetcher.url(),
+ events: fetcher.events()
+ });
+ }
+});
diff --git a/mm/mounts/modules/default/calendar/windowsZones.json b/mm/mounts/modules/default/calendar/windowsZones.json
new file mode 100644
index 0000000..cad82bb
--- /dev/null
+++ b/mm/mounts/modules/default/calendar/windowsZones.json
@@ -0,0 +1,237 @@
+{
+ "Dateline Standard Time": { "iana": ["Etc/GMT+12"] },
+ "UTC-11": { "iana": ["Etc/GMT+11"] },
+ "Aleutian Standard Time": { "iana": ["America/Adak"] },
+ "Hawaiian Standard Time": { "iana": ["Pacific/Honolulu"] },
+ "Marquesas Standard Time": { "iana": ["Pacific/Marquesas"] },
+ "Alaskan Standard Time": { "iana": ["America/Anchorage"] },
+ "UTC-09": { "iana": ["Etc/GMT+9"] },
+ "Pacific Standard Time (Mexico)": { "iana": ["America/Tijuana"] },
+ "UTC-08": { "iana": ["Etc/GMT+8"] },
+ "Pacific Standard Time": { "iana": ["America/Los_Angeles"] },
+ "US Mountain Standard Time": { "iana": ["America/Phoenix"] },
+ "Mountain Standard Time (Mexico)": { "iana": ["America/Chihuahua"] },
+ "Mountain Standard Time": { "iana": ["America/Denver"] },
+ "Central America Standard Time": { "iana": ["America/Guatemala"] },
+ "Central Standard Time": { "iana": ["America/Chicago"] },
+ "Easter Island Standard Time": { "iana": ["Pacific/Easter"] },
+ "Central Standard Time (Mexico)": { "iana": ["America/Mexico_City"] },
+ "Canada Central Standard Time": { "iana": ["America/Regina"] },
+ "SA Pacific Standard Time": { "iana": ["America/Bogota"] },
+ "Eastern Standard Time (Mexico)": { "iana": ["America/Cancun"] },
+ "Eastern Standard Time": { "iana": ["America/New_York"] },
+ "Haiti Standard Time": { "iana": ["America/Port-au-Prince"] },
+ "Cuba Standard Time": { "iana": ["America/Havana"] },
+ "US Eastern Standard Time": { "iana": ["America/Indianapolis"] },
+ "Turks And Caicos Standard Time": { "iana": ["America/Grand_Turk"] },
+ "Paraguay Standard Time": { "iana": ["America/Asuncion"] },
+ "Atlantic Standard Time": { "iana": ["America/Halifax"] },
+ "Venezuela Standard Time": { "iana": ["America/Caracas"] },
+ "Central Brazilian Standard Time": { "iana": ["America/Cuiaba"] },
+ "SA Western Standard Time": { "iana": ["America/La_Paz"] },
+ "Pacific SA Standard Time": { "iana": ["America/Santiago"] },
+ "Newfoundland Standard Time": { "iana": ["America/St_Johns"] },
+ "Tocantins Standard Time": { "iana": ["America/Araguaina"] },
+ "E. South America Standard Time": { "iana": ["America/Sao_Paulo"] },
+ "SA Eastern Standard Time": { "iana": ["America/Cayenne"] },
+ "Argentina Standard Time": { "iana": ["America/Buenos_Aires"] },
+ "Greenland Standard Time": { "iana": ["America/Godthab"] },
+ "Montevideo Standard Time": { "iana": ["America/Montevideo"] },
+ "Magallanes Standard Time": { "iana": ["America/Punta_Arenas"] },
+ "Saint Pierre Standard Time": { "iana": ["America/Miquelon"] },
+ "Bahia Standard Time": { "iana": ["America/Bahia"] },
+ "UTC-02": { "iana": ["Etc/GMT+2"] },
+ "Azores Standard Time": { "iana": ["Atlantic/Azores"] },
+ "Cape Verde Standard Time": { "iana": ["Atlantic/Cape_Verde"] },
+ "UTC": { "iana": ["Etc/GMT"] },
+ "GMT Standard Time": { "iana": ["Europe/London"] },
+ "Greenwich Standard Time": { "iana": ["Atlantic/Reykjavik"] },
+ "Sao Tome Standard Time": { "iana": ["Africa/Sao_Tome"] },
+ "Morocco Standard Time": { "iana": ["Africa/Casablanca"] },
+ "W. Europe Standard Time": { "iana": ["Europe/Berlin"] },
+ "Central Europe Standard Time": { "iana": ["Europe/Budapest"] },
+ "Romance Standard Time": { "iana": ["Europe/Paris"] },
+ "Central European Standard Time": { "iana": ["Europe/Warsaw"] },
+ "W. Central Africa Standard Time": { "iana": ["Africa/Lagos"] },
+ "Jordan Standard Time": { "iana": ["Asia/Amman"] },
+ "GTB Standard Time": { "iana": ["Europe/Bucharest"] },
+ "Middle East Standard Time": { "iana": ["Asia/Beirut"] },
+ "Egypt Standard Time": { "iana": ["Africa/Cairo"] },
+ "E. Europe Standard Time": { "iana": ["Europe/Chisinau"] },
+ "Syria Standard Time": { "iana": ["Asia/Damascus"] },
+ "West Bank Standard Time": { "iana": ["Asia/Hebron"] },
+ "South Africa Standard Time": { "iana": ["Africa/Johannesburg"] },
+ "FLE Standard Time": { "iana": ["Europe/Kiev"] },
+ "Israel Standard Time": { "iana": ["Asia/Jerusalem"] },
+ "Kaliningrad Standard Time": { "iana": ["Europe/Kaliningrad"] },
+ "Sudan Standard Time": { "iana": ["Africa/Khartoum"] },
+ "Libya Standard Time": { "iana": ["Africa/Tripoli"] },
+ "Namibia Standard Time": { "iana": ["Africa/Windhoek"] },
+ "Arabic Standard Time": { "iana": ["Asia/Baghdad"] },
+ "Turkey Standard Time": { "iana": ["Europe/Istanbul"] },
+ "Arab Standard Time": { "iana": ["Asia/Riyadh"] },
+ "Belarus Standard Time": { "iana": ["Europe/Minsk"] },
+ "Russian Standard Time": { "iana": ["Europe/Moscow"] },
+ "E. Africa Standard Time": { "iana": ["Africa/Nairobi"] },
+ "Iran Standard Time": { "iana": ["Asia/Tehran"] },
+ "Arabian Standard Time": { "iana": ["Asia/Dubai"] },
+ "Astrakhan Standard Time": { "iana": ["Europe/Astrakhan"] },
+ "Azerbaijan Standard Time": { "iana": ["Asia/Baku"] },
+ "Russia Time Zone 3": { "iana": ["Europe/Samara"] },
+ "Mauritius Standard Time": { "iana": ["Indian/Mauritius"] },
+ "Saratov Standard Time": { "iana": ["Europe/Saratov"] },
+ "Georgian Standard Time": { "iana": ["Asia/Tbilisi"] },
+ "Volgograd Standard Time": { "iana": ["Europe/Volgograd"] },
+ "Caucasus Standard Time": { "iana": ["Asia/Yerevan"] },
+ "Afghanistan Standard Time": { "iana": ["Asia/Kabul"] },
+ "West Asia Standard Time": { "iana": ["Asia/Tashkent"] },
+ "Ekaterinburg Standard Time": { "iana": ["Asia/Yekaterinburg"] },
+ "Pakistan Standard Time": { "iana": ["Asia/Karachi"] },
+ "Qyzylorda Standard Time": { "iana": ["Asia/Qyzylorda"] },
+ "India Standard Time": { "iana": ["Asia/Calcutta"] },
+ "Sri Lanka Standard Time": { "iana": ["Asia/Colombo"] },
+ "Nepal Standard Time": { "iana": ["Asia/Katmandu"] },
+ "Central Asia Standard Time": { "iana": ["Asia/Almaty"] },
+ "Bangladesh Standard Time": { "iana": ["Asia/Dhaka"] },
+ "Omsk Standard Time": { "iana": ["Asia/Omsk"] },
+ "Myanmar Standard Time": { "iana": ["Asia/Rangoon"] },
+ "SE Asia Standard Time": { "iana": ["Asia/Bangkok"] },
+ "Altai Standard Time": { "iana": ["Asia/Barnaul"] },
+ "W. Mongolia Standard Time": { "iana": ["Asia/Hovd"] },
+ "North Asia Standard Time": { "iana": ["Asia/Krasnoyarsk"] },
+ "N. Central Asia Standard Time": { "iana": ["Asia/Novosibirsk"] },
+ "Tomsk Standard Time": { "iana": ["Asia/Tomsk"] },
+ "China Standard Time": { "iana": ["Asia/Shanghai"] },
+ "North Asia East Standard Time": { "iana": ["Asia/Irkutsk"] },
+ "Singapore Standard Time": { "iana": ["Asia/Singapore"] },
+ "W. Australia Standard Time": { "iana": ["Australia/Perth"] },
+ "Taipei Standard Time": { "iana": ["Asia/Taipei"] },
+ "Ulaanbaatar Standard Time": { "iana": ["Asia/Ulaanbaatar"] },
+ "Aus Central W. Standard Time": { "iana": ["Australia/Eucla"] },
+ "Transbaikal Standard Time": { "iana": ["Asia/Chita"] },
+ "Tokyo Standard Time": { "iana": ["Asia/Tokyo"] },
+ "North Korea Standard Time": { "iana": ["Asia/Pyongyang"] },
+ "Korea Standard Time": { "iana": ["Asia/Seoul"] },
+ "Yakutsk Standard Time": { "iana": ["Asia/Yakutsk"] },
+ "Cen. Australia Standard Time": { "iana": ["Australia/Adelaide"] },
+ "AUS Central Standard Time": { "iana": ["Australia/Darwin"] },
+ "E. Australia Standard Time": { "iana": ["Australia/Brisbane"] },
+ "AUS Eastern Standard Time": { "iana": ["Australia/Sydney"] },
+ "West Pacific Standard Time": { "iana": ["Pacific/Port_Moresby"] },
+ "Tasmania Standard Time": { "iana": ["Australia/Hobart"] },
+ "Vladivostok Standard Time": { "iana": ["Asia/Vladivostok"] },
+ "Lord Howe Standard Time": { "iana": ["Australia/Lord_Howe"] },
+ "Bougainville Standard Time": { "iana": ["Pacific/Bougainville"] },
+ "Russia Time Zone 10": { "iana": ["Asia/Srednekolymsk"] },
+ "Magadan Standard Time": { "iana": ["Asia/Magadan"] },
+ "Norfolk Standard Time": { "iana": ["Pacific/Norfolk"] },
+ "Sakhalin Standard Time": { "iana": ["Asia/Sakhalin"] },
+ "Central Pacific Standard Time": { "iana": ["Pacific/Guadalcanal"] },
+ "Russia Time Zone 11": { "iana": ["Asia/Kamchatka"] },
+ "New Zealand Standard Time": { "iana": ["Pacific/Auckland"] },
+ "UTC+12": { "iana": ["Etc/GMT-12"] },
+ "Fiji Standard Time": { "iana": ["Pacific/Fiji"] },
+ "Chatham Islands Standard Time": { "iana": ["Pacific/Chatham"] },
+ "UTC+13": { "iana": ["Etc/GMT-13"] },
+ "Tonga Standard Time": { "iana": ["Pacific/Tongatapu"] },
+ "Samoa Standard Time": { "iana": ["Pacific/Apia"] },
+ "Line Islands Standard Time": { "iana": ["Pacific/Kiritimati"] },
+ "(UTC-12:00) International Date Line West": { "iana": ["Etc/GMT+12"] },
+ "(UTC-11:00) Midway Island, Samoa": { "iana": ["Pacific/Apia"] },
+ "(UTC-10:00) Hawaii": { "iana": ["Pacific/Honolulu"] },
+ "(UTC-09:00) Alaska": { "iana": ["America/Anchorage"] },
+ "(UTC-08:00) Pacific Time (US & Canada); Tijuana": { "iana": ["America/Los_Angeles"] },
+ "(UTC-08:00) Pacific Time (US and Canada); Tijuana": { "iana": ["America/Los_Angeles"] },
+ "(UTC-07:00) Mountain Time (US & Canada)": { "iana": ["America/Denver"] },
+ "(UTC-07:00) Mountain Time (US and Canada)": { "iana": ["America/Denver"] },
+ "(UTC-07:00) Chihuahua, La Paz, Mazatlan": { "iana": [null] },
+ "(UTC-07:00) Arizona": { "iana": ["America/Phoenix"] },
+ "(UTC-06:00) Central Time (US & Canada)": { "iana": ["America/Chicago"] },
+ "(UTC-06:00) Central Time (US and Canada)": { "iana": ["America/Chicago"] },
+ "(UTC-06:00) Saskatchewan": { "iana": ["America/Regina"] },
+ "(UTC-06:00) Guadalajara, Mexico City, Monterrey": { "iana": [null] },
+ "(UTC-06:00) Central America": { "iana": ["America/Guatemala"] },
+ "(UTC-05:00) Eastern Time (US & Canada)": { "iana": ["America/New_York"] },
+ "(UTC-05:00) Eastern Time (US and Canada)": { "iana": ["America/New_York"] },
+ "(UTC-05:00) Indiana (East)": { "iana": ["America/Indianapolis"] },
+ "(UTC-05:00) Bogota, Lima, Quito": { "iana": ["America/Bogota"] },
+ "(UTC-04:00) Atlantic Time (Canada)": { "iana": ["America/Halifax"] },
+ "(UTC-04:00) Georgetown, La Paz, San Juan": { "iana": ["America/La_Paz"] },
+ "(UTC-04:00) Santiago": { "iana": ["America/Santiago"] },
+ "(UTC-03:30) Newfoundland": { "iana": [null] },
+ "(UTC-03:00) Brasilia": { "iana": ["America/Sao_Paulo"] },
+ "(UTC-03:00) Georgetown": { "iana": ["America/Cayenne"] },
+ "(UTC-03:00) Greenland": { "iana": ["America/Godthab"] },
+ "(UTC-02:00) Mid-Atlantic": { "iana": [null] },
+ "(UTC-01:00) Azores": { "iana": ["Atlantic/Azores"] },
+ "(UTC-01:00) Cape Verde Islands": { "iana": ["Atlantic/Cape_Verde"] },
+ "(UTC) Greenwich Mean Time: Dublin, Edinburgh, Lisbon, London": { "iana": [null] },
+ "(UTC) Monrovia, Reykjavik": { "iana": ["Atlantic/Reykjavik"] },
+ "(UTC+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague": { "iana": ["Europe/Budapest"] },
+ "(UTC+01:00) Sarajevo, Skopje, Warsaw, Zagreb": { "iana": ["Europe/Warsaw"] },
+ "(UTC+01:00) Brussels, Copenhagen, Madrid, Paris": { "iana": ["Europe/Paris"] },
+ "(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna": { "iana": ["Europe/Berlin"] },
+ "(UTC+01:00) West Central Africa": { "iana": ["Africa/Lagos"] },
+ "(UTC+02:00) Minsk": { "iana": ["Europe/Chisinau"] },
+ "(UTC+02:00) Cairo": { "iana": ["Africa/Cairo"] },
+ "(UTC+02:00) Helsinki, Kiev, Riga, Sofia, Tallinn, Vilnius": { "iana": ["Europe/Kiev"] },
+ "(UTC+02:00) Athens, Bucharest, Istanbul": { "iana": ["Europe/Bucharest"] },
+ "(UTC+02:00) Jerusalem": { "iana": ["Asia/Jerusalem"] },
+ "(UTC+02:00) Harare, Pretoria": { "iana": ["Africa/Johannesburg"] },
+ "(UTC+03:00) Moscow, St. Petersburg, Volgograd": { "iana": ["Europe/Moscow"] },
+ "(UTC+03:00) Kuwait, Riyadh": { "iana": ["Asia/Riyadh"] },
+ "(UTC+03:00) Nairobi": { "iana": ["Africa/Nairobi"] },
+ "(UTC+03:00) Baghdad": { "iana": ["Asia/Baghdad"] },
+ "(UTC+03:30) Tehran": { "iana": ["Asia/Tehran"] },
+ "(UTC+04:00) Abu Dhabi, Muscat": { "iana": ["Asia/Dubai"] },
+ "(UTC+04:00) Baku, Tbilisi, Yerevan": { "iana": ["Asia/Yerevan"] },
+ "(UTC+04:30) Kabul": { "iana": [null] },
+ "(UTC+05:00) Ekaterinburg": { "iana": ["Asia/Yekaterinburg"] },
+ "(UTC+05:00) Tashkent": { "iana": ["Asia/Tashkent"] },
+ "(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi": { "iana": ["Asia/Calcutta"] },
+ "(UTC+05:45) Kathmandu": { "iana": ["Asia/Katmandu"] },
+ "(UTC+06:00) Astana, Dhaka": { "iana": ["Asia/Almaty"] },
+ "(UTC+06:00) Sri Jayawardenepura": { "iana": ["Asia/Colombo"] },
+ "(UTC+06:00) Almaty, Novosibirsk": { "iana": ["Asia/Novosibirsk"] },
+ "(UTC+06:30) Yangon (Rangoon)": { "iana": ["Asia/Rangoon"] },
+ "(UTC+07:00) Bangkok, Hanoi, Jakarta": { "iana": ["Asia/Bangkok"] },
+ "(UTC+07:00) Krasnoyarsk": { "iana": ["Asia/Krasnoyarsk"] },
+ "(UTC+08:00) Beijing, Chongqing, Hong Kong, Urumqi": { "iana": ["Asia/Shanghai"] },
+ "(UTC+08:00) Kuala Lumpur, Singapore": { "iana": ["Asia/Singapore"] },
+ "(UTC+08:00) Taipei": { "iana": ["Asia/Taipei"] },
+ "(UTC+08:00) Perth": { "iana": ["Australia/Perth"] },
+ "(UTC+08:00) Irkutsk, Ulaanbaatar": { "iana": ["Asia/Irkutsk"] },
+ "(UTC+09:00) Seoul": { "iana": ["Asia/Seoul"] },
+ "(UTC+09:00) Osaka, Sapporo, Tokyo": { "iana": ["Asia/Tokyo"] },
+ "(UTC+09:00) Yakutsk": { "iana": ["Asia/Yakutsk"] },
+ "(UTC+09:30) Darwin": { "iana": ["Australia/Darwin"] },
+ "(UTC+09:30) Adelaide": { "iana": ["Australia/Adelaide"] },
+ "(UTC+10:00) Canberra, Melbourne, Sydney": { "iana": ["Australia/Sydney"] },
+ "(GMT+10:00) Canberra, Melbourne, Sydney": { "iana": ["Australia/Sydney"] },
+ "(UTC+10:00) Brisbane": { "iana": ["Australia/Brisbane"] },
+ "(UTC+10:00) Hobart": { "iana": ["Australia/Hobart"] },
+ "(UTC+10:00) Vladivostok": { "iana": ["Asia/Vladivostok"] },
+ "(UTC+10:00) Guam, Port Moresby": { "iana": ["Pacific/Port_Moresby"] },
+ "(UTC+11:00) Magadan, Solomon Islands, New Caledonia": { "iana": ["Pacific/Guadalcanal"] },
+ "(UTC+12:00) Fiji, Kamchatka, Marshall Is.": { "iana": [null] },
+ "(UTC+12:00) Auckland, Wellington": { "iana": ["Pacific/Auckland"] },
+ "(UTC+13:00) Nuku'alofa": { "iana": ["Pacific/Tongatapu"] },
+ "(UTC-03:00) Buenos Aires": { "iana": ["America/Buenos_Aires"] },
+ "(UTC+02:00) Beirut": { "iana": ["Asia/Beirut"] },
+ "(UTC+02:00) Amman": { "iana": ["Asia/Amman"] },
+ "(UTC-06:00) Guadalajara, Mexico City, Monterrey - New": { "iana": ["America/Mexico_City"] },
+ "(UTC-07:00) Chihuahua, La Paz, Mazatlan - New": { "iana": ["America/Chihuahua"] },
+ "(UTC-08:00) Tijuana, Baja California": { "iana": ["America/Tijuana"] },
+ "(UTC+02:00) Windhoek": { "iana": ["Africa/Windhoek"] },
+ "(UTC+03:00) Tbilisi": { "iana": ["Asia/Tbilisi"] },
+ "(UTC-04:00) Manaus": { "iana": ["America/Cuiaba"] },
+ "(UTC-03:00) Montevideo": { "iana": ["America/Montevideo"] },
+ "(UTC+04:00) Yerevan": { "iana": [null] },
+ "(UTC-04:30) Caracas": { "iana": ["America/Caracas"] },
+ "(UTC) Casablanca": { "iana": ["Africa/Casablanca"] },
+ "(UTC+05:00) Islamabad, Karachi": { "iana": ["Asia/Karachi"] },
+ "(UTC+04:00) Port Louis": { "iana": ["Indian/Mauritius"] },
+ "(UTC) Coordinated Universal Time": { "iana": ["Etc/GMT"] },
+ "(UTC-04:00) Asuncion": { "iana": ["America/Asuncion"] },
+ "(UTC+12:00) Petropavlovsk-Kamchatsky": { "iana": [null] }
+}
diff --git a/mm/mounts/modules/default/clock/README.md b/mm/mounts/modules/default/clock/README.md
new file mode 100644
index 0000000..16703eb
--- /dev/null
+++ b/mm/mounts/modules/default/clock/README.md
@@ -0,0 +1,6 @@
+# Module: Clock
+
+The `clock` module is one of the default modules of the MagicMirror².
+This module displays the current date and time. The information will be updated realtime.
+
+For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/clock.html).
diff --git a/mm/mounts/modules/default/clock/clock.js b/mm/mounts/modules/default/clock/clock.js
new file mode 100644
index 0000000..c063d4d
--- /dev/null
+++ b/mm/mounts/modules/default/clock/clock.js
@@ -0,0 +1,306 @@
+/* global SunCalc, formatTime */
+
+/* MagicMirror²
+ * Module: Clock
+ *
+ * By Michael Teeuw https://michaelteeuw.nl
+ * MIT Licensed.
+ */
+Module.register("clock", {
+ // Module config defaults.
+ defaults: {
+ displayType: "digital", // options: digital, analog, both
+
+ timeFormat: config.timeFormat,
+ timezone: null,
+
+ displaySeconds: true,
+ showPeriod: true,
+ showPeriodUpper: false,
+ clockBold: false,
+ showDate: true,
+ showTime: true,
+ showWeek: false,
+ dateFormat: "dddd, LL",
+ sendNotifications: false,
+
+ /* specific to the analog clock */
+ analogSize: "200px",
+ analogFace: "simple", // options: 'none', 'simple', 'face-###' (where ### is 001 to 012 inclusive)
+ analogPlacement: "bottom", // options: 'top', 'bottom', 'left', 'right'
+ analogShowDate: "top", // OBSOLETE, can be replaced with analogPlacement and showTime, options: false, 'top', or 'bottom'
+ secondsColor: "#888888",
+
+ showSunTimes: false,
+ showMoonTimes: false,
+ lat: 47.630539,
+ lon: -122.344147
+ },
+ // Define required scripts.
+ getScripts: function () {
+ return ["moment.js", "moment-timezone.js", "suncalc.js"];
+ },
+ // Define styles.
+ getStyles: function () {
+ return ["clock_styles.css"];
+ },
+ // Define start sequence.
+ start: function () {
+ Log.info(`Starting module: ${this.name}`);
+
+ // Schedule update interval.
+ this.second = moment().second();
+ this.minute = moment().minute();
+
+ // Calculate how many ms should pass until next update depending on if seconds is displayed or not
+ const delayCalculator = (reducedSeconds) => {
+ const EXTRA_DELAY = 50; // Deliberate imperceptible delay to prevent off-by-one timekeeping errors
+
+ if (this.config.displaySeconds) {
+ return 1000 - moment().milliseconds() + EXTRA_DELAY;
+ } else {
+ return (60 - reducedSeconds) * 1000 - moment().milliseconds() + EXTRA_DELAY;
+ }
+ };
+
+ // A recursive timeout function instead of interval to avoid drifting
+ const notificationTimer = () => {
+ this.updateDom();
+
+ if (this.config.sendNotifications) {
+ // If seconds is displayed CLOCK_SECOND-notification should be sent (but not when CLOCK_MINUTE-notification is sent)
+ if (this.config.displaySeconds) {
+ this.second = moment().second();
+ if (this.second !== 0) {
+ this.sendNotification("CLOCK_SECOND", this.second);
+ setTimeout(notificationTimer, delayCalculator(0));
+ return;
+ }
+ }
+
+ // If minute changed or seconds isn't displayed send CLOCK_MINUTE-notification
+ this.minute = moment().minute();
+ this.sendNotification("CLOCK_MINUTE", this.minute);
+ }
+
+ setTimeout(notificationTimer, delayCalculator(0));
+ };
+
+ // Set the initial timeout with the amount of seconds elapsed as
+ // reducedSeconds, so it will trigger when the minute changes
+ setTimeout(notificationTimer, delayCalculator(this.second));
+
+ // Set locale.
+ moment.locale(config.language);
+ },
+ // Override dom generator.
+ getDom: function () {
+ const wrapper = document.createElement("div");
+ wrapper.classList.add("clock-grid");
+
+ /************************************
+ * Create wrappers for analog and digital clock
+ */
+ const analogWrapper = document.createElement("div");
+ analogWrapper.className = "clock-circle";
+ const digitalWrapper = document.createElement("div");
+ digitalWrapper.className = "digital";
+ digitalWrapper.style.gridArea = "center";
+
+ /************************************
+ * Create wrappers for DIGITAL clock
+ */
+ const dateWrapper = document.createElement("div");
+ const timeWrapper = document.createElement("div");
+ const secondsWrapper = document.createElement("sup");
+ const periodWrapper = document.createElement("span");
+ const sunWrapper = document.createElement("div");
+ const moonWrapper = document.createElement("div");
+ const weekWrapper = document.createElement("div");
+
+ // Style Wrappers
+ dateWrapper.className = "date normal medium";
+ timeWrapper.className = "time bright large light";
+ secondsWrapper.className = "seconds dimmed";
+ sunWrapper.className = "sun dimmed small";
+ moonWrapper.className = "moon dimmed small";
+ weekWrapper.className = "week dimmed medium";
+
+ // Set content of wrappers.
+ // The moment().format("h") method has a bug on the Raspberry Pi.
+ // So we need to generate the timestring manually.
+ // See issue: https://github.com/MichMich/MagicMirror/issues/181
+ let timeString;
+ const now = moment();
+ if (this.config.timezone) {
+ now.tz(this.config.timezone);
+ }
+
+ let hourSymbol = "HH";
+ if (this.config.timeFormat !== 24) {
+ hourSymbol = "h";
+ }
+
+ if (this.config.clockBold) {
+ timeString = now.format(`${hourSymbol}[]mm[]`);
+ } else {
+ timeString = now.format(`${hourSymbol}:mm`);
+ }
+
+ if (this.config.showDate) {
+ dateWrapper.innerHTML = now.format(this.config.dateFormat);
+ digitalWrapper.appendChild(dateWrapper);
+ }
+
+ if (this.config.displayType !== "analog" && this.config.showTime) {
+ timeWrapper.innerHTML = timeString;
+ secondsWrapper.innerHTML = now.format("ss");
+ if (this.config.showPeriodUpper) {
+ periodWrapper.innerHTML = now.format("A");
+ } else {
+ periodWrapper.innerHTML = now.format("a");
+ }
+ if (this.config.displaySeconds) {
+ timeWrapper.appendChild(secondsWrapper);
+ }
+ if (this.config.showPeriod && this.config.timeFormat !== 24) {
+ timeWrapper.appendChild(periodWrapper);
+ }
+ digitalWrapper.appendChild(timeWrapper);
+ }
+
+ /****************************************************************
+ * Create wrappers for Sun Times, only if specified in config
+ */
+ if (this.config.showSunTimes) {
+ const sunTimes = SunCalc.getTimes(now, this.config.lat, this.config.lon);
+ const isVisible = now.isBetween(sunTimes.sunrise, sunTimes.sunset);
+ let nextEvent;
+ if (now.isBefore(sunTimes.sunrise)) {
+ nextEvent = sunTimes.sunrise;
+ } else if (now.isBefore(sunTimes.sunset)) {
+ nextEvent = sunTimes.sunset;
+ } else {
+ const tomorrowSunTimes = SunCalc.getTimes(now.clone().add(1, "day"), this.config.lat, this.config.lon);
+ nextEvent = tomorrowSunTimes.sunrise;
+ }
+ const untilNextEvent = moment.duration(moment(nextEvent).diff(now));
+ const untilNextEventString = `${untilNextEvent.hours()}h ${untilNextEvent.minutes()}m`;
+ sunWrapper.innerHTML =
+ ` ${untilNextEventString}` +
+ ` ${formatTime(this.config, sunTimes.sunrise)}` +
+ ` ${formatTime(this.config, sunTimes.sunset)}`;
+ digitalWrapper.appendChild(sunWrapper);
+ }
+
+ /****************************************************************
+ * Create wrappers for Moon Times, only if specified in config
+ */
+ if (this.config.showMoonTimes) {
+ const moonIllumination = SunCalc.getMoonIllumination(now.toDate());
+ const moonTimes = SunCalc.getMoonTimes(now, this.config.lat, this.config.lon);
+ const moonRise = moonTimes.rise;
+ let moonSet;
+ if (moment(moonTimes.set).isAfter(moonTimes.rise)) {
+ moonSet = moonTimes.set;
+ } else {
+ const nextMoonTimes = SunCalc.getMoonTimes(now.clone().add(1, "day"), this.config.lat, this.config.lon);
+ moonSet = nextMoonTimes.set;
+ }
+ const isVisible = now.isBetween(moonRise, moonSet) || moonTimes.alwaysUp === true;
+ const illuminatedFractionString = `${Math.round(moonIllumination.fraction * 100)}%`;
+ moonWrapper.innerHTML =
+ ` ${illuminatedFractionString}` +
+ ` ${moonRise ? formatTime(this.config, moonRise) : "..."}` +
+ ` ${moonSet ? formatTime(this.config, moonSet) : "..."}`;
+ digitalWrapper.appendChild(moonWrapper);
+ }
+
+ if (this.config.showWeek) {
+ weekWrapper.innerHTML = this.translate("WEEK", { weekNumber: now.week() });
+ digitalWrapper.appendChild(weekWrapper);
+ }
+
+ /****************************************************************
+ * Create wrappers for ANALOG clock, only if specified in config
+ */
+ if (this.config.displayType !== "digital") {
+ // If it isn't 'digital', then an 'analog' clock was also requested
+
+ // Calculate the degree offset for each hand of the clock
+ if (this.config.timezone) {
+ now.tz(this.config.timezone);
+ }
+ const second = now.seconds() * 6,
+ minute = now.minute() * 6 + second / 60,
+ hour = ((now.hours() % 12) / 12) * 360 + 90 + minute / 12;
+
+ // Create wrappers
+ analogWrapper.style.width = this.config.analogSize;
+ analogWrapper.style.height = this.config.analogSize;
+
+ if (this.config.analogFace !== "" && this.config.analogFace !== "simple" && this.config.analogFace !== "none") {
+ analogWrapper.style.background = `url(${this.data.path}faces/${this.config.analogFace}.svg)`;
+ analogWrapper.style.backgroundSize = "100%";
+
+ // The following line solves issue: https://github.com/MichMich/MagicMirror/issues/611
+ // analogWrapper.style.border = "1px solid black";
+ analogWrapper.style.border = "rgba(0, 0, 0, 0.1)"; //Updated fix for Issue 611 where non-black backgrounds are used
+ } else if (this.config.analogFace !== "none") {
+ analogWrapper.style.border = "2px solid white";
+ }
+ const clockFace = document.createElement("div");
+ clockFace.className = "clock-face";
+
+ const clockHour = document.createElement("div");
+ clockHour.id = "clock-hour";
+ clockHour.style.transform = `rotate(${hour}deg)`;
+ clockHour.className = "clock-hour";
+ const clockMinute = document.createElement("div");
+ clockMinute.id = "clock-minute";
+ clockMinute.style.transform = `rotate(${minute}deg)`;
+ clockMinute.className = "clock-minute";
+
+ // Combine analog wrappers
+ clockFace.appendChild(clockHour);
+ clockFace.appendChild(clockMinute);
+
+ if (this.config.displaySeconds) {
+ const clockSecond = document.createElement("div");
+ clockSecond.id = "clock-second";
+ clockSecond.style.transform = `rotate(${second}deg)`;
+ clockSecond.className = "clock-second";
+ clockSecond.style.backgroundColor = this.config.secondsColor;
+ clockFace.appendChild(clockSecond);
+ }
+ analogWrapper.appendChild(clockFace);
+ }
+
+ /*******************************************
+ * Update placement, respect old analogShowDate even if it's not needed anymore
+ */
+ if (this.config.displayType === "analog") {
+ // Display only an analog clock
+ if (this.config.showDate) {
+ // Add date to the analog clock
+ dateWrapper.innerHTML = now.format(this.config.dateFormat);
+ wrapper.appendChild(dateWrapper);
+ }
+ if (this.config.analogShowDate === "bottom") {
+ wrapper.classList.add("clock-grid-bottom");
+ } else if (this.config.analogShowDate === "top") {
+ wrapper.classList.add("clock-grid-top");
+ }
+ wrapper.appendChild(analogWrapper);
+ } else if (this.config.displayType === "digital") {
+ wrapper.appendChild(digitalWrapper);
+ } else if (this.config.displayType === "both") {
+ wrapper.classList.add(`clock-grid-${this.config.analogPlacement}`);
+ wrapper.appendChild(analogWrapper);
+ wrapper.appendChild(digitalWrapper);
+ }
+
+ // Return the wrapper to the dom.
+ return wrapper;
+ }
+});
diff --git a/mm/mounts/modules/default/clock/clock_styles.css b/mm/mounts/modules/default/clock/clock_styles.css
new file mode 100644
index 0000000..e938dd2
--- /dev/null
+++ b/mm/mounts/modules/default/clock/clock_styles.css
@@ -0,0 +1,93 @@
+.clock-grid {
+ display: inline-flex;
+ gap: 15px;
+}
+
+.clock-grid-left {
+ flex-direction: row;
+}
+
+.clock-grid-right {
+ flex-direction: row-reverse;
+}
+
+.clock-grid-top {
+ flex-direction: column;
+}
+
+.clock-grid-bottom {
+ flex-direction: column-reverse;
+}
+
+.clock-circle {
+ place-self: center;
+ position: relative;
+ border-radius: 50%;
+ background-size: 100%;
+}
+
+.clock-face {
+ width: 100%;
+ height: 100%;
+}
+
+.clock-face::after {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 6px;
+ height: 6px;
+ margin: -3px 0 0 -3px;
+ background: var(--color-text-bright);
+ border-radius: 3px;
+ content: "";
+ display: block;
+}
+
+.clock-hour {
+ width: 0;
+ height: 0;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin: -2px 0 -2px -25%; /* numbers must match negative length & thickness */
+ padding: 2px 0 2px 25%; /* indicator length & thickness */
+ background: var(--color-text-bright);
+ transform-origin: 100% 50%;
+ border-radius: 3px 0 0 3px;
+}
+
+.clock-minute {
+ width: 0;
+ height: 0;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin: -35% -2px 0; /* numbers must match negative length & thickness */
+ padding: 35% 2px 0; /* indicator length & thickness */
+ background: var(--color-text-bright);
+ transform-origin: 50% 100%;
+ border-radius: 3px 0 0 3px;
+}
+
+.clock-second {
+ width: 0;
+ height: 0;
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ margin: -38% -1px 0 0; /* numbers must match negative length & thickness */
+ padding: 38% 1px 0 0; /* indicator length & thickness */
+ background: var(--color-text);
+ transform-origin: 50% 100%;
+}
+
+.module.clock .sun,
+.module.clock .moon {
+ display: flex;
+}
+
+.module.clock .sun > *,
+.module.clock .moon > * {
+ flex: 1;
+}
diff --git a/mm/mounts/modules/default/clock/faces/face-001.svg b/mm/mounts/modules/default/clock/faces/face-001.svg
new file mode 100644
index 0000000..abd08ce
--- /dev/null
+++ b/mm/mounts/modules/default/clock/faces/face-001.svg
@@ -0,0 +1 @@
+
diff --git a/mm/mounts/modules/default/clock/faces/face-002.svg b/mm/mounts/modules/default/clock/faces/face-002.svg
new file mode 100644
index 0000000..1ec3104
--- /dev/null
+++ b/mm/mounts/modules/default/clock/faces/face-002.svg
@@ -0,0 +1 @@
+
diff --git a/mm/mounts/modules/default/clock/faces/face-003.svg b/mm/mounts/modules/default/clock/faces/face-003.svg
new file mode 100644
index 0000000..7cfeeba
--- /dev/null
+++ b/mm/mounts/modules/default/clock/faces/face-003.svg
@@ -0,0 +1 @@
+
diff --git a/mm/mounts/modules/default/clock/faces/face-004.svg b/mm/mounts/modules/default/clock/faces/face-004.svg
new file mode 100644
index 0000000..bc97588
--- /dev/null
+++ b/mm/mounts/modules/default/clock/faces/face-004.svg
@@ -0,0 +1 @@
+
diff --git a/mm/mounts/modules/default/clock/faces/face-005.svg b/mm/mounts/modules/default/clock/faces/face-005.svg
new file mode 100644
index 0000000..0bc1b43
--- /dev/null
+++ b/mm/mounts/modules/default/clock/faces/face-005.svg
@@ -0,0 +1 @@
+
diff --git a/mm/mounts/modules/default/clock/faces/face-006.svg b/mm/mounts/modules/default/clock/faces/face-006.svg
new file mode 100644
index 0000000..63d1c93
--- /dev/null
+++ b/mm/mounts/modules/default/clock/faces/face-006.svg
@@ -0,0 +1 @@
+
diff --git a/mm/mounts/modules/default/clock/faces/face-007.svg b/mm/mounts/modules/default/clock/faces/face-007.svg
new file mode 100644
index 0000000..e557f55
--- /dev/null
+++ b/mm/mounts/modules/default/clock/faces/face-007.svg
@@ -0,0 +1 @@
+
diff --git a/mm/mounts/modules/default/clock/faces/face-008.svg b/mm/mounts/modules/default/clock/faces/face-008.svg
new file mode 100644
index 0000000..6fadb39
--- /dev/null
+++ b/mm/mounts/modules/default/clock/faces/face-008.svg
@@ -0,0 +1 @@
+
diff --git a/mm/mounts/modules/default/clock/faces/face-009.svg b/mm/mounts/modules/default/clock/faces/face-009.svg
new file mode 100644
index 0000000..bd207e0
--- /dev/null
+++ b/mm/mounts/modules/default/clock/faces/face-009.svg
@@ -0,0 +1 @@
+
diff --git a/mm/mounts/modules/default/clock/faces/face-010.svg b/mm/mounts/modules/default/clock/faces/face-010.svg
new file mode 100644
index 0000000..8c5e584
--- /dev/null
+++ b/mm/mounts/modules/default/clock/faces/face-010.svg
@@ -0,0 +1 @@
+
diff --git a/mm/mounts/modules/default/clock/faces/face-011.svg b/mm/mounts/modules/default/clock/faces/face-011.svg
new file mode 100644
index 0000000..9886fed
--- /dev/null
+++ b/mm/mounts/modules/default/clock/faces/face-011.svg
@@ -0,0 +1 @@
+
diff --git a/mm/mounts/modules/default/clock/faces/face-012.svg b/mm/mounts/modules/default/clock/faces/face-012.svg
new file mode 100644
index 0000000..cfd8069
--- /dev/null
+++ b/mm/mounts/modules/default/clock/faces/face-012.svg
@@ -0,0 +1 @@
+
diff --git a/mm/mounts/modules/default/compliments/README.md b/mm/mounts/modules/default/compliments/README.md
new file mode 100644
index 0000000..57e105f
--- /dev/null
+++ b/mm/mounts/modules/default/compliments/README.md
@@ -0,0 +1,6 @@
+# Module: Compliments
+
+The `compliments` module is one of the default modules of the MagicMirror².
+This module displays a random compliment.
+
+For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/compliments.html).
diff --git a/mm/mounts/modules/default/compliments/compliments.js b/mm/mounts/modules/default/compliments/compliments.js
new file mode 100644
index 0000000..a905581
--- /dev/null
+++ b/mm/mounts/modules/default/compliments/compliments.js
@@ -0,0 +1,181 @@
+/* MagicMirror²
+ * Module: Compliments
+ *
+ * By Michael Teeuw https://michaelteeuw.nl
+ * MIT Licensed.
+ */
+Module.register("compliments", {
+ // Module config defaults.
+ defaults: {
+ compliments: {
+ anytime: ["Hey there sexy!"],
+ morning: ["Good morning, handsome!", "Enjoy your day!", "How was your sleep?"],
+ afternoon: ["Hello, beauty!", "You look sexy!", "Looking good today!"],
+ evening: ["Wow, you look hot!", "You look nice!", "Hi, sexy!"],
+ "....-01-01": ["Happy new year!"]
+ },
+ updateInterval: 30000,
+ remoteFile: null,
+ fadeSpeed: 4000,
+ morningStartTime: 3,
+ morningEndTime: 12,
+ afternoonStartTime: 12,
+ afternoonEndTime: 17,
+ random: true
+ },
+ lastIndexUsed: -1,
+ // Set currentweather from module
+ currentWeatherType: "",
+
+ // Define required scripts.
+ getScripts: function () {
+ return ["moment.js"];
+ },
+
+ // Define start sequence.
+ start: async function () {
+ Log.info(`Starting module: ${this.name}`);
+
+ this.lastComplimentIndex = -1;
+
+ if (this.config.remoteFile !== null) {
+ const response = await this.loadComplimentFile();
+ this.config.compliments = JSON.parse(response);
+ this.updateDom();
+ }
+
+ // Schedule update timer.
+ setInterval(() => {
+ this.updateDom(this.config.fadeSpeed);
+ }, this.config.updateInterval);
+ },
+
+ /**
+ * Generate a random index for a list of compliments.
+ * @param {string[]} compliments Array with compliments.
+ * @returns {number} a random index of given array
+ */
+ randomIndex: function (compliments) {
+ if (compliments.length === 1) {
+ return 0;
+ }
+
+ const generate = function () {
+ return Math.floor(Math.random() * compliments.length);
+ };
+
+ let complimentIndex = generate();
+
+ while (complimentIndex === this.lastComplimentIndex) {
+ complimentIndex = generate();
+ }
+
+ this.lastComplimentIndex = complimentIndex;
+
+ return complimentIndex;
+ },
+
+ /**
+ * Retrieve an array of compliments for the time of the day.
+ * @returns {string[]} array with compliments for the time of the day.
+ */
+ complimentArray: function () {
+ const hour = moment().hour();
+ const date = moment().format("YYYY-MM-DD");
+ let compliments = [];
+
+ // Add time of day compliments
+ if (hour >= this.config.morningStartTime && hour < this.config.morningEndTime && this.config.compliments.hasOwnProperty("morning")) {
+ compliments = [...this.config.compliments.morning];
+ } else if (hour >= this.config.afternoonStartTime && hour < this.config.afternoonEndTime && this.config.compliments.hasOwnProperty("afternoon")) {
+ compliments = [...this.config.compliments.afternoon];
+ } else if (this.config.compliments.hasOwnProperty("evening")) {
+ compliments = [...this.config.compliments.evening];
+ }
+
+ // Add compliments based on weather
+ if (this.currentWeatherType in this.config.compliments) {
+ Array.prototype.push.apply(compliments, this.config.compliments[this.currentWeatherType]);
+ }
+
+ // Add compliments for anytime
+ Array.prototype.push.apply(compliments, this.config.compliments.anytime);
+
+ // Add compliments for special days
+ for (let entry in this.config.compliments) {
+ if (new RegExp(entry).test(date)) {
+ Array.prototype.push.apply(compliments, this.config.compliments[entry]);
+ }
+ }
+
+ return compliments;
+ },
+
+ /**
+ * Retrieve a file from the local filesystem
+ * @returns {Promise} Resolved when the file is loaded
+ */
+ loadComplimentFile: async function () {
+ const isRemote = this.config.remoteFile.indexOf("http://") === 0 || this.config.remoteFile.indexOf("https://") === 0,
+ url = isRemote ? this.config.remoteFile : this.file(this.config.remoteFile);
+ const response = await fetch(url);
+ return await response.text();
+ },
+
+ /**
+ * Retrieve a random compliment.
+ * @returns {string} a compliment
+ */
+ getRandomCompliment: function () {
+ // get the current time of day compliments list
+ const compliments = this.complimentArray();
+ // variable for index to next message to display
+ let index;
+ // are we randomizing
+ if (this.config.random) {
+ // yes
+ index = this.randomIndex(compliments);
+ } else {
+ // no, sequential
+ // if doing sequential, don't fall off the end
+ index = this.lastIndexUsed >= compliments.length - 1 ? 0 : ++this.lastIndexUsed;
+ }
+
+ return compliments[index] || "";
+ },
+
+ // Override dom generator.
+ getDom: function () {
+ const wrapper = document.createElement("div");
+ wrapper.className = this.config.classes ? this.config.classes : "thin xlarge bright pre-line";
+ // get the compliment text
+ const complimentText = this.getRandomCompliment();
+ // split it into parts on newline text
+ const parts = complimentText.split("\n");
+ // create a span to hold the compliment
+ const compliment = document.createElement("span");
+ // process all the parts of the compliment text
+ for (const part of parts) {
+ if (part !== "") {
+ // create a text element for each part
+ compliment.appendChild(document.createTextNode(part));
+ // add a break
+ compliment.appendChild(document.createElement("BR"));
+ }
+ }
+ // only add compliment to wrapper if there is actual text in there
+ if (compliment.children.length > 0) {
+ // remove the last break
+ compliment.lastElementChild.remove();
+ wrapper.appendChild(compliment);
+ }
+ return wrapper;
+ },
+
+ // Override notification handler.
+ notificationReceived: function (notification, payload, sender) {
+ if (notification === "CURRENTWEATHER_TYPE") {
+ this.currentWeatherType = payload.type;
+ }
+ }
+});
diff --git a/mm/mounts/modules/default/defaultmodules.js b/mm/mounts/modules/default/defaultmodules.js
new file mode 100644
index 0000000..c74e94a
--- /dev/null
+++ b/mm/mounts/modules/default/defaultmodules.js
@@ -0,0 +1,12 @@
+/* MagicMirror² Default Modules List
+ * Modules listed below can be loaded without the 'default/' prefix. Omitting the default folder name.
+ *
+ * By Michael Teeuw https://michaelteeuw.nl
+ * MIT Licensed.
+ */
+const defaultModules = ["alert", "calendar", "clock", "compliments", "helloworld", "newsfeed", "updatenotification", "weather"];
+
+/*************** DO NOT EDIT THE LINE BELOW ***************/
+if (typeof module !== "undefined") {
+ module.exports = defaultModules;
+}
diff --git a/mm/mounts/modules/default/helloworld/README.md b/mm/mounts/modules/default/helloworld/README.md
new file mode 100644
index 0000000..065d5f9
--- /dev/null
+++ b/mm/mounts/modules/default/helloworld/README.md
@@ -0,0 +1,5 @@
+# Module: Hello World
+
+The `helloworld` module is one of the default modules of the MagicMirror². It is a simple way to display a static text on the mirror.
+
+For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/helloworld.html).
diff --git a/mm/mounts/modules/default/helloworld/helloworld.js b/mm/mounts/modules/default/helloworld/helloworld.js
new file mode 100644
index 0000000..53357d0
--- /dev/null
+++ b/mm/mounts/modules/default/helloworld/helloworld.js
@@ -0,0 +1,20 @@
+/* MagicMirror²
+ * Module: HelloWorld
+ *
+ * By Michael Teeuw https://michaelteeuw.nl
+ * MIT Licensed.
+ */
+Module.register("helloworld", {
+ // Default module config.
+ defaults: {
+ text: "Hello World!"
+ },
+
+ getTemplate: function () {
+ return "helloworld.njk";
+ },
+
+ getTemplateData: function () {
+ return this.config;
+ }
+});
diff --git a/mm/mounts/modules/default/helloworld/helloworld.njk b/mm/mounts/modules/default/helloworld/helloworld.njk
new file mode 100644
index 0000000..005ca28
--- /dev/null
+++ b/mm/mounts/modules/default/helloworld/helloworld.njk
@@ -0,0 +1,5 @@
+
+
{{text | safe}}
diff --git a/mm/mounts/modules/default/newsfeed/README.md b/mm/mounts/modules/default/newsfeed/README.md
new file mode 100644
index 0000000..0671f13
--- /dev/null
+++ b/mm/mounts/modules/default/newsfeed/README.md
@@ -0,0 +1,6 @@
+# Module: News Feed
+
+The `newsfeed` module is one of the default modules of the MagicMirror².
+This module displays news headlines based on an RSS feed. Scrolling through news headlines happens time-based (`updateInterval`), but can also be controlled by sending news feed specific notifications to the module.
+
+For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/newsfeed.html).
diff --git a/mm/mounts/modules/default/newsfeed/fullarticle.njk b/mm/mounts/modules/default/newsfeed/fullarticle.njk
new file mode 100644
index 0000000..6570396
--- /dev/null
+++ b/mm/mounts/modules/default/newsfeed/fullarticle.njk
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/mm/mounts/modules/default/newsfeed/newsfeed.css b/mm/mounts/modules/default/newsfeed/newsfeed.css
new file mode 100644
index 0000000..2c690a4
--- /dev/null
+++ b/mm/mounts/modules/default/newsfeed/newsfeed.css
@@ -0,0 +1,24 @@
+iframe.newsfeed-fullarticle {
+ width: 100vw;
+
+ /* very large height value to allow scrolling */
+ height: 3000px;
+ top: 0;
+ left: 0;
+ border: none;
+ z-index: 1;
+}
+
+.region.bottom.bar.newsfeed-fullarticle {
+ bottom: inherit;
+ top: -90px;
+}
+
+.newsfeed-list {
+ list-style: none;
+}
+
+.newsfeed-list li {
+ text-align: justify;
+ margin-bottom: 0.5em;
+}
diff --git a/mm/mounts/modules/default/newsfeed/newsfeed.js b/mm/mounts/modules/default/newsfeed/newsfeed.js
new file mode 100644
index 0000000..eee0b44
--- /dev/null
+++ b/mm/mounts/modules/default/newsfeed/newsfeed.js
@@ -0,0 +1,409 @@
+/* MagicMirror²
+ * Module: NewsFeed
+ *
+ * By Michael Teeuw https://michaelteeuw.nl
+ * MIT Licensed.
+ */
+Module.register("newsfeed", {
+ // Default module config.
+ defaults: {
+ feeds: [
+ {
+ title: "New York Times",
+ url: "https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml",
+ encoding: "UTF-8" //ISO-8859-1
+ }
+ ],
+ showAsList: false,
+ showSourceTitle: true,
+ showPublishDate: true,
+ broadcastNewsFeeds: true,
+ broadcastNewsUpdates: true,
+ showDescription: false,
+ showTitleAsUrl: false,
+ wrapTitle: true,
+ wrapDescription: true,
+ truncDescription: true,
+ lengthDescription: 400,
+ hideLoading: false,
+ reloadInterval: 5 * 60 * 1000, // every 5 minutes
+ updateInterval: 10 * 1000,
+ animationSpeed: 2.5 * 1000,
+ maxNewsItems: 0, // 0 for unlimited
+ ignoreOldItems: false,
+ ignoreOlderThan: 24 * 60 * 60 * 1000, // 1 day
+ removeStartTags: "",
+ removeEndTags: "",
+ startTags: [],
+ endTags: [],
+ prohibitedWords: [],
+ scrollLength: 500,
+ logFeedWarnings: false,
+ dangerouslyDisableAutoEscaping: false
+ },
+
+ getUrlPrefix: function (item) {
+ if (item.useCorsProxy) {
+ return `${location.protocol}//${location.host}/cors?url=`;
+ } else {
+ return "";
+ }
+ },
+
+ // Define required scripts.
+ getScripts: function () {
+ return ["moment.js"];
+ },
+
+ //Define required styles.
+ getStyles: function () {
+ return ["newsfeed.css"];
+ },
+
+ // Define required translations.
+ getTranslations: function () {
+ // The translations for the default modules are defined in the core translation files.
+ // Therefor we can just return false. Otherwise we should have returned a dictionary.
+ // If you're trying to build your own module including translations, check out the documentation.
+ return false;
+ },
+
+ // Define start sequence.
+ start: function () {
+ Log.info(`Starting module: ${this.name}`);
+
+ // Set locale.
+ moment.locale(config.language);
+
+ this.newsItems = [];
+ this.loaded = false;
+ this.error = null;
+ this.activeItem = 0;
+ this.scrollPosition = 0;
+
+ this.registerFeeds();
+
+ this.isShowingDescription = this.config.showDescription;
+ },
+
+ // Override socket notification handler.
+ socketNotificationReceived: function (notification, payload) {
+ if (notification === "NEWS_ITEMS") {
+ this.generateFeed(payload);
+
+ if (!this.loaded) {
+ if (this.config.hideLoading) {
+ this.show();
+ }
+ this.scheduleUpdateInterval();
+ }
+
+ this.loaded = true;
+ this.error = null;
+ } else if (notification === "NEWSFEED_ERROR") {
+ this.error = this.translate(payload.error_type);
+ this.scheduleUpdateInterval();
+ }
+ },
+
+ //Override fetching of template name
+ getTemplate: function () {
+ if (this.config.feedUrl) {
+ return "oldconfig.njk";
+ } else if (this.config.showFullArticle) {
+ return "fullarticle.njk";
+ }
+ return "newsfeed.njk";
+ },
+
+ //Override template data and return whats used for the current template
+ getTemplateData: function () {
+ // this.config.showFullArticle is a run-time configuration, triggered by optional notifications
+ if (this.config.showFullArticle) {
+ return {
+ url: this.getActiveItemURL()
+ };
+ }
+ if (this.error) {
+ return {
+ error: this.error
+ };
+ }
+ if (this.newsItems.length === 0) {
+ return {
+ empty: true
+ };
+ }
+ if (this.activeItem >= this.newsItems.length) {
+ this.activeItem = 0;
+ }
+
+ const item = this.newsItems[this.activeItem];
+ const items = this.newsItems.map(function (item) {
+ item.publishDate = moment(new Date(item.pubdate)).fromNow();
+ return item;
+ });
+
+ return {
+ loaded: true,
+ config: this.config,
+ sourceTitle: item.sourceTitle,
+ publishDate: moment(new Date(item.pubdate)).fromNow(),
+ title: item.title,
+ url: this.getUrlPrefix(item) + item.url,
+ description: item.description,
+ items: items
+ };
+ },
+
+ getActiveItemURL: function () {
+ const item = this.newsItems[this.activeItem];
+ if (item) {
+ return typeof item.url === "string" ? this.getUrlPrefix(item) + item.url : this.getUrlPrefix(item) + item.url.href;
+ } else {
+ return "";
+ }
+ },
+
+ /**
+ * Registers the feeds to be used by the backend.
+ */
+ registerFeeds: function () {
+ for (let feed of this.config.feeds) {
+ this.sendSocketNotification("ADD_FEED", {
+ feed: feed,
+ config: this.config
+ });
+ }
+ },
+
+ /**
+ * Generate an ordered list of items for this configured module.
+ * @param {object} feeds An object with feeds returned by the node helper.
+ */
+ generateFeed: function (feeds) {
+ let newsItems = [];
+ for (let feed in feeds) {
+ const feedItems = feeds[feed];
+ if (this.subscribedToFeed(feed)) {
+ for (let item of feedItems) {
+ item.sourceTitle = this.titleForFeed(feed);
+ if (!(this.config.ignoreOldItems && Date.now() - new Date(item.pubdate) > this.config.ignoreOlderThan)) {
+ newsItems.push(item);
+ }
+ }
+ }
+ }
+ newsItems.sort(function (a, b) {
+ const dateA = new Date(a.pubdate);
+ const dateB = new Date(b.pubdate);
+ return dateB - dateA;
+ });
+
+ if (this.config.maxNewsItems > 0) {
+ newsItems = newsItems.slice(0, this.config.maxNewsItems);
+ }
+
+ if (this.config.prohibitedWords.length > 0) {
+ newsItems = newsItems.filter(function (item) {
+ for (let word of this.config.prohibitedWords) {
+ if (item.title.toLowerCase().indexOf(word.toLowerCase()) > -1) {
+ return false;
+ }
+ }
+ return true;
+ }, this);
+ }
+ newsItems.forEach((item) => {
+ //Remove selected tags from the beginning of rss feed items (title or description)
+ if (this.config.removeStartTags === "title" || this.config.removeStartTags === "both") {
+ for (let startTag of this.config.startTags) {
+ if (item.title.slice(0, startTag.length) === startTag) {
+ item.title = item.title.slice(startTag.length, item.title.length);
+ }
+ }
+ }
+
+ if (this.config.removeStartTags === "description" || this.config.removeStartTags === "both") {
+ if (this.isShowingDescription) {
+ for (let startTag of this.config.startTags) {
+ if (item.description.slice(0, startTag.length) === startTag) {
+ item.description = item.description.slice(startTag.length, item.description.length);
+ }
+ }
+ }
+ }
+
+ //Remove selected tags from the end of rss feed items (title or description)
+ if (this.config.removeEndTags) {
+ for (let endTag of this.config.endTags) {
+ if (item.title.slice(-endTag.length) === endTag) {
+ item.title = item.title.slice(0, -endTag.length);
+ }
+ }
+
+ if (this.isShowingDescription) {
+ for (let endTag of this.config.endTags) {
+ if (item.description.slice(-endTag.length) === endTag) {
+ item.description = item.description.slice(0, -endTag.length);
+ }
+ }
+ }
+ }
+ });
+
+ // get updated news items and broadcast them
+ const updatedItems = [];
+ newsItems.forEach((value) => {
+ if (this.newsItems.findIndex((value1) => value1 === value) === -1) {
+ // Add item to updated items list
+ updatedItems.push(value);
+ }
+ });
+
+ // check if updated items exist, if so and if we should broadcast these updates, then lets do so
+ if (this.config.broadcastNewsUpdates && updatedItems.length > 0) {
+ this.sendNotification("NEWS_FEED_UPDATE", { items: updatedItems });
+ }
+
+ this.newsItems = newsItems;
+ },
+
+ /**
+ * Check if this module is configured to show this feed.
+ * @param {string} feedUrl Url of the feed to check.
+ * @returns {boolean} True if it is subscribed, false otherwise
+ */
+ subscribedToFeed: function (feedUrl) {
+ for (let feed of this.config.feeds) {
+ if (feed.url === feedUrl) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Returns title for the specific feed url.
+ * @param {string} feedUrl Url of the feed
+ * @returns {string} The title of the feed
+ */
+ titleForFeed: function (feedUrl) {
+ for (let feed of this.config.feeds) {
+ if (feed.url === feedUrl) {
+ return feed.title || "";
+ }
+ }
+ return "";
+ },
+
+ /**
+ * Schedule visual update.
+ */
+ scheduleUpdateInterval: function () {
+ this.updateDom(this.config.animationSpeed);
+
+ // Broadcast NewsFeed if needed
+ if (this.config.broadcastNewsFeeds) {
+ this.sendNotification("NEWS_FEED", { items: this.newsItems });
+ }
+
+ // #2638 Clear timer if it already exists
+ if (this.timer) clearInterval(this.timer);
+
+ this.timer = setInterval(() => {
+ this.activeItem++;
+ this.updateDom(this.config.animationSpeed);
+
+ // Broadcast NewsFeed if needed
+ if (this.config.broadcastNewsFeeds) {
+ this.sendNotification("NEWS_FEED", { items: this.newsItems });
+ }
+ }, this.config.updateInterval);
+ },
+
+ resetDescrOrFullArticleAndTimer: function () {
+ this.isShowingDescription = this.config.showDescription;
+ this.config.showFullArticle = false;
+ this.scrollPosition = 0;
+ // reset bottom bar alignment
+ document.getElementsByClassName("region bottom bar")[0].classList.remove("newsfeed-fullarticle");
+ if (!this.timer) {
+ this.scheduleUpdateInterval();
+ }
+ },
+
+ notificationReceived: function (notification, payload, sender) {
+ const before = this.activeItem;
+ if (notification === "MODULE_DOM_CREATED" && this.config.hideLoading) {
+ this.hide();
+ } else if (notification === "ARTICLE_NEXT") {
+ this.activeItem++;
+ if (this.activeItem >= this.newsItems.length) {
+ this.activeItem = 0;
+ }
+ this.resetDescrOrFullArticleAndTimer();
+ Log.debug(`${this.name} - going from article #${before} to #${this.activeItem} (of ${this.newsItems.length})`);
+ this.updateDom(100);
+ } else if (notification === "ARTICLE_PREVIOUS") {
+ this.activeItem--;
+ if (this.activeItem < 0) {
+ this.activeItem = this.newsItems.length - 1;
+ }
+ this.resetDescrOrFullArticleAndTimer();
+ Log.debug(`${this.name} - going from article #${before} to #${this.activeItem} (of ${this.newsItems.length})`);
+ this.updateDom(100);
+ }
+ // if "more details" is received the first time: show article summary, on second time show full article
+ else if (notification === "ARTICLE_MORE_DETAILS") {
+ // full article is already showing, so scrolling down
+ if (this.config.showFullArticle === true) {
+ this.scrollPosition += this.config.scrollLength;
+ window.scrollTo(0, this.scrollPosition);
+ Log.debug(`${this.name} - scrolling down`);
+ Log.debug(`${this.name} - ARTICLE_MORE_DETAILS, scroll position: ${this.config.scrollLength}`);
+ } else {
+ this.showFullArticle();
+ }
+ } else if (notification === "ARTICLE_SCROLL_UP") {
+ if (this.config.showFullArticle === true) {
+ this.scrollPosition -= this.config.scrollLength;
+ window.scrollTo(0, this.scrollPosition);
+ Log.debug(`${this.name} - scrolling up`);
+ Log.debug(`${this.name} - ARTICLE_SCROLL_UP, scroll position: ${this.config.scrollLength}`);
+ }
+ } else if (notification === "ARTICLE_LESS_DETAILS") {
+ this.resetDescrOrFullArticleAndTimer();
+ Log.debug(`${this.name} - showing only article titles again`);
+ this.updateDom(100);
+ } else if (notification === "ARTICLE_TOGGLE_FULL") {
+ if (this.config.showFullArticle) {
+ this.activeItem++;
+ this.resetDescrOrFullArticleAndTimer();
+ } else {
+ this.showFullArticle();
+ }
+ } else if (notification === "ARTICLE_INFO_REQUEST") {
+ this.sendNotification("ARTICLE_INFO_RESPONSE", {
+ title: this.newsItems[this.activeItem].title,
+ source: this.newsItems[this.activeItem].sourceTitle,
+ date: this.newsItems[this.activeItem].pubdate,
+ desc: this.newsItems[this.activeItem].description,
+ url: this.getActiveItemURL()
+ });
+ }
+ },
+
+ showFullArticle: function () {
+ this.isShowingDescription = !this.isShowingDescription;
+ this.config.showFullArticle = !this.isShowingDescription;
+ // make bottom bar align to top to allow scrolling
+ if (this.config.showFullArticle === true) {
+ document.getElementsByClassName("region bottom bar")[0].classList.add("newsfeed-fullarticle");
+ }
+ clearInterval(this.timer);
+ this.timer = null;
+ Log.debug(`${this.name} - showing ${this.isShowingDescription ? "article description" : "full article"}`);
+ this.updateDom(100);
+ }
+});
diff --git a/mm/mounts/modules/default/newsfeed/newsfeed.njk b/mm/mounts/modules/default/newsfeed/newsfeed.njk
new file mode 100644
index 0000000..9e7e9d7
--- /dev/null
+++ b/mm/mounts/modules/default/newsfeed/newsfeed.njk
@@ -0,0 +1,93 @@
+{% macro escapeText(text, dangerouslyDisableAutoEscaping=false) %}
+ {% if dangerouslyDisableAutoEscaping %}
+ {{ text | safe}}
+ {% else %}
+ {{ text }}
+ {% endif %}
+{% endmacro %}
+
+{% macro escapeTitle(title, url, dangerouslyDisableAutoEscaping=false, showTitleAsUrl=false) %}
+ {% if dangerouslyDisableAutoEscaping %}
+ {% if showTitleAsUrl %}
+ {{ title | safe }}
+ {% else %}
+ {{ title | safe}}
+ {% endif %}
+ {% else %}
+ {% if showTitleAsUrl %}
+ {{ title }}
+ {% else %}
+ {{ title }}
+ {% endif %}
+ {% endif %}
+{% endmacro %}
+
+{% if loaded %}
+ {% if config.showAsList %}
+
+ {% for item in items %}
+
+ {% if (config.showSourceTitle and item.sourceTitle) or config.showPublishDate %}
+
+ {% if item.sourceTitle and config.showSourceTitle %}
+ {{ item.sourceTitle }}{% if config.showPublishDate %}, {% else %}: {% endif %}
+ {% endif %}
+ {% if config.showPublishDate %}
+ {{ item.publishDate }}:
+ {% endif %}
+
+ {% endfor %}
+{% endif %}
diff --git a/mm/mounts/modules/default/utils.js b/mm/mounts/modules/default/utils.js
new file mode 100644
index 0000000..e60d96e
--- /dev/null
+++ b/mm/mounts/modules/default/utils.js
@@ -0,0 +1,172 @@
+/**
+ * A function to make HTTP requests via the server to avoid CORS-errors.
+ * @param {string} url the url to fetch from
+ * @param {string} type what contenttype to expect in the response, can be "json" or "xml"
+ * @param {boolean} useCorsProxy A flag to indicate
+ * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send
+ * @param {Array.} expectedResponseHeaders the expected HTTP headers to receive
+ * @returns {Promise} resolved when the fetch is done. The response headers is placed in a headers-property (provided the response does not already contain a headers-property).
+ */
+async function performWebRequest(url, type = "json", useCorsProxy = false, requestHeaders = undefined, expectedResponseHeaders = undefined) {
+ const request = {};
+ let requestUrl;
+ if (useCorsProxy) {
+ requestUrl = getCorsUrl(url, requestHeaders, expectedResponseHeaders);
+ } else {
+ requestUrl = url;
+ request.headers = getHeadersToSend(requestHeaders);
+ }
+ const response = await fetch(requestUrl, request);
+ const data = await response.text();
+
+ if (type === "xml") {
+ return new DOMParser().parseFromString(data, "text/html");
+ } else {
+ if (!data || !data.length > 0) return undefined;
+
+ const dataResponse = JSON.parse(data);
+ if (!dataResponse.headers) {
+ dataResponse.headers = getHeadersFromResponse(expectedResponseHeaders, response);
+ }
+ return dataResponse;
+ }
+}
+
+/**
+ * Gets a URL that will be used when calling the CORS-method on the server.
+ * @param {string} url the url to fetch from
+ * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send
+ * @param {Array.} expectedResponseHeaders the expected HTTP headers to receive
+ * @returns {string} to be used as URL when calling CORS-method on server.
+ */
+const getCorsUrl = function (url, requestHeaders, expectedResponseHeaders) {
+ if (!url || url.length < 1) {
+ throw new Error(`Invalid URL: ${url}`);
+ } else {
+ let corsUrl = `${location.protocol}//${location.host}/cors?`;
+
+ const requestHeaderString = getRequestHeaderString(requestHeaders);
+ if (requestHeaderString) corsUrl = `${corsUrl}sendheaders=${requestHeaderString}`;
+
+ const expectedResponseHeadersString = getExpectedResponseHeadersString(expectedResponseHeaders);
+ if (requestHeaderString && expectedResponseHeadersString) {
+ corsUrl = `${corsUrl}&expectedheaders=${expectedResponseHeadersString}`;
+ } else if (expectedResponseHeadersString) {
+ corsUrl = `${corsUrl}expectedheaders=${expectedResponseHeadersString}`;
+ }
+
+ if (requestHeaderString || expectedResponseHeadersString) {
+ return `${corsUrl}&url=${url}`;
+ }
+ return `${corsUrl}url=${url}`;
+ }
+};
+
+/**
+ * Gets the part of the CORS URL that represents the HTTP headers to send.
+ * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send
+ * @returns {string} to be used as request-headers component in CORS URL.
+ */
+const getRequestHeaderString = function (requestHeaders) {
+ let requestHeaderString = "";
+ if (requestHeaders) {
+ for (const header of requestHeaders) {
+ if (requestHeaderString.length === 0) {
+ requestHeaderString = `${header.name}:${encodeURIComponent(header.value)}`;
+ } else {
+ requestHeaderString = `${requestHeaderString},${header.name}:${encodeURIComponent(header.value)}`;
+ }
+ }
+ return requestHeaderString;
+ }
+ return undefined;
+};
+
+/**
+ * Gets headers and values to attach to the web request.
+ * @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send
+ * @returns {object} An object specifying name and value of the headers.
+ */
+const getHeadersToSend = (requestHeaders) => {
+ const headersToSend = {};
+ if (requestHeaders) {
+ for (const header of requestHeaders) {
+ headersToSend[header.name] = header.value;
+ }
+ }
+
+ return headersToSend;
+};
+
+/**
+ * Gets the part of the CORS URL that represents the expected HTTP headers to receive.
+ * @param {Array.} expectedResponseHeaders the expected HTTP headers to receive
+ * @returns {string} to be used as the expected HTTP-headers component in CORS URL.
+ */
+const getExpectedResponseHeadersString = function (expectedResponseHeaders) {
+ let expectedResponseHeadersString = "";
+ if (expectedResponseHeaders) {
+ for (const header of expectedResponseHeaders) {
+ if (expectedResponseHeadersString.length === 0) {
+ expectedResponseHeadersString = `${header}`;
+ } else {
+ expectedResponseHeadersString = `${expectedResponseHeadersString},${header}`;
+ }
+ }
+ return expectedResponseHeaders;
+ }
+ return undefined;
+};
+
+/**
+ * Gets the values for the expected headers from the response.
+ * @param {Array.} expectedResponseHeaders the expected HTTP headers to receive
+ * @param {Response} response the HTTP response
+ * @returns {string} to be used as the expected HTTP-headers component in CORS URL.
+ */
+const getHeadersFromResponse = (expectedResponseHeaders, response) => {
+ const responseHeaders = [];
+
+ if (expectedResponseHeaders) {
+ for (const header of expectedResponseHeaders) {
+ const headerValue = response.headers.get(header);
+ responseHeaders.push({ name: header, value: headerValue });
+ }
+ }
+
+ return responseHeaders;
+};
+
+/**
+ * Format the time according to the config
+ * @param {object} config The config of the module
+ * @param {object} time time to format
+ * @returns {string} The formatted time string
+ */
+const formatTime = (config, time) => {
+ let date = moment(time);
+
+ if (config.timezone) {
+ date = date.tz(config.timezone);
+ }
+
+ if (config.timeFormat !== 24) {
+ if (config.showPeriod) {
+ if (config.showPeriodUpper) {
+ return date.format("h:mm A");
+ } else {
+ return date.format("h:mm a");
+ }
+ } else {
+ return date.format("h:mm");
+ }
+ }
+
+ return date.format("HH:mm");
+};
+
+if (typeof module !== "undefined")
+ module.exports = {
+ performWebRequest,
+ formatTime
+ };
diff --git a/mm/mounts/modules/default/weather/README.md b/mm/mounts/modules/default/weather/README.md
new file mode 100644
index 0000000..7effbb1
--- /dev/null
+++ b/mm/mounts/modules/default/weather/README.md
@@ -0,0 +1,5 @@
+# Weather Module
+
+This module will be configurable to be used as a current weather view, or to show the forecast. This way the module can be used twice to fulfill both purposes.
+
+For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/weather.html).
diff --git a/mm/mounts/modules/default/weather/current.njk b/mm/mounts/modules/default/weather/current.njk
new file mode 100644
index 0000000..09781db
--- /dev/null
+++ b/mm/mounts/modules/default/weather/current.njk
@@ -0,0 +1,89 @@
+{% if current %}
+ {% if not config.onlyTemp %}
+