Compare commits

..

19 Commits

Author SHA1 Message Date
Matt Walsh
0d508d7f50 5.27.3 2025-07-30 22:29:25 -05:00
Matt Walsh
d85a5ed3b1 add strings to permalink structure close #129 2025-07-30 22:29:20 -05:00
Matt Walsh
831e1680e9 Merge pull request #128 from jacroe/feature/94-drabbit
Add "No Data" icon
2025-07-30 22:05:09 -05:00
Jacob Roeland
73cbc0aa81 Add "No Data" icon
Supports #94
2025-07-25 21:23:23 -04:00
Matt Walsh
9150d42802 5.27.2 2025-07-15 22:13:04 -05:00
Matt Walsh
54257e4667 add new naming issue template 2025-07-15 22:07:52 -05:00
Matt Walsh
7d50ce28bd Allow for station data overrides #125 2025-07-15 22:00:33 -05:00
Matt Walsh
2db7f30de7 5.27.1 2025-07-11 22:36:57 -05:00
Matt Walsh
5c7a6ab1a4 fix for rss feed encoding types close #124 2025-07-11 22:36:47 -05:00
Matt Walsh
4b63328b74 update dependencies 2025-07-06 10:54:20 -05:00
Matt Walsh
ae1d004f60 Merge pull request #123 from rmitchellscott/fix-static-envs
fix: url encode envs in static-env-handler. Fixes #122.
2025-07-06 10:36:51 -05:00
Mitchell Scott
7dd4c1dd24 fix: url encode envs in static-env-handler. Fixes #122. 2025-07-04 04:41:56 -06:00
Matt Walsh
1120247c99 include custom rss feed in build #57 2025-06-30 23:32:30 -05:00
Matt Walsh
c5c01e5450 5.27.0 2025-06-30 23:29:46 -05:00
Matt Walsh
0a65221905 update readme for custom rss close #57 2025-06-30 23:29:39 -05:00
Matt Walsh
9f9667c895 add single text scroll option #57 2025-06-28 09:20:36 -05:00
Matt Walsh
fda44e95fc rss feeds scroll, needs additional testing #57 2025-06-28 00:59:40 -05:00
Matt Walsh
945c12e6c6 parse rss feed #57 2025-06-28 00:29:55 -05:00
Matt Walsh
0fde88cd8f restructure current weather scroll to allow add/remove of rss feed #57 2025-06-28 00:29:47 -05:00
18 changed files with 16815 additions and 16570 deletions

13
.github/ISSUE_TEMPLATE/naming _issue.md vendored Normal file
View File

@@ -0,0 +1,13 @@
---
name: Naming issue
about: A city, airport or other location is not named correctly
title: 'Name Issue: '
labels: naming
assignees: ''
---
This form is not for reporting a location that you can not find from the search box.
Use this form to help us rename airports, points of interest and other data provided from the API (rarely updated) to a better name. For example the airport in Broomfield colorado was renamed from "Jeffco" in the API to "Rocky Mountain Metro" it's new name.
You can also make a pull request on the `[station-overrides.mjs](https://github.com/netbymatt/ws4kp/blob/main/datagenerators/stations-states.mjs)` file which includes instructions on how to make the change directly. This is the preferred method.

View File

@@ -176,6 +176,9 @@ A hook is provided as `/server/scripts/custom.js` to allow customizations to you
When using Docker, mount your `custom.js` file to `/usr/share/nginx/html/scripts/custom.js` to customize the static build.
### 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.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
// station overrides are used to change the data for a station that is provided by the api
// the most common use is to adjust the city (station name) for formatting or to update an outdated name
// a complete station object looks like this:
// {
// "id": "KMCO", // 4-letter station identifier and key for lookups
// "city": "Orlando International Airport", // name displayed for this station
// "state": "FL", // state
// "lat": 28.41826, // latitude of station
// "lon": -81.32413 // longitude of station
// }
// any or all of the data for a station can be overwritten, follow the existing override patterns below
const overrides = {
KBJC: {
city: 'Rocky Mountain Metro',
},
};
export default overrides;

