diff --git a/README.md b/README.md
index db6fdd5..7444e5b 100644
--- a/README.md
+++ b/README.md
@@ -311,6 +311,9 @@ When using Docker:
* **Static deployment**: Mount your `custom.js` file to `/usr/share/nginx/html/scripts/custom.js`
* **Server deployment**: Mount your `custom.js` file to `/app/server/scripts/custom.js`
+### RSS feeds and custom scroll
+If you would like your Weatherstar to have custom scrolling text in the bottom blue bar, or show headlines from an rss feed turn on the setting for `Enable RSS Feed/Text` and then enter a URL or text in the resulting text box. Then press set.
+
## Issue reporting and feature requests
Please do not report issues with api.weather.gov being down. It's a new service and not considered fully operational yet. I've also observed that the API can go down on a regional basis (based on NWS office locations). This means that you may have problems getting data for, say, Chicago right now, but Dallas and others are working just fine.
diff --git a/gulp/publish-frontend.mjs b/gulp/publish-frontend.mjs
index 50f54ec..f2d3f30 100644
--- a/gulp/publish-frontend.mjs
+++ b/gulp/publish-frontend.mjs
@@ -89,6 +89,7 @@ const mjsSources = [
'server/scripts/modules/travelforecast.mjs',
'server/scripts/modules/progress.mjs',
'server/scripts/modules/media.mjs',
+ 'server/scripts/modules/custom-rss-feed.mjs',
'server/scripts/index.mjs',
];
diff --git a/package-lock.json b/package-lock.json
index f580bd2..b274d14 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "ws4kp",
- "version": "5.26.2",
+ "version": "5.27.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ws4kp",
- "version": "5.26.2",
+ "version": "5.27.0",
"license": "MIT",
"dependencies": {
"dotenv": "^16.5.0",
diff --git a/package.json b/package.json
index 6e47593..210ac4b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "ws4kp",
- "version": "5.26.2",
+ "version": "5.27.0",
"description": "Welcome to the WeatherStar 4000+ project page!",
"main": "index.mjs",
"type": "module",
diff --git a/server/scripts/modules/currentweatherscroll.mjs b/server/scripts/modules/currentweatherscroll.mjs
index 2ecffce..0ee7955 100644
--- a/server/scripts/modules/currentweatherscroll.mjs
+++ b/server/scripts/modules/currentweatherscroll.mjs
@@ -18,6 +18,7 @@ let screenIndex = 0;
let sinceLastUpdate = 0;
let nextUpdate = DEFAULT_UPDATE;
let resetFlag;
+let defaultScreensLoaded = true;
// start drawing conditions
// reset starts from the first item in the text scroll list
@@ -63,7 +64,7 @@ const incrementInterval = (force) => {
stop(display?.elemId === 'progress');
return;
}
- screenIndex = (screenIndex + 1) % (lastScreen);
+ screenIndex = (screenIndex + 1) % (workingScreens.length);
// draw new text
drawScreen();
@@ -87,7 +88,7 @@ const drawScreen = async () => {
// if we have no current weather and no hazards, there's nothing to display
if (!data && (!scrollData.hazards || scrollData.hazards.length === 0)) return;
- const thisScreen = screens[screenIndex](scrollData);
+ const thisScreen = workingScreens[screenIndex](scrollData);
// update classes on the scroll area
elemForEach('.weather-display .scroll', (elem) => {
@@ -136,8 +137,10 @@ const hazards = (data) => {
};
};
+// additional screens are stored in a separate for simple clearing/resettings
+let additionalScreens = [];
// the "screens" are stored in an array for easy addition and removal
-const screens = [
+const baseScreens = [
// hazards
hazards,
// station name
@@ -179,6 +182,9 @@ const screens = [
},
];
+// working screens are the combination of base screens (when active) and additional screens
+let workingScreens = [...baseScreens, ...additionalScreens];
+
// internal draw function with preset parameters
const drawCondition = (text) => {
// update all html scroll elements
@@ -194,19 +200,18 @@ const setHeader = (text) => {
});
};
-// store the original number of screens
-const originalScreens = screens.length;
-let lastScreen = originalScreens;
-
-// reset the number of screens
+// reset the screens back to the original set
const reset = () => {
- lastScreen = originalScreens;
+ workingScreens = [...baseScreens];
+ additionalScreens = [];
+ defaultScreensLoaded = true;
};
-// add screen
-const addScreen = (screen) => {
- screens.push(screen);
- lastScreen += 1;
+// add screen, keepBase keeps the regular weather crawl
+const addScreen = (screen, keepBase = true) => {
+ defaultScreensLoaded = false;
+ additionalScreens.push(screen);
+ workingScreens = [...(keepBase ? baseScreens : []), ...additionalScreens];
};
const drawScrollCondition = (screen) => {
@@ -259,6 +264,9 @@ const parseMessage = (event) => {
}
};
+const screenCount = () => workingScreens.length;
+const atDefault = () => defaultScreensLoaded;
+
// add event listener for start message
window.addEventListener('message', parseMessage);
@@ -266,10 +274,14 @@ window.CurrentWeatherScroll = {
addScreen,
reset,
start,
+ screenCount,
+ atDefault,
};
export {
addScreen,
reset,
start,
+ screenCount,
+ atDefault,
};
diff --git a/server/scripts/modules/custom-rss-feed.mjs b/server/scripts/modules/custom-rss-feed.mjs
new file mode 100644
index 0000000..059f8e6
--- /dev/null
+++ b/server/scripts/modules/custom-rss-feed.mjs
@@ -0,0 +1,130 @@
+import Setting from './utils/setting.mjs';
+import { reset as resetScroll, addScreen as addScroll } from './currentweatherscroll.mjs';
+import { json } from './utils/fetch.mjs';
+
+let firstRun = true;
+
+const parser = new DOMParser();
+
+// change of enable handler
+const changeEnable = (newValue) => {
+ let newDisplay;
+ if (newValue) {
+ // add the feed to the scroll
+ parseFeed(customFeed.value);
+ // show the string box
+ newDisplay = 'block';
+ } else {
+ // set scroll back to original
+ resetScroll();
+ // hide the string entry
+ newDisplay = 'none';
+ }
+ const stringEntry = document.getElementById('settings-customFeed-label');
+ if (stringEntry) {
+ stringEntry.style.display = newDisplay;
+ }
+};
+
+// parse the feed/text provided
+const parseFeed = (textInput) => {
+ // skip getting the feed on first run
+ if (firstRun) return;
+
+ // test validity
+ if (textInput === undefined || textInput === '') {
+ resetScroll();
+ }
+
+ // test for url
+ if (textInput.match(/https?:\/\//)) {
+ getFeed(textInput);
+ return;
+ }
+
+ // add single text scroll
+ resetScroll();
+ addScroll(
+ () => (
+ {
+ type: 'scroll',
+ text: textInput,
+ }),
+ // keep the existing scroll
+ true,
+ );
+};
+
+// get the rss feed and then swap out the current weather scroll
+const getFeed = async (url) => {
+ // get the text as a string
+ // it needs to be proxied, use a free service
+ const rssResponse = await json(`https://api.allorigins.win/get?url=${url}`);
+
+ // this returns a data url
+ // a few sanity checks
+ if (rssResponse.status.content_type.indexOf('xml') < 0) return;
+ if (rssResponse.contents.indexOf('base64') > 100) return;
+ // base 64 decode everything after the comma
+ const rss = atob(rssResponse.contents.split('base64,')[1]);
+
+ // parse the rss
+ const doc = parser.parseFromString(rss, 'text/xml');
+
+ // get the title
+ const rssTitle = doc.querySelector('channel title').textContent;
+
+ // get each item
+ const titles = [...doc.querySelectorAll('item title')].map((t) => t.textContent);
+
+ // reset the scroll, then add the screens
+ resetScroll();
+ titles.forEach((title) => {
+ // data is provided to the screen handler, so we return a function
+ addScroll(
+ () => ({
+ header: rssTitle,
+ type: 'scroll',
+ text: title,
+ }),
+ // false parameter does not include the default weather scrolls
+ false,
+ );
+ });
+};
+
+// change the feed source and re-load if necessary
+const changeFeed = (newValue) => {
+ // first pass through won't have custom feed enable ready
+ if (firstRun) return;
+
+ if (customFeedEnable.value) {
+ parseFeed(newValue);
+ }
+};
+
+const customFeed = new Setting('customFeed', {
+ name: 'Custom RSS Feed',
+ defaultValue: '',
+ type: 'string',
+ changeAction: changeFeed,
+ placeholder: 'Text or URL',
+});
+
+const customFeedEnable = new Setting('customFeedEnable', {
+ name: 'Enable RSS Feed/Text',
+ defaultValue: false,
+ changeAction: changeEnable,
+});
+
+// initialize the custom feed inputs on the page
+document.addEventListener('DOMContentLoaded', () => {
+ // add the controls to the page
+ const settingsSection = document.querySelector('#settings');
+ settingsSection.append(customFeedEnable.generate(), customFeed.generate());
+ // clear the first run value
+ firstRun = false;
+ // call change enable with the current value to show/hide the url box
+ // and make the call to get the feed if enabled
+ changeEnable(customFeedEnable.value);
+});
diff --git a/server/scripts/modules/utils/setting.mjs b/server/scripts/modules/utils/setting.mjs
index 4369df4..17e44f3 100644
--- a/server/scripts/modules/utils/setting.mjs
+++ b/server/scripts/modules/utils/setting.mjs
@@ -12,6 +12,7 @@ const DEFAULTS = {
stickyRead: false,
values: [],
visible: true,
+ placeholder: '',
};
class Setting {
@@ -33,6 +34,7 @@ class Setting {
this.values = options.values;
this.visible = options.visible;
this.changeAction = options.changeAction;
+ this.placeholder = options.placeholder;
// get value from url
const urlValue = parseQueryString()?.[`settings-${shortName}-${this.type}`];
@@ -50,6 +52,9 @@ class Setting {
// couldn't parse as a float, store as a string
urlState = urlValue;
}
+ if (this.type === 'string' && urlValue !== undefined) {
+ urlState = urlValue;
+ }
// get existing value if present
const storedValue = urlState ?? this.getFromLocalStorage();
@@ -62,6 +67,9 @@ class Setting {
case 'select':
this.selectChange({ target: { value: this.myValue } });
break;
+ case 'string':
+ this.stringChange({ target: { value: this.myValue } });
+ break;
case 'checkbox':
default:
this.checkboxChange({ target: { checked: this.myValue } });
@@ -126,6 +134,34 @@ class Setting {
return label;
}
+ generateString() {
+ // create a string input and accompanying set button
+ const label = document.createElement('label');
+ label.for = `settings-${this.shortName}-string`;
+ label.id = `settings-${this.shortName}-label`;
+ // text input box
+ const textInput = document.createElement('input');
+ textInput.type = 'text';
+ textInput.value = this.myValue;
+ textInput.id = `settings-${this.shortName}-string`;
+ textInput.name = `settings-${this.shortName}-string`;
+ textInput.placeholder = this.placeholder;
+ // set button
+ const setButton = document.createElement('input');
+ setButton.type = 'button';
+ setButton.value = 'Set';
+ setButton.id = `settings-${this.shortName}-button`;
+ setButton.name = `settings-${this.shortName}-button`;
+ setButton.addEventListener('click', () => {
+ this.stringChange({ target: { value: textInput.value } });
+ });
+ // assemble
+ label.append(textInput, setButton);
+
+ this.element = label;
+ return label;
+ }
+
checkboxChange(e) {
// update the state
this.myValue = e.target.checked;
@@ -148,6 +184,15 @@ class Setting {
this.changeAction(this.myValue);
}
+ stringChange(e) {
+ // update the value
+ this.myValue = e.target.value;
+ this.storeToLocalStorage(this.myValue);
+
+ // call the change action
+ this.changeAction(this.myValue);
+ }
+
storeToLocalStorage(value) {
if (!this.sticky) return;
const allSettingsString = localStorage?.getItem(SETTINGS_KEY) ?? '{}';
@@ -179,8 +224,8 @@ class Setting {
switch (this.type) {
case 'boolean':
case 'checkbox':
- return storedValue;
case 'select':
+ case 'string':
return storedValue;
default:
return null;
@@ -231,6 +276,8 @@ class Setting {
switch (this.type) {
case 'select':
return this.generateSelect();
+ case 'string':
+ return this.generateString();
case 'checkbox':
default:
return this.generateCheckbox();
diff --git a/views/index.ejs b/views/index.ejs
index 57b8cab..b18cd46 100644
--- a/views/index.ejs
+++ b/views/index.ejs
@@ -57,6 +57,7 @@
+
<% } %>