diff --git a/.idea/spothole.iml b/.idea/spothole.iml new file mode 100644 index 0000000..06bd9e9 --- /dev/null +++ b/.idea/spothole.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index e97a875..74b54fc 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,24 @@ # (S)pothole -*Work in progress.* +**Work in progress.** -A utility to aggregate spots from amateur radio DX clusters and xOTA spotting sites, and provide an open JSON API as well as a website to browse the data. +(S)pothole is a utility to aggregate "spots" from amateur radio DX clusters and xOTA spotting sites, and provide an open JSON API as well as a website to browse the data. -Currently supports: -* DX Clusters -* POTA -* WWFF -* SOTA -* GMA -* HEMA -* UKBOTA -* Parks n Peaks -* RBN -* APRS +While there are several other web-based interfaces to DX clusters, and sites that aggregate spots from various outfoor activity programmes for amateur radio, (S)pothole differentiates itself by supporting a large number of data sources, and by being "API first" rather than just providing a web front-end. This allows other software to be built on top of it. + +The API is deliberately well-defined with an OpenAPI specification and auto-generated API documentation. The API delivers spots in a consistent format regardless of the data source, freeing developers from needing to know how each individual data source presents its data. + +(S)pothole itself is also open source, Public Domain licenced code that anyone can take and modify. + +Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, and Parks 'n' Peaks. + +### Accessing the public version + +TODO + +### Installing your own copy + +TODO ### Writing your own Providers @@ -34,8 +38,6 @@ Finally, simply add the appropriate config to the `providers` section of `config ### Third Party Libraries -The project contains a self-hosted copy of Font Awesome's free library, in the `/fa/` directory. This is subject to Font Awesome's licence and is not covered by the overall licence declared in the `LICENSE` file. This approach was taken in preference to using their hosted kits due to the popularity of this project exceeding the page view limit for their free hosted offering. +The project contains a self-hosted copy of Font Awesome's free library, in the `/webasset/fa/` directory. This is subject to Font Awesome's licence and is not covered by the overall licence declared in the `LICENSE` file. This approach was taken in preference to using their hosted kits due to the popularity of this project exceeding the page view limit for their free hosted offering. -TODO JS & Python libs... - -This project would not have been possible without these libraries, so many thanks to their developers. +The software uses a number of Python libraries as listed in `requirements.txt`, and a number of JavaScript libraries such as jQuery and moment.js. This project would not have been possible without these libraries, so many thanks to their developers. diff --git a/core/constants.py b/core/constants.py index 3316771..f2906e9 100644 --- a/core/constants.py +++ b/core/constants.py @@ -261,7 +261,7 @@ DXCC_FLAGS = { 222: "\U0001F1EB\U0001F1F4", # FAROE ISLANDS 223: "\U0001F3F4\U000E0067\U000E0062\U000E0065\U000E006E\U000E0067\U000E007F", # ENGLAND 224: "\U0001F1EB\U0001F1EE", # FINLAND - 225: "", # SARDINIA + 225: "\U0001F1EE\U0001F1F9", # SARDINIA 226: "", # SAUDI ARABIA/IRAQ NEUT ZONE 227: "\U0001F1EB\U0001F1F7", # FRANCE 228: "", # SERRANA BANK & RONCADOR CAY diff --git a/providers/wwff.py b/providers/wwff.py index a75081f..87a439f 100644 --- a/providers/wwff.py +++ b/providers/wwff.py @@ -30,7 +30,7 @@ class WWFF(HTTPProvider): sig_refs=[source_spot["reference"]], sig_refs_names=[source_spot["reference_name"]], icon="seedling", - time=datetime.fromtimestamp(source_spot["spot_time"]).replace(tzinfo=pytz.UTC), + time=datetime.fromtimestamp(source_spot["spot_time"], tz=pytz.UTC), latitude=source_spot["latitude"], longitude=source_spot["longitude"]) diff --git a/server/webserver.py b/server/webserver.py index d6401f4..49cb9cd 100644 --- a/server/webserver.py +++ b/server/webserver.py @@ -52,6 +52,7 @@ class WebServer: self.last_api_access_time = datetime.now(pytz.UTC) self.status = "OK" response.content_type = 'application/json' + response.set_header('Cache-Control', 'no-store') return json.dumps(data, default=serialize_everything) # Serve a templated page diff --git a/webassets/css/style.css b/webassets/css/style.css index 5d067b5..c52878e 100644 --- a/webassets/css/style.css +++ b/webassets/css/style.css @@ -9,8 +9,33 @@ } } +span.flag-wrapper { + display: inline-block; + width: 1.7em; +} + span.icon-wrapper { display: inline-block; width: 1.5em; text-align: center; +} + +span.freq-mhz { + display: inline-block; + width: 2em; + text-align: right; + font-weight: bold; +} + +span.freq-khz { + padding: 0 0.2em; +} + +span.freq-hz { + font-size: 0.8em; +} + +tr.table-faded td { + color: gray; + text-decoration: line-through !important; } \ No newline at end of file diff --git a/webassets/js/code.js b/webassets/js/code.js index 8380779..a277605 100644 --- a/webassets/js/code.js +++ b/webassets/js/code.js @@ -1,6 +1,8 @@ // How often to query the server? const REFRESH_INTERVAL_SEC = 60; +// Storage for the spot data that the server gives us. +var spots = [] // Storage for the options that the server gives us. This will define our filters. var options = {}; // Last time we updated the spots list on display. @@ -11,48 +13,69 @@ function loadSpots() { $.getJSON('/api/spots', function(jsonData) { // Store last updated time lastUpdateTime = moment.utc(); - - // Populate table with headers - let headers = Object.keys(jsonData[0]); - let table = $('').append(''); - ["UTC", "DX", "Frequency", "Mode", "Comment", "Source", "DE"].forEach(header => table.find('thead tr').append(``)); - - jsonData.forEach(row => { - // Create row - let $tr = $(''); - - // Show in red if QRT - if (row["qrt"] == true) { - $tr.addClass("table-danger"); - } - - // Format a UTC time for display - var time = moment.utc(row["time"], moment.ISO_8601); - var time_formatted = time.format("HH:mm") - - // Figure out a SIG or Source - var source = row["source"]; - if (row["sig"]) { - source = row["sig"]; - } - - // Populate the table data - $tr.append(``); - $tr.append(``); - $tr.append(``); - $tr.append(``); - $tr.append(''); - $tr.append(``); - $tr.append(``); - table.find('tbody').append($tr); - }); - - // Update DOM - $('#table-container').html(table); updateRefreshDisplay(); + // Store data + spots = jsonData; + // Update table + updateTable(); }); } +// Update the spots table +function updateTable() { + // Populate table with headers + let headers = Object.keys(spots[0]); + let table = $('
${header}
${time_formatted}${row["dx_flag"]} ${row["dx_call"]}${row["freq"]}${row["mode"]}' + escapeHtml(`${row["comment"]}`) + ' ${source}${row["de_flag"]} ${row["de_call"]}
').append(''); + ["UTC", "DX", "Frequency", "Mode", "Comment", "Source", "Ref.", "DE"].forEach(header => table.find('thead tr').append(``)); + + spots.forEach(s => { + // Create row + let $tr = $(''); + + // Show in red if QRT + if (s["qrt"] == true) { + $tr.addClass("table-faded"); + } + + // Format a UTC time for display + var time = moment.utc(s["time"], moment.ISO_8601); + var time_formatted = time.format("HH:mm") + + // Format the frequency + var mhz = Math.floor(s["freq"] / 1000.0); + var khz = Math.floor(s["freq"] - (mhz * 1000.0)); + var hz = Math.floor((s["freq"] - Math.floor(s["freq"])) * 1000.0); + var hz_string = (hz > 0) ? hz.toFixed(0) : ""; + var freq_string = `${mhz.toFixed(0)}${khz.toFixed(0).padStart(3, '0')}${hz_string}` + + // Format sig_refs + var sig_refs = "" + if (s["sig_refs"]) { + sig_refs = s["sig_refs"].join(", ") + } + + // Format DE flag + var de_flag = ""; + if (s["de_flag"] && s["de_flag"] != "") { + de_flag = s["de_flag"]; + } + + // Populate the row + $tr.append(``); + $tr.append(``); + $tr.append(``); + $tr.append(``); + $tr.append(''); + $tr.append(``); + $tr.append(``); + $tr.append(``); + table.find('tbody').append($tr); + }); + + // Update DOM + $('#table-container').html(table); +} + // Load server status function loadStatus() { $.getJSON('/api/status', function(jsonData) { @@ -81,29 +104,29 @@ function updateRefreshDisplay() { count = REFRESH_INTERVAL_SEC - secSinceUpdate; updatingString = "Updating in " + count.toFixed(0) + " seconds..."; } - $("#timing-container").text("Last updated at " + lastUpdateTime.format('hh:mm') + " UTC. " + updatingString); + $("#timing-container").text("Last updated at " + lastUpdateTime.format('HH:mm') + " UTC. " + updatingString); } } // Utility function to escape HTML characters from a string. function escapeHtml(str) { - if (typeof str !== 'string') { - return ''; - } - - const escapeCharacter = (match) => { - switch (match) { - case '&': return '&'; - case '<': return '<'; - case '>': return '>'; - case '"': return '"'; - case '\'': return '''; - case '`': return '`'; - default: return match; + if (typeof str !== 'string') { + return ''; } - }; - return str.replace(/[&<>"'`]/g, escapeCharacter); + const escapeCharacter = (match) => { + switch (match) { + case '&': return '&'; + case '<': return '<'; + case '>': return '>'; + case '"': return '"'; + case '\'': return '''; + case '`': return '`'; + default: return match; + } + }; + + return str.replace(/[&<>"'`]/g, escapeCharacter); }
${header}
${time_formatted}${s["dx_flag"]}${s["dx_call"]}${freq_string}${s["mode"]}' + escapeHtml(`${s["comment"]}`) + ' ${s["source"]}${sig_refs}${s["de_flag"]}${s["de_call"]}