View File

@@ -5,6 +5,7 @@ import { writeFileSync } from 'fs';
import https from './https.mjs';
import states from './stations-states.mjs';
import chunk from './chunk.mjs';
import overrides from './stations-overrides.mjs';
// skip stations starting with these letters
const skipStations = ['U', 'C', 'H', 'W', 'Y', 'T', 'S', 'M', 'O', 'L', 'A', 'F', 'B', 'N', 'V', 'R', 'D', 'E', 'I', 'G', 'J'];
@@ -43,12 +44,16 @@ for (let i = 0; i < chunkStates.length; i += 1) {
console.log(`Duplicate station: ${state}-${id}`);
return;
}
// get any overrides if available
const override = overrides[id] ?? {};
output[id] = {
id,
city: station.properties.name,
state,
lat: station.geometry.coordinates[1],
lon: station.geometry.coordinates[0],
// finally add the overrides
...override,
};
});
next = stations?.pagination?.next;
@@ -59,7 +64,7 @@ for (let i = 0; i < chunkStates.length; i += 1) {
while (next && stations.features.length > 0);
console.log(`Complete: ${state}`);
return true;
} catch (e) {
} catch {
console.error(`Unable to get state: ${state}`);
return false;
}

View File

@@ -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',
];

715
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "ws4kp",
"version": "5.26.2",
"version": "5.27.3",
"description": "Welcome to the WeatherStar 4000+ project page!",
"main": "index.mjs",
"type": "module",
@@ -49,7 +49,7 @@
"webpack-stream": "^7.0.0"
},
"dependencies": {
"dotenv": "^16.5.0",
"dotenv": "^17.0.1",
"ejs": "^3.1.5",
"express": "^5.1.0"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -13350,6 +13350,13 @@ const StationInfo = {
lat: 38.7578,
lon: -104.3013,
},
KAEJ: {
id: 'KAEJ',
city: 'Central Colorado Regional Airport',
state: 'CO',
lat: 38.81416,
lon: -106.12069,
},
KAFF: {
id: 'KAFF',
city: 'Air Force Academy',
@@ -13357,13 +13364,6 @@ const StationInfo = {
lat: 38.96667,
lon: -104.81667,
},
KAIB: {
id: 'KAIB',
city: 'Nucla Hopkins Field Airport',
state: 'CO',
lat: 38.23875,
lon: -108.563277,
},
KAJZ: {
id: 'KAJZ',
city: 'Delta/Blake Field Airport',
@@ -13415,7 +13415,7 @@ const StationInfo = {
},
KBJC: {
id: 'KBJC',
city: 'Broomfield / Jeffco',
city: 'Rocky Mountain Metro',
state: 'CO',
lat: 39.90085,
lon: -105.10417,

View File

@@ -114,11 +114,14 @@ class CurrentWeather extends WeatherDisplay {
const wind = (typeof this.data.WindSpeed === 'number') ? this.data.WindDirection.padEnd(3, '') + this.data.WindSpeed.toString().padStart(3, ' ') : this.data.WindSpeed;
// get location (city name) from StationInfo if available (allows for overrides)
const location = (StationInfo[this.data.station.properties.stationIdentifier]?.city ?? locationCleanup(this.data.station.properties.name)).substr(0, 20);
const fill = {
temp: this.data.Temperature + String.fromCharCode(176),
condition,
wind,
location: locationCleanup(this.data.station.properties.name).substr(0, 20),
location,
humidity: `${this.data.Humidity}%`,
dewpoint: this.data.DewPoint + String.fromCharCode(176),
ceiling: (this.data.Ceiling === 0 ? 'Unlimited' : this.data.Ceiling + this.data.CeilingUnit),

View File

@@ -15,6 +15,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
@@ -60,7 +61,7 @@ const incrementInterval = (force) => {
stop(display?.elemId === 'progress');
return;
}
screenIndex = (screenIndex + 1) % (lastScreen);
screenIndex = (screenIndex + 1) % (workingScreens.length);
// draw new text
drawScreen();
@@ -78,7 +79,7 @@ const drawScreen = async () => {
// nothing to do if there's no data yet
if (!data) return;
const thisScreen = screens[screenIndex](data);
const thisScreen = workingScreens[screenIndex](data);
// update classes on the scroll area
elemForEach('.weather-display .scroll', (elem) => {
@@ -125,12 +126,17 @@ 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
(data) => `Conditions at ${locationCleanup(data.station.properties.name).substr(0, 20)}`,
(data) => {
const location = (StationInfo[data.station.properties.stationIdentifier]?.city ?? locationCleanup(data.station.properties.name)).substr(0, 20);
return `Conditions at ${location}`;
},
// temperature
(data) => {
@@ -168,6 +174,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
@@ -183,19 +192,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) => {
@@ -238,6 +246,9 @@ const parseMessage = (event) => {
}
};
const screenCount = () => workingScreens.length;
const atDefault = () => defaultScreensLoaded;
// add event listener for start message
window.addEventListener('message', parseMessage);
@@ -245,10 +256,14 @@ window.CurrentWeatherScroll = {
addScreen,
reset,
start,
screenCount,
atDefault,
};
export {
addScreen,
reset,
start,
screenCount,
atDefault,
};

View File

@@ -0,0 +1,132 @@
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;
// determine return type
const isBase64 = rssResponse.status.content_type.substring(0, 8) !== 'text/xml';
// base 64 decode everything after the comma
const rss = isBase64 ? atob(rssResponse.contents.split('base64,')[1]) : rssResponse.contents;
// 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);
});

View File

@@ -20,45 +20,44 @@ document.addEventListener('DOMContentLoaded', () => {
});
const scanMusicDirectory = async () => {
const parseDirectory = async (path, prefix = "") => {
const listing = await text(path);
const matches = [...listing.matchAll(/href="([^\"]+\.mp3)"/gi)];
return matches.map((m) => `${prefix}${m[1]}`);
};
const parseDirectory = async (path, prefix = '') => {
const listing = await text(path);
const matches = [...listing.matchAll(/href="([^"]+\.mp3)"/gi)];
return matches.map((m) => `${prefix}${m[1]}`);
};
try {
let files = await parseDirectory("music/");
if (files.length === 0) {
files = await parseDirectory("music/default/", "default/");
}
return { availableFiles: files };
} catch (e) {
console.error("Unable to scan music directory");
console.error(e);
return { availableFiles: [] };
}
try {
let files = await parseDirectory('music/');
if (files.length === 0) {
files = await parseDirectory('music/default/', 'default/');
}
return { availableFiles: files };
} catch (e) {
console.error('Unable to scan music directory');
console.error(e);
return { availableFiles: [] };
}
};
const getMedia = async () => {
try {
const response = await fetch('playlist.json');
if (response.ok) {
playlist = await response.json();
} else if (response.status === 404
&& response.headers.get('X-Weatherstar') === 'true') {
console.warn("Couldn't get playlist.json, falling back to directory scan");
playlist = await scanMusicDirectory();
} else {
console.warn(`Couldn't get playlist.json: ${response.status} ${response.statusText}`);
playlist = { availableFiles: [] };
}
} catch (e) {
console.warn("Couldn't get playlist.json, falling back to directory scan");
playlist = await scanMusicDirectory();
}
try {
const response = await fetch('playlist.json');
if (response.ok) {
playlist = await response.json();
} else if (response.status === 404
&& response.headers.get('X-Weatherstar') === 'true') {
console.warn("Couldn't get playlist.json, falling back to directory scan");
playlist = await scanMusicDirectory();
} else {
console.warn(`Couldn't get playlist.json: ${response.status} ${response.statusText}`);
playlist = { availableFiles: [] };
}
} catch (e) {
console.warn("Couldn't get playlist.json, falling back to directory scan");
playlist = await scanMusicDirectory();
}
enableMediaPlayer();
enableMediaPlayer();
};
const enableMediaPlayer = () => {
@@ -219,11 +218,11 @@ const playerEnded = () => {
};
const setTrackName = (fileName) => {
const baseName = fileName.split('/').pop();
const trackName = decodeURIComponent(
baseName.replace(/\.mp3/gi, '').replace(/(_-)/gi, '')
);
document.getElementById('musicTrack').innerHTML = trackName;
const baseName = fileName.split('/').pop();
const trackName = decodeURIComponent(
baseName.replace(/\.mp3/gi, '').replace(/(_-)/gi, ''),
);
document.getElementById('musicTrack').innerHTML = trackName;
};
export {

View File

@@ -34,10 +34,17 @@ const createLink = async (e) => {
// get all select boxes
elemForEach('select', (elem) => {
if (elem?.id) {
queryStringElements[elem.id] = elem?.value ?? 0;
queryStringElements[elem.id] = encodeURIComponent(elem?.value ?? '');
}
});
// get all text boxes
elemForEach('input[type=text]', ((elem) => {
if (elem?.id) {
queryStringElements[elem.id] = elem?.value ?? 0;
}
}));
// add the location string
queryStringElements.latLonQuery = localStorage.getItem('latLonQuery');
queryStringElements.latLon = localStorage.getItem('latLon');

View File

@@ -11,6 +11,7 @@ const DEFAULTS = {
sticky: true,
values: [],
visible: true,
placeholder: '',
};
class Setting {
@@ -31,6 +32,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}`];
@@ -48,6 +50,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();
@@ -60,6 +65,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 } });
@@ -124,6 +132,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;
@@ -146,6 +182,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) ?? '{}';
@@ -163,8 +208,8 @@ class Setting {
switch (this.type) {
case 'boolean':
case 'checkbox':
return storedValue;
case 'select':
case 'string':
return storedValue;
default:
return null;
@@ -214,6 +259,8 @@ class Setting {
switch (this.type) {
case 'select':
return this.generateSelect();
case 'string':
return this.generateString();
case 'checkbox':
default:
return this.generateCheckbox();

35
static-env-handler.sh Normal file → Executable file
View File

@@ -4,22 +4,27 @@ set -eu
ROOT="/usr/share/nginx/html"
QS=""
# URL encode a string
url_encode() {
local string="$1"
printf '%s' "$string" | sed 's/ /%20/g; s/"/%22/g; s/</%3C/g; s/>/%3E/g; s/&/%26/g; s/#/%23/g; s/+/%2B/g'
}
# build query string from WSQS_ env vars
for var in $(env); do
case "$var" in
WSQS_*=*)
key="${var%%=*}"
val="${var#*=}"
key="${key#WSQS_}"
key="${key//_/-}"
if [ -n "$QS" ]; then
QS="$QS&${key}=${val}"
else
QS="${key}=${val}"
fi
;;
esac
done
while IFS='=' read -r key val; do
# Remove WSQS_ prefix and convert underscores to hyphens
key="${key#WSQS_}"
key="${key//_/-}"
# URL encode the value
encoded_val=$(url_encode "$val")
if [ -n "$QS" ]; then
QS="$QS&${key}=${encoded_val}"
else
QS="${key}=${encoded_val}"
fi
done << EOF
$(env | grep '^WSQS_')
EOF
if [ -n "$QS" ]; then

View File

@@ -54,6 +54,7 @@
<script type="module" src="scripts/modules/radar.mjs"></script>
<script type="module" src="scripts/modules/settings.mjs"></script>
<script type="module" src="scripts/modules/media.mjs"></script>
<script type="module" src="scripts/modules/custom-rss-feed.mjs"></script>
<script type="module" src="scripts/index.mjs"></script>
<!-- data -->
<script type="text/javascript" src="scripts/data/travelcities.js"></script>