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 @@ + <% } %>