atmosx-nwws-parser 1.0.19 → 1.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +182 -64
- package/dist/cjs/index.cjs +3799 -0
- package/dist/esm/index.mjs +3757 -0
- package/package.json +49 -37
- package/src/bootstrap.ts +196 -0
- package/src/database.ts +148 -0
- package/src/dictionaries/awips.ts +342 -0
- package/src/dictionaries/events.ts +142 -0
- package/src/dictionaries/icao.ts +237 -0
- package/src/dictionaries/offshore.ts +12 -0
- package/src/dictionaries/signatures.ts +107 -0
- package/src/eas.ts +493 -0
- package/src/index.ts +229 -0
- package/src/parsers/events/api.ts +151 -0
- package/src/parsers/events/cap.ts +138 -0
- package/src/parsers/events/text.ts +106 -0
- package/src/parsers/events/ugc.ts +109 -0
- package/src/parsers/events/vtec.ts +78 -0
- package/src/parsers/events.ts +367 -0
- package/src/parsers/hvtec.ts +46 -0
- package/src/parsers/pvtec.ts +71 -0
- package/src/parsers/stanza.ts +132 -0
- package/src/parsers/text.ts +166 -0
- package/src/parsers/ugc.ts +197 -0
- package/src/types.ts +251 -0
- package/src/utils.ts +314 -0
- package/src/xmpp.ts +144 -0
- package/test.js +58 -34
- package/tsconfig.json +12 -5
- package/tsup.config.ts +14 -0
- package/bootstrap.ts +0 -122
- package/dist/bootstrap.js +0 -153
- package/dist/src/events.js +0 -585
- package/dist/src/helper.js +0 -463
- package/dist/src/stanza.js +0 -147
- package/dist/src/text-parser.js +0 -119
- package/dist/src/ugc.js +0 -214
- package/dist/src/vtec.js +0 -125
- package/src/events.ts +0 -394
- package/src/helper.ts +0 -298
- package/src/stanza.ts +0 -102
- package/src/text-parser.ts +0 -120
- package/src/ugc.ts +0 -122
- package/src/vtec.ts +0 -99
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/*
|
|
2
|
+
_ _ __ __
|
|
3
|
+
/\ | | | | (_) \ \ / /
|
|
4
|
+
/ \ | |_ _ __ ___ ___ ___ _ __ | |__ ___ _ __ _ ___ \ V /
|
|
5
|
+
/ /\ \| __| "_ ` _ \ / _ \/ __| "_ \| "_ \ / _ \ "__| |/ __| > <
|
|
6
|
+
/ ____ \ |_| | | | | | (_) \__ \ |_) | | | | __/ | | | (__ / . \
|
|
7
|
+
/_/ \_\__|_| |_| |_|\___/|___/ .__/|_| |_|\___|_| |_|\___/_/ \_\
|
|
8
|
+
| |
|
|
9
|
+
|_|
|
|
10
|
+
|
|
11
|
+
Written by: KiyoWx (k3yomi)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
import * as loader from '../bootstrap';
|
|
16
|
+
import * as types from '../types';
|
|
17
|
+
|
|
18
|
+
export class StanzaParser {
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @function validate
|
|
22
|
+
* @description
|
|
23
|
+
* Validates and parses a stanza message, extracting its attributes and metadata.
|
|
24
|
+
* Handles both raw message strings (for debug/testing) and actual stanza objects.
|
|
25
|
+
* Determines whether the message is a CAP alert, contains VTEC codes, or contains UGCs,
|
|
26
|
+
* and identifies the AWIPS product type and prefix.
|
|
27
|
+
*
|
|
28
|
+
* @static
|
|
29
|
+
* @param {any} stanza
|
|
30
|
+
* @param {boolean | types.StanzaAttributes} [isDebug=false]
|
|
31
|
+
* @returns {{
|
|
32
|
+
* message: string;
|
|
33
|
+
* attributes: types.StanzaAttributes;
|
|
34
|
+
* isCap: boolean,
|
|
35
|
+
* isPVtec: boolean;
|
|
36
|
+
* isCapDescription: boolean;
|
|
37
|
+
* awipsType: Record<string, string>;
|
|
38
|
+
* isApi: boolean;
|
|
39
|
+
* ignore: boolean;
|
|
40
|
+
* isUGC?: boolean;
|
|
41
|
+
* }}
|
|
42
|
+
*/
|
|
43
|
+
public static validate(stanza: any, isDebug: boolean | types.StanzaAttributes = false): { message: string; attributes: types.StanzaAttributes; isCap: any; isPVtec: boolean; isCapDescription: any; awipsType: any; isApi: boolean; ignore: boolean; isUGC?: boolean; } {
|
|
44
|
+
if (isDebug !== false) {
|
|
45
|
+
const vTypes = isDebug as types.StanzaAttributes;
|
|
46
|
+
const message = stanza;
|
|
47
|
+
const attributes = vTypes;
|
|
48
|
+
const isCap = vTypes.isCap ?? message.includes(`<?xml`);
|
|
49
|
+
const isCapDescription = message.includes(`<areaDesc>`);
|
|
50
|
+
const isPVtec = message.match(loader.definitions.regular_expressions.pvtec) != null;
|
|
51
|
+
const isUGC = message.match(loader.definitions.regular_expressions.ugc1) != null;
|
|
52
|
+
const awipsType = this.getType(attributes);
|
|
53
|
+
return { message, attributes, isCap, isPVtec, isUGC, isCapDescription, awipsType: awipsType, isApi: false, ignore: false };
|
|
54
|
+
}
|
|
55
|
+
if (stanza.is(`message`)) {
|
|
56
|
+
let cb = stanza.getChild(`x`)
|
|
57
|
+
if (cb && cb.children) {
|
|
58
|
+
let message = unescape(cb.children[0])
|
|
59
|
+
let attributes = cb.attrs
|
|
60
|
+
if (attributes.awipsid && attributes.awipsid.length > 1) {
|
|
61
|
+
const isCap = message.includes(`<?xml`);
|
|
62
|
+
const isCapDescription = message.includes(`<areaDesc>`);
|
|
63
|
+
const isPVtec = message.match(loader.definitions.regular_expressions.pvtec) != null;
|
|
64
|
+
const isUGC = message.match(loader.definitions.regular_expressions.ugc1) != null;
|
|
65
|
+
const awipsType = this.getType(attributes);
|
|
66
|
+
this.cache(message, {attributes, isCap, isPVtec, awipsType });
|
|
67
|
+
return { message, attributes, isCap, isPVtec, isUGC, isCapDescription, awipsType: awipsType, isApi: false, ignore: false };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { message: null, attributes: null, isApi: null, isCap: null, isPVtec: null, isUGC: null, isCapDescription: null, awipsType: null, ignore: true };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @function getType
|
|
76
|
+
* @description
|
|
77
|
+
* Determines the AWIPS product type and prefix from a stanza's attributes.
|
|
78
|
+
* Returns a default type of 'XX' if the attributes are missing or the AWIPS ID
|
|
79
|
+
* does not match any known definitions.
|
|
80
|
+
*
|
|
81
|
+
* @private
|
|
82
|
+
* @static
|
|
83
|
+
* @param {unknown} attributes
|
|
84
|
+
* @returns {Record<string, string>}
|
|
85
|
+
*/
|
|
86
|
+
private static getType(attributes: unknown): Record<string, string> {
|
|
87
|
+
const attrs = attributes as types.StanzaAttributesType | undefined;
|
|
88
|
+
if (!attrs?.awipsid) return { type: 'XX', prefix: 'XX' };
|
|
89
|
+
const awipsDefs = loader.definitions.awips;
|
|
90
|
+
for (const [prefix, type] of Object.entries(awipsDefs)) {
|
|
91
|
+
if (attrs.awipsid.startsWith(prefix)) {
|
|
92
|
+
return { type, prefix };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { type: 'XX', prefix: 'XX' };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @function cache
|
|
100
|
+
* @description
|
|
101
|
+
* Saves a compiled stanza message to the local cache directory.
|
|
102
|
+
* Ensures the message contains "STANZA ATTRIBUTES..." metadata and timestamps,
|
|
103
|
+
* and appends the formatted entry to both a category-specific file and a general cache file.
|
|
104
|
+
*
|
|
105
|
+
* @private
|
|
106
|
+
* @static
|
|
107
|
+
* @async
|
|
108
|
+
* @param {unknown} compiled
|
|
109
|
+
* @returns {Promise<void>}
|
|
110
|
+
*/
|
|
111
|
+
private static async cache(message: string, compiled: unknown): Promise<void> {
|
|
112
|
+
if (!compiled) return;
|
|
113
|
+
const data = compiled as types.StanzaCompiled;
|
|
114
|
+
const settings = loader.settings as types.ClientSettingsTypes;
|
|
115
|
+
const { fs, path } = loader.packages;
|
|
116
|
+
if (!message || !settings.noaa_weather_wire_service_settings.cache.directory) return;
|
|
117
|
+
const cacheDir = settings.noaa_weather_wire_service_settings.cache.directory;
|
|
118
|
+
if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
|
|
119
|
+
const prefix = `category-${data.awipsType.prefix}-${data.awipsType.type}s`;
|
|
120
|
+
const suffix = `${data.isCap ? 'cap' : 'raw'}${data.isPVtec ? '-vtec' : ''}`;
|
|
121
|
+
const categoryFile = path.join(cacheDir, `${prefix}-${suffix}.bin`);
|
|
122
|
+
const cacheFile = path.join(cacheDir, `cache-${suffix}.bin`);
|
|
123
|
+
const entry = `[SoF]\nSTANZA ATTRIBUTES...${JSON.stringify(compiled)}\n[EoF]\n${message}`;
|
|
124
|
+
await Promise.all([
|
|
125
|
+
fs.promises.appendFile(categoryFile, entry, 'utf8'),
|
|
126
|
+
fs.promises.appendFile(cacheFile, entry, 'utf8'),
|
|
127
|
+
]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export default StanzaParser;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/*
|
|
2
|
+
_ _ __ __
|
|
3
|
+
/\ | | | | (_) \ \ / /
|
|
4
|
+
/ \ | |_ _ __ ___ ___ ___ _ __ | |__ ___ _ __ _ ___ \ V /
|
|
5
|
+
/ /\ \| __| "_ ` _ \ / _ \/ __| "_ \| "_ \ / _ \ "__| |/ __| > <
|
|
6
|
+
/ ____ \ |_| | | | | | (_) \__ \ |_) | | | | __/ | | | (__ / . \
|
|
7
|
+
/_/ \_\__|_| |_| |_|\___/|___/ .__/|_| |_|\___|_| |_|\___/_/ \_\
|
|
8
|
+
| |
|
|
9
|
+
|_|
|
|
10
|
+
|
|
11
|
+
Written by: KiyoWx (k3yomi)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as loader from '../bootstrap';
|
|
15
|
+
|
|
16
|
+
export class TextParser {
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @function textProductToString
|
|
20
|
+
* @description
|
|
21
|
+
* Searches a text product message for a line containing a specific value,
|
|
22
|
+
* extracts the substring immediately following that value, and optionally
|
|
23
|
+
* removes additional specified strings. Cleans up the extracted string by
|
|
24
|
+
* trimming whitespace and removing any remaining occurrences of the search
|
|
25
|
+
* value or '<' characters.
|
|
26
|
+
*
|
|
27
|
+
* @static
|
|
28
|
+
* @param {string} message
|
|
29
|
+
* @param {string} value
|
|
30
|
+
* @param {string[]} [removal=[]]
|
|
31
|
+
* @returns {string | null}
|
|
32
|
+
*/
|
|
33
|
+
public static textProductToString(message: string,value: string,removal: string[] = []): string | null {
|
|
34
|
+
const lines = message.split('\n');
|
|
35
|
+
for (const line of lines) {
|
|
36
|
+
if (line.includes(value)) {
|
|
37
|
+
let result = line.slice(line.indexOf(value) + value.length).trim();
|
|
38
|
+
for (const str of removal) { result = result.split(str).join(''); }
|
|
39
|
+
result = result.replace(value, '').replace('<', '').trim();
|
|
40
|
+
return result || null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @function textProductToPolygon
|
|
48
|
+
* @description
|
|
49
|
+
* Parses a text product message to extract polygon coordinates based on
|
|
50
|
+
* LAT...LON data. Coordinates are converted to [latitude, longitude] pairs
|
|
51
|
+
* with longitude negated (assumes Western Hemisphere). If the polygon has
|
|
52
|
+
* more than two points, the first point is repeated at the end to close it.
|
|
53
|
+
*
|
|
54
|
+
* @static
|
|
55
|
+
* @param {string} message
|
|
56
|
+
* @returns {[number, number][]}
|
|
57
|
+
*/
|
|
58
|
+
public static textProductToPolygon(message: string): [number, number][] {
|
|
59
|
+
const coordinates: [number, number][] = [];
|
|
60
|
+
const latLonMatch = message.match(/LAT\.{3}LON\s+([\d\s]+)/i);
|
|
61
|
+
if (!latLonMatch || !latLonMatch[1]) return coordinates;
|
|
62
|
+
const coordStrings = latLonMatch[1].replace(/\n/g, ' ').trim().split(/\s+/);
|
|
63
|
+
for (let i = 0; i < coordStrings.length - 1; i += 2) {
|
|
64
|
+
const lat = parseFloat(coordStrings[i]) / 100;
|
|
65
|
+
const lon = -parseFloat(coordStrings[i + 1]) / 100;
|
|
66
|
+
if (!isNaN(lat) && !isNaN(lon)) { coordinates.push([lon, lat]); }
|
|
67
|
+
}
|
|
68
|
+
if (coordinates.length > 2) { coordinates.push(coordinates[0]); }
|
|
69
|
+
return coordinates;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @function textProductToDescription
|
|
74
|
+
* @description
|
|
75
|
+
* Extracts a clean description portion from a text product message, optionally
|
|
76
|
+
* removing a handle and any extra metadata such as "STANZA ATTRIBUTES...".
|
|
77
|
+
* Also trims and normalizes whitespace.
|
|
78
|
+
*
|
|
79
|
+
* @static
|
|
80
|
+
* @param {string} message
|
|
81
|
+
* @param {string | null} [handle=null]
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
public static textProductToDescription(message: string, handle: string = null): string {
|
|
85
|
+
const original = message;
|
|
86
|
+
const discoveredDates = Array.from(message.matchAll(loader.definitions.regular_expressions.dateline));
|
|
87
|
+
if (discoveredDates.length) {
|
|
88
|
+
const lastMatch = discoveredDates[discoveredDates.length - 1][0];
|
|
89
|
+
const startIdx = message.lastIndexOf(lastMatch);
|
|
90
|
+
if (startIdx !== -1) {
|
|
91
|
+
const endIdx = message.indexOf('&&', startIdx);
|
|
92
|
+
message = message.substring(startIdx + lastMatch.length, endIdx !== -1 ? endIdx : undefined).trimStart();
|
|
93
|
+
if (message.startsWith('/')) message = message.slice(1).trimStart();
|
|
94
|
+
if (handle && message.includes(handle)) {
|
|
95
|
+
const handleIdx = message.indexOf(handle);
|
|
96
|
+
message = message.substring(handleIdx + handle.length).trimStart();
|
|
97
|
+
if (message.startsWith('/')) message = message.slice(1).trimStart();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} else if (handle) {
|
|
101
|
+
const handleIdx = message.indexOf(handle);
|
|
102
|
+
if (handleIdx !== -1) {
|
|
103
|
+
let afterHandle = message.substring(handleIdx + handle.length).trimStart();
|
|
104
|
+
if (afterHandle.startsWith('/')) afterHandle = afterHandle.slice(1).trimStart();
|
|
105
|
+
const latEnd = afterHandle.indexOf('&&')
|
|
106
|
+
message = latEnd !== -1 ? afterHandle.substring(0, latEnd).trim() : afterHandle.trim();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return message.replace(/\s+/g, ' ').trim().startsWith('STANZA ATTRIBUTES...') ? original : message.split('STANZA ATTRIBUTES...')[0].trim();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @function getXmlValues
|
|
114
|
+
* @description
|
|
115
|
+
* Recursively extracts specified values from a parsed XML-like object.
|
|
116
|
+
* Searches both object keys and array items for matching keys (case-insensitive)
|
|
117
|
+
* and returns the corresponding values. If multiple unique values are found for
|
|
118
|
+
* a key, an array is returned; if one value is found, it returns that value;
|
|
119
|
+
* if none are found, returns `null`.
|
|
120
|
+
*
|
|
121
|
+
* @static
|
|
122
|
+
* @param {any} parsed
|
|
123
|
+
* @param {string[]} valuesToExtract
|
|
124
|
+
* @returns {Record<string, string | string[] | null>}
|
|
125
|
+
*/
|
|
126
|
+
public static getXmlValues(parsed: any, valuesToExtract: string[]): Record<string, string> {
|
|
127
|
+
const extracted: Record<string, any> = {};
|
|
128
|
+
const findValueByKey = (obj: any, searchKey: string) => {
|
|
129
|
+
const results = [];
|
|
130
|
+
if (obj === null || typeof obj !== 'object') {
|
|
131
|
+
return results;
|
|
132
|
+
}
|
|
133
|
+
const searchKeyLower = searchKey.toLowerCase();
|
|
134
|
+
for (const key in obj) {
|
|
135
|
+
if (obj.hasOwnProperty(key) && key.toLowerCase() === searchKeyLower) {
|
|
136
|
+
results.push(obj[key]);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (Array.isArray(obj)) {
|
|
140
|
+
for (const item of obj) {
|
|
141
|
+
if (item.valueName && item.valueName.toLowerCase() === searchKeyLower && item.value !== undefined) {
|
|
142
|
+
results.push(item.value);
|
|
143
|
+
}
|
|
144
|
+
const nestedResults = findValueByKey(item, searchKey);
|
|
145
|
+
results.push(...nestedResults);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
for (const key in obj) {
|
|
149
|
+
if (obj.hasOwnProperty(key)) {
|
|
150
|
+
const nestedResults = findValueByKey(obj[key], searchKey);
|
|
151
|
+
results.push(...nestedResults);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return results;
|
|
155
|
+
};
|
|
156
|
+
for (const key of valuesToExtract) {
|
|
157
|
+
const values = findValueByKey(parsed.alert, key);
|
|
158
|
+
const uniqueValues = [...new Set(values)];
|
|
159
|
+
extracted[key] = uniqueValues.length === 0 ? null : (uniqueValues.length === 1 ? uniqueValues[0] : uniqueValues);
|
|
160
|
+
}
|
|
161
|
+
return extracted;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export default TextParser;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/*
|
|
2
|
+
_ _ __ __
|
|
3
|
+
/\ | | | | (_) \ \ / /
|
|
4
|
+
/ \ | |_ _ __ ___ ___ ___ _ __ | |__ ___ _ __ _ ___ \ V /
|
|
5
|
+
/ /\ \| __| "_ ` _ \ / _ \/ __| "_ \| "_ \ / _ \ "__| |/ __| > <
|
|
6
|
+
/ ____ \ |_| | | | | | (_) \__ \ |_) | | | | __/ | | | (__ / . \
|
|
7
|
+
/_/ \_\__|_| |_| |_|\___/|___/ .__/|_| |_|\___|_| |_|\___/_/ \_\
|
|
8
|
+
| |
|
|
9
|
+
|_|
|
|
10
|
+
|
|
11
|
+
Written by: KiyoWx (k3yomi)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as loader from '../bootstrap';
|
|
15
|
+
import * as types from '../types';
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
export class UGCParser {
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @function ugcExtractor
|
|
22
|
+
* @description
|
|
23
|
+
* Extracts UGC (Universal Geographic Code) information from a message.
|
|
24
|
+
* This includes parsing the header, resolving zones, calculating the expiry
|
|
25
|
+
* date, and retrieving associated location names from the database.
|
|
26
|
+
*
|
|
27
|
+
* @static
|
|
28
|
+
* @async
|
|
29
|
+
* @param {string} message
|
|
30
|
+
* @returns {Promise<types.UGCEntry | null>}
|
|
31
|
+
*/
|
|
32
|
+
public static async ugcExtractor(message: string): Promise<types.UGCEntry | null> {
|
|
33
|
+
const header = this.getHeader(message);
|
|
34
|
+
if (!header) return null;
|
|
35
|
+
const zones = this.getZones(header);
|
|
36
|
+
if (zones.length === 0) return null;
|
|
37
|
+
const expiry = this.getExpiry(message);
|
|
38
|
+
const locations = await this.getLocations(zones);
|
|
39
|
+
return {
|
|
40
|
+
zones: zones,
|
|
41
|
+
locations: locations,
|
|
42
|
+
expiry: expiry
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @function getHeader
|
|
48
|
+
* @description
|
|
49
|
+
* Extracts the UGC header from a message by locating patterns defined in
|
|
50
|
+
* `ugc1` and `ugc2` regular expressions. Removes all whitespace and the
|
|
51
|
+
* trailing character from the matched header.
|
|
52
|
+
*
|
|
53
|
+
* @static
|
|
54
|
+
* @param {string} message
|
|
55
|
+
* @returns {string | null}
|
|
56
|
+
*/
|
|
57
|
+
public static getHeader(message: string): string | null {
|
|
58
|
+
const start = message.search(loader.definitions.regular_expressions.ugc1);
|
|
59
|
+
const subMessage = message.substring(start);
|
|
60
|
+
const end = subMessage.search(loader.definitions.regular_expressions.ugc2);
|
|
61
|
+
const full = subMessage.substring(0, end).replace(/\s+/g, '').slice(0, -1);
|
|
62
|
+
return full || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @function getExpiry
|
|
67
|
+
* @description
|
|
68
|
+
* Extracts an expiration date from a message using the UGC3 format.
|
|
69
|
+
* The function parses day, hour, and minute from the message and constructs
|
|
70
|
+
* a Date object in the current month and year. Returns `null` if no valid
|
|
71
|
+
* expiration is found.
|
|
72
|
+
*
|
|
73
|
+
* @static
|
|
74
|
+
* @param {string} message
|
|
75
|
+
* @returns {Date | null}
|
|
76
|
+
*/
|
|
77
|
+
public static getExpiry(message: string): Date | null {
|
|
78
|
+
const start = message.match(loader.definitions.regular_expressions.ugc3);
|
|
79
|
+
const day = parseInt(start[0].substring(0, 2), 10);
|
|
80
|
+
const hour = parseInt(start[0].substring(2, 4), 10);
|
|
81
|
+
const minute = parseInt(start[0].substring(4, 6), 10);
|
|
82
|
+
const now = new Date();
|
|
83
|
+
const expires = new Date(now.getUTCFullYear(), now.getUTCMonth(), day, hour, minute, 0);
|
|
84
|
+
return expires;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @function getLocations
|
|
89
|
+
* @description
|
|
90
|
+
* Retrieves human-readable location names for an array of zone identifiers
|
|
91
|
+
* from the shapefiles database. If a zone is not found, the zone ID itself
|
|
92
|
+
* is returned. Duplicate locations are removed and the result is sorted.
|
|
93
|
+
*
|
|
94
|
+
* @static
|
|
95
|
+
* @async
|
|
96
|
+
* @param {string[]} zones
|
|
97
|
+
* @returns {Promise<string[]>}
|
|
98
|
+
*/
|
|
99
|
+
public static async getLocations(zones: string[]): Promise<string[]> {
|
|
100
|
+
const uniqueZones = Array.from(new Set(zones.map(z => z.trim())));
|
|
101
|
+
const placeholders = uniqueZones.map(() => '?').join(',');
|
|
102
|
+
const rows = await loader.cache.db.prepare(
|
|
103
|
+
`SELECT id, location FROM shapefiles WHERE id IN (${placeholders})`
|
|
104
|
+
).all(...uniqueZones);
|
|
105
|
+
const locationMap = new Map<string, string>();
|
|
106
|
+
for (const row of rows) { locationMap.set(row.id, row.location) }
|
|
107
|
+
const locations = uniqueZones.map(id => locationMap.get(id) ?? id);
|
|
108
|
+
return locations.sort();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @function getCoordinates
|
|
113
|
+
* @description
|
|
114
|
+
* Retrieves geographic coordinates for an array of zone identifiers
|
|
115
|
+
* from the shapefiles database. Returns the coordinates of the first
|
|
116
|
+
* polygon found for any matching zone.
|
|
117
|
+
*
|
|
118
|
+
* @static
|
|
119
|
+
* @param {string[]} zones
|
|
120
|
+
* @returns {[number, number][]}
|
|
121
|
+
*/
|
|
122
|
+
public static getCoordinates(zones: string[]): any | null {
|
|
123
|
+
const polygons: any[] = [];
|
|
124
|
+
for (const zone of zones.map(z => z.trim())) {
|
|
125
|
+
const row = loader.cache.db.prepare(`SELECT geometry FROM shapefiles WHERE id = ?`).get(zone);
|
|
126
|
+
if (row !== undefined) {
|
|
127
|
+
const geometry = JSON.parse(row.geometry);
|
|
128
|
+
if (geometry?.type === 'Polygon') {
|
|
129
|
+
polygons.push({
|
|
130
|
+
type: "Feature",
|
|
131
|
+
geometry,
|
|
132
|
+
properties: {}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (polygons.length === 0) return null;
|
|
138
|
+
let merged = polygons[0];
|
|
139
|
+
for (let i = 1; i < polygons.length; i++) {
|
|
140
|
+
merged = loader.packages.turf.union(merged, polygons[i]);
|
|
141
|
+
}
|
|
142
|
+
const outerRing = merged.geometry.type === "Polygon"
|
|
143
|
+
? merged.geometry.coordinates[0]
|
|
144
|
+
: merged.geometry.coordinates[0][0];
|
|
145
|
+
const skip = loader.settings.global_settings.shapefile_skip;
|
|
146
|
+
const skippedRing = outerRing.filter((_: any, index: number) => index % skip === 0);
|
|
147
|
+
return [skippedRing]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @function getZones
|
|
152
|
+
* @description
|
|
153
|
+
* Parses a UGC header string and returns an array of individual zone
|
|
154
|
+
* identifiers. Handles ranges indicated with `>` and preserves the
|
|
155
|
+
* state and format prefixes.
|
|
156
|
+
*
|
|
157
|
+
* @static
|
|
158
|
+
* @param {string} header
|
|
159
|
+
* @returns {string[]}
|
|
160
|
+
*/
|
|
161
|
+
public static getZones(header: string): string[] {
|
|
162
|
+
const ugcSplit = header.split('-');
|
|
163
|
+
const zones: string[] = [];
|
|
164
|
+
let state = ugcSplit[0].substring(0, 2);
|
|
165
|
+
const format = ugcSplit[0].substring(2, 3);
|
|
166
|
+
for (const part of ugcSplit) {
|
|
167
|
+
if (/^[A-Z]/.test(part)) {
|
|
168
|
+
state = part.substring(0, 2);
|
|
169
|
+
if (part.includes('>')) {
|
|
170
|
+
const [start, end] = part.split('>');
|
|
171
|
+
const startNum = parseInt(start.substring(3), 10);
|
|
172
|
+
const endNum = parseInt(end, 10);
|
|
173
|
+
for (let j = startNum; j <= endNum; j++) {
|
|
174
|
+
zones.push(`${state}${format}${j.toString().padStart(3, '0')}`);
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
zones.push(part);
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (part.includes('>')) {
|
|
182
|
+
const [start, end] = part.split('>');
|
|
183
|
+
const startNum = parseInt(start, 10);
|
|
184
|
+
const endNum = parseInt(end, 10);
|
|
185
|
+
for (let j = startNum; j <= endNum; j++) {
|
|
186
|
+
zones.push(`${state}${format}${j.toString().padStart(3, '0')}`);
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
zones.push(`${state}${format}${part}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return zones.filter(item => item !== '');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export default UGCParser;
|