@atmosx/event-product-parser 2.0.1
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/LICENSE +17 -0
- package/README.md +269 -0
- package/dist/cjs/index.cjs +7554 -0
- package/dist/esm/index.mjs +7542 -0
- package/dist/index.d.mts +1024 -0
- package/dist/index.d.ts +1024 -0
- package/package.json +55 -0
- package/src/@dictionaries/awips.ts +358 -0
- package/src/@dictionaries/events.ts +168 -0
- package/src/@dictionaries/icao.ts +250 -0
- package/src/@dictionaries/signatures.ts +139 -0
- package/src/@parsers/@events/api.ts +146 -0
- package/src/@parsers/@events/cap.ts +123 -0
- package/src/@parsers/@events/text.ts +104 -0
- package/src/@parsers/@events/ugc.ts +107 -0
- package/src/@parsers/@events/vtec.ts +76 -0
- package/src/@parsers/events.ts +392 -0
- package/src/@parsers/hvtec.ts +46 -0
- package/src/@parsers/pvtec.ts +72 -0
- package/src/@parsers/stanza.ts +97 -0
- package/src/@parsers/text.ts +165 -0
- package/src/@parsers/ugc.ts +247 -0
- package/src/@submodules/database.ts +162 -0
- package/src/@submodules/eas.ts +490 -0
- package/src/@submodules/utils.ts +222 -0
- package/src/@submodules/xmpp.ts +142 -0
- package/src/bootstrap.ts +190 -0
- package/src/index.ts +218 -0
- package/src/types.ts +259 -0
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
export class HVtecParser {
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @function HVtecExtractor
|
|
21
|
+
* @description
|
|
22
|
+
* Extracts VTEC entries from a raw NWWS message string and returns
|
|
23
|
+
* structured objects containing type, tracking, event, status,
|
|
24
|
+
* WMO identifiers, and expiry date.
|
|
25
|
+
*
|
|
26
|
+
* @static
|
|
27
|
+
* @param {string} message
|
|
28
|
+
* @returns {Promise<types.HtecEntry[] | null>}
|
|
29
|
+
*/
|
|
30
|
+
public static async HVtecExtractor(message: string): Promise<types.HVtecEntry[] | null> {
|
|
31
|
+
const matches = message.match(loader.definitions.regular_expressions.hvtec);
|
|
32
|
+
if (!matches || matches.length !== 1) return null;
|
|
33
|
+
const hvtec = matches[0];
|
|
34
|
+
const parts = hvtec.split('.');
|
|
35
|
+
if (parts.length < 7) return null;
|
|
36
|
+
const hvtecs: types.HVtecEntry[] = [{
|
|
37
|
+
severity: loader.definitions.severity[parts[1]],
|
|
38
|
+
cause: loader.definitions.causes[parts[2]],
|
|
39
|
+
record: loader.definitions.records[parts[6]],
|
|
40
|
+
raw: hvtec,
|
|
41
|
+
}];
|
|
42
|
+
return hvtecs;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default HVtecParser;
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
export class PVtecParser {
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @function pVtecExtractor
|
|
21
|
+
* @description
|
|
22
|
+
* Extracts VTEC entries from a raw NWWS message string and returns
|
|
23
|
+
* structured objects containing type, tracking, event, status,
|
|
24
|
+
* WMO identifiers, and expiry date.
|
|
25
|
+
*
|
|
26
|
+
* @static
|
|
27
|
+
* @param {string} message
|
|
28
|
+
* @returns {Promise<types.VtecEntry[] | null>}
|
|
29
|
+
*/
|
|
30
|
+
public static async pVtecExtractor(message: string): Promise<types.PVtecEntry[] | null> {
|
|
31
|
+
const matches = message.match(loader.definitions.regular_expressions.pvtec) ?? [];
|
|
32
|
+
const pVtecs: types.PVtecEntry[] = [];
|
|
33
|
+
for (const pvtec of matches) {
|
|
34
|
+
const parts = pvtec.split('.');
|
|
35
|
+
if (parts.length < 7) continue;
|
|
36
|
+
const dates = parts[6].split('-');
|
|
37
|
+
pVtecs.push({
|
|
38
|
+
raw: pvtec,
|
|
39
|
+
type: loader.definitions.productTypes[parts[0]],
|
|
40
|
+
tracking: `${parts[2]}-${parts[3]}-${parts[4]}-${parts[5]}`,
|
|
41
|
+
event: `${loader.definitions.events[parts[3]]} ${loader.definitions.actions[parts[4]]}`,
|
|
42
|
+
status: loader.definitions.status[parts[1]],
|
|
43
|
+
wmo: message.match(loader.definitions.regular_expressions.wmo)?.[0] || null,
|
|
44
|
+
expires: this.parseExpiryDate(dates),
|
|
45
|
+
isKWNS: (parts[4] == `A` || parts[4] == `Y`) && (parts[3] == `TO` || parts[3] == `SV`) ? true : false,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return pVtecs.length > 0 ? pVtecs : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @function parseExpiryDate
|
|
53
|
+
* @description
|
|
54
|
+
* Converts a NWWS VTEC/expiry timestamp string into a formatted local ISO date string
|
|
55
|
+
* with an Eastern Time offset (-04:00). Returns `Invalid Date Format` if the input
|
|
56
|
+
* is `000000T0000Z`.
|
|
57
|
+
*
|
|
58
|
+
* @private
|
|
59
|
+
* @static
|
|
60
|
+
* @param {string[]} args
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
private static parseExpiryDate(args: String[]): string {
|
|
64
|
+
if (args[1] == `000000T0000Z`) return `Invalid Date Format`;
|
|
65
|
+
const expires = `${new Date().getFullYear().toString().substring(0, 2)}${args[1].substring(0, 2)}-${args[1].substring(2, 4)}-${args[1].substring(4, 6)}T${args[1].substring(7, 9)}:${args[1].substring(9, 11)}:00`;
|
|
66
|
+
const local = new Date(new Date(expires).getTime() - 4 * 60 * 60000);
|
|
67
|
+
const pad = (n: number) => n.toString().padStart(2, '0');
|
|
68
|
+
return `${local.getFullYear()}-${pad(local.getMonth() + 1)}-${pad(local.getDate())}T${pad(local.getHours())}:${pad(local.getMinutes())}:00.000-04:00`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export default PVtecParser;
|
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
export class StanzaParser {
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @function validate
|
|
21
|
+
* @description
|
|
22
|
+
* Validates and parses a stanza message, extracting its attributes and metadata.
|
|
23
|
+
* Handles both raw message strings (for debug/testing) and actual stanza objects.
|
|
24
|
+
* Determines whether the message is a CAP alert, contains VTEC codes, or contains UGCs,
|
|
25
|
+
* and identifies the AWIPS product type and prefix.
|
|
26
|
+
*
|
|
27
|
+
* @static
|
|
28
|
+
* @param {any} stanza
|
|
29
|
+
* @param {boolean | types.StanzaAttributes} [isDebug=false]
|
|
30
|
+
* @returns {{
|
|
31
|
+
* message: string;
|
|
32
|
+
* attributes: types.StanzaAttributes;
|
|
33
|
+
* isCap: boolean,
|
|
34
|
+
* isPVtec: boolean;
|
|
35
|
+
* isCapDescription: boolean;
|
|
36
|
+
* awipsType: Record<string, string>;
|
|
37
|
+
* isApi: boolean;
|
|
38
|
+
* ignore: boolean;
|
|
39
|
+
* isUGC?: boolean;
|
|
40
|
+
* }}
|
|
41
|
+
*/
|
|
42
|
+
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; } {
|
|
43
|
+
if (isDebug !== false) {
|
|
44
|
+
const vTypes = isDebug as types.StanzaAttributes;
|
|
45
|
+
const message = stanza;
|
|
46
|
+
const attributes = vTypes;
|
|
47
|
+
const isCap = vTypes.isCap ?? message.includes(`<?xml`);
|
|
48
|
+
const isCapDescription = message.includes(`<areaDesc>`);
|
|
49
|
+
const isPVtec = message.match(loader.definitions.regular_expressions.pvtec) != null;
|
|
50
|
+
const isUGC = message.match(loader.definitions.regular_expressions.ugc1) != null;
|
|
51
|
+
const awipsType = this.getType(attributes);
|
|
52
|
+
return { message, attributes, isCap, isPVtec, isUGC, isCapDescription, awipsType: awipsType, isApi: false, ignore: false };
|
|
53
|
+
}
|
|
54
|
+
if (stanza.is(`message`)) {
|
|
55
|
+
let cb = stanza.getChild(`x`)
|
|
56
|
+
if (cb && cb.children) {
|
|
57
|
+
let message = unescape(cb.children[0]);
|
|
58
|
+
let attributes = cb.attrs
|
|
59
|
+
if (attributes.awipsid && attributes.awipsid.length > 1) {
|
|
60
|
+
const isCap = message.includes(`<?xml`);
|
|
61
|
+
const isCapDescription = message.includes(`<areaDesc>`);
|
|
62
|
+
const isPVtec = message.match(loader.definitions.regular_expressions.pvtec) != null;
|
|
63
|
+
const isUGC = message.match(loader.definitions.regular_expressions.ugc1) != null;
|
|
64
|
+
const awipsType = this.getType(attributes);
|
|
65
|
+
return { message, attributes, isCap, isPVtec, isUGC, isCapDescription, awipsType: awipsType, isApi: false, ignore: false };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { message: null, attributes: null, isApi: null, isCap: null, isPVtec: null, isUGC: null, isCapDescription: null, awipsType: null, ignore: true };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @function getType
|
|
74
|
+
* @description
|
|
75
|
+
* Determines the AWIPS product type and prefix from a stanza's attributes.
|
|
76
|
+
* Returns a default type of 'XX' if the attributes are missing or the AWIPS ID
|
|
77
|
+
* does not match any known definitions.
|
|
78
|
+
*
|
|
79
|
+
* @private
|
|
80
|
+
* @static
|
|
81
|
+
* @param {unknown} attributes
|
|
82
|
+
* @returns {Record<string, string>}
|
|
83
|
+
*/
|
|
84
|
+
private static getType(attributes: unknown): Record<string, string> {
|
|
85
|
+
const attrs = attributes as types.StanzaAttributesType | undefined;
|
|
86
|
+
if (!attrs?.awipsid) return { type: 'XX', prefix: 'XX' };
|
|
87
|
+
const awipsDefs = loader.definitions.awips as Record<string, string>;
|
|
88
|
+
for (const [prefix, type] of Object.entries(awipsDefs)) {
|
|
89
|
+
if (attrs.awipsid.startsWith(prefix)) {
|
|
90
|
+
return { type, prefix };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { type: 'XX', prefix: 'XX' };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default StanzaParser;
|
|
@@ -0,0 +1,165 @@
|
|
|
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
|
+
export default TextParser;
|
|
@@ -0,0 +1,247 @@
|
|
|
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
|
+
export class UGCParser {
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @function ugcExtractor
|
|
21
|
+
* @description
|
|
22
|
+
* Extracts UGC (Universal Geographic Code) information from a message.
|
|
23
|
+
* This includes parsing the header, resolving zones, calculating the expiry
|
|
24
|
+
* date, and retrieving associated location names from the database.
|
|
25
|
+
*
|
|
26
|
+
* @static
|
|
27
|
+
* @async
|
|
28
|
+
* @param {string} message
|
|
29
|
+
* @returns {Promise<types.UGCEntry | null>}
|
|
30
|
+
*/
|
|
31
|
+
public static async ugcExtractor(message: string): Promise<types.UGCEntry | null> {
|
|
32
|
+
const header = this.getHeader(message);
|
|
33
|
+
if (!header) return null;
|
|
34
|
+
const zones = this.getZones(header);
|
|
35
|
+
if (zones.length === 0) return null;
|
|
36
|
+
const expiry = this.getExpiry(message);
|
|
37
|
+
const locations = await this.getLocations(zones);
|
|
38
|
+
return {
|
|
39
|
+
zones: zones,
|
|
40
|
+
locations: locations,
|
|
41
|
+
expiry: expiry
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @function getHeader
|
|
47
|
+
* @description
|
|
48
|
+
* Extracts the UGC header from a message by locating patterns defined in
|
|
49
|
+
* `ugc1` and `ugc2` regular expressions. Removes all whitespace and the
|
|
50
|
+
* trailing character from the matched header.
|
|
51
|
+
*
|
|
52
|
+
* @static
|
|
53
|
+
* @param {string} message
|
|
54
|
+
* @returns {string | null}
|
|
55
|
+
*/
|
|
56
|
+
public static getHeader(message: string): string | null {
|
|
57
|
+
const start = message.search(loader.definitions.regular_expressions.ugc1);
|
|
58
|
+
const subMessage = message.substring(start);
|
|
59
|
+
const end = subMessage.search(loader.definitions.regular_expressions.ugc2);
|
|
60
|
+
const full = subMessage.substring(0, end).replace(/\s+/g, '').slice(0, -1);
|
|
61
|
+
return full || null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @function getExpiry
|
|
66
|
+
* @description
|
|
67
|
+
* Extracts an expiration date from a message using the UGC3 format.
|
|
68
|
+
* The function parses day, hour, and minute from the message and constructs
|
|
69
|
+
* a Date object in the current month and year. Returns `null` if no valid
|
|
70
|
+
* expiration is found.
|
|
71
|
+
*
|
|
72
|
+
* @static
|
|
73
|
+
* @param {string} message
|
|
74
|
+
* @returns {Date | null}
|
|
75
|
+
*/
|
|
76
|
+
public static getExpiry(message: string): Date | null {
|
|
77
|
+
const match = message.match(/\b(\d{6})-/);
|
|
78
|
+
if (!match) return null;
|
|
79
|
+
const token = match[1];
|
|
80
|
+
const day = parseInt(token.slice(0, 2), 10);
|
|
81
|
+
const hour = parseInt(token.slice(2, 4), 10);
|
|
82
|
+
const minute = parseInt(token.slice(4, 6), 10);
|
|
83
|
+
const now = new Date();
|
|
84
|
+
const expires = new Date(Date.UTC(now.getUTCFullYear(),now.getUTCMonth(),day,hour,minute));
|
|
85
|
+
return expires;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @function getLocations
|
|
90
|
+
* @description
|
|
91
|
+
* Retrieves human-readable location names for an array of zone identifiers
|
|
92
|
+
* from the shapefiles database. If a zone is not found, the zone ID itself
|
|
93
|
+
* is returned. Duplicate locations are removed and the result is sorted.
|
|
94
|
+
*
|
|
95
|
+
* @static
|
|
96
|
+
* @async
|
|
97
|
+
* @param {string[]} zones
|
|
98
|
+
* @returns {Promise<string[]>}
|
|
99
|
+
*/
|
|
100
|
+
public static async getLocations(zones: string[]): Promise<string[]> {
|
|
101
|
+
const uniqueZones = Array.from(new Set(zones.map(z => z.trim())));
|
|
102
|
+
const placeholders = uniqueZones.map(() => '?').join(',');
|
|
103
|
+
const rows = await loader.cache.db.prepare(
|
|
104
|
+
`SELECT id, location FROM shapefiles WHERE id IN (${placeholders})`
|
|
105
|
+
).all(...uniqueZones);
|
|
106
|
+
const locationMap = new Map<string, string>();
|
|
107
|
+
for (const row of rows) { locationMap.set(row.id, row.location) }
|
|
108
|
+
const locations = uniqueZones.map(id => locationMap.get(id) ?? id);
|
|
109
|
+
return locations.sort();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @function getCoordinates
|
|
114
|
+
* @description
|
|
115
|
+
* Calculates the outer boundary coordinates for a set of UGC zones by
|
|
116
|
+
* querying their geometries from the database, merging them, and extracting
|
|
117
|
+
* the largest outer ring. The coordinates are downsampled based on a skip
|
|
118
|
+
* setting to reduce complexity. Returns `null` if no valid coordinates are found.
|
|
119
|
+
*
|
|
120
|
+
* @static
|
|
121
|
+
* @param {string[]} zones
|
|
122
|
+
* @returns {[number, number][]}
|
|
123
|
+
*/
|
|
124
|
+
public static getCoordinates(zones: string[], isUnion=true): any | null {
|
|
125
|
+
const list = [...new Set(zones.map(z => z.trim()))].filter(z => z === 'XX000' ? false : true);
|
|
126
|
+
if (list.length === 0) return null;
|
|
127
|
+
const placeholders = list.map(() => "?").join(",");
|
|
128
|
+
const rows = loader.cache.db
|
|
129
|
+
.prepare(`SELECT geometry FROM shapefiles WHERE id IN (${placeholders})`)
|
|
130
|
+
.all(...list);
|
|
131
|
+
const polygons: any[] = [];
|
|
132
|
+
for (const row of rows) {
|
|
133
|
+
if (!row?.geometry) continue;
|
|
134
|
+
const geom = JSON.parse(row.geometry);
|
|
135
|
+
if (geom?.type === "Polygon") {
|
|
136
|
+
polygons.push(geom.coordinates);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (polygons.length === 0) return null;
|
|
140
|
+
if (isUnion) {
|
|
141
|
+
const unionFn = loader.packages.polygonClipping.union as (...polys: any[]) => any;
|
|
142
|
+
const mergedCoords = unionFn(...polygons);
|
|
143
|
+
if (!mergedCoords || mergedCoords.length === 0) return null;
|
|
144
|
+
let maxArea = -1;
|
|
145
|
+
let bestPoly: any[] = [];
|
|
146
|
+
for (const poly of mergedCoords) {
|
|
147
|
+
const outerRing = poly[0];
|
|
148
|
+
let area = 0;
|
|
149
|
+
for (let i = 0; i < outerRing.length - 1; i++) {
|
|
150
|
+
const [x1, y1] = outerRing[i];
|
|
151
|
+
const [x2, y2] = outerRing[i + 1];
|
|
152
|
+
area += x1 * y2 - x2 * y1;
|
|
153
|
+
}
|
|
154
|
+
area = Math.abs(area / 2);
|
|
155
|
+
if (area > maxArea) {
|
|
156
|
+
maxArea = area;
|
|
157
|
+
bestPoly = poly;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (!bestPoly || bestPoly.length === 0) return null;
|
|
161
|
+
const outerRing = bestPoly[0];
|
|
162
|
+
const skip = Math.max(1, parseInt(String(loader.settings.global_settings.shapefile_skip), 10) || 1);
|
|
163
|
+
let skipped = outerRing.filter((_: any, idx: number) => idx % skip === 0);
|
|
164
|
+
if (skipped.length < 4) {
|
|
165
|
+
skipped = outerRing.slice();
|
|
166
|
+
}
|
|
167
|
+
const first = skipped[0];
|
|
168
|
+
const last = skipped[skipped.length - 1];
|
|
169
|
+
if (!first || !last || first[0] !== last[0] || first[1] !== last[1]) {
|
|
170
|
+
skipped.push([first[0], first[1]]);
|
|
171
|
+
}
|
|
172
|
+
return {type: "Polygon", coordinates: [skipped]};
|
|
173
|
+
} else {
|
|
174
|
+
const multi: any[] = [];
|
|
175
|
+
for (const polyCoords of polygons) {
|
|
176
|
+
if (Array.isArray(polyCoords) && Array.isArray(polyCoords[0])) {
|
|
177
|
+
multi.push(polyCoords);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (multi.length === 0) return null;
|
|
181
|
+
const skip = Math.max(1, parseInt(String(loader.settings.global_settings.shapefile_skip), 10) || 1);
|
|
182
|
+
if (skip > 1) {
|
|
183
|
+
for (let p = 0; p < multi.length; p++) {
|
|
184
|
+
for (let r = 0; r < multi[p].length; r++) {
|
|
185
|
+
const ring = multi[p][r];
|
|
186
|
+
let reduced = ring.filter((_: any, i: number) => i % skip === 0);
|
|
187
|
+
if (reduced.length < 4) reduced = ring.slice();
|
|
188
|
+
const first = reduced[0];
|
|
189
|
+
const last = reduced[reduced.length - 1];
|
|
190
|
+
if ( first && last && (first[0] !== last[0] || first[1] !== last[1])) {
|
|
191
|
+
reduced.push([first[0], first[1]]);
|
|
192
|
+
}
|
|
193
|
+
multi[p][r] = reduced;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return {type: "MultiPolygon", coordinates: multi};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @function getZones
|
|
203
|
+
* @description
|
|
204
|
+
* Parses a UGC header string and returns an array of individual zone
|
|
205
|
+
* identifiers. Handles ranges indicated with `>` and preserves the
|
|
206
|
+
* state and format prefixes.
|
|
207
|
+
*
|
|
208
|
+
* @static
|
|
209
|
+
* @param {string} header
|
|
210
|
+
* @returns {string[]}
|
|
211
|
+
*/
|
|
212
|
+
public static getZones(header: string): string[] {
|
|
213
|
+
const ugcSplit = header.split('-');
|
|
214
|
+
const zones: string[] = [];
|
|
215
|
+
let state = ugcSplit[0].substring(0, 2);
|
|
216
|
+
const format = ugcSplit[0].substring(2, 3);
|
|
217
|
+
for (const part of ugcSplit) {
|
|
218
|
+
if (/^[A-Z]/.test(part)) {
|
|
219
|
+
state = part.substring(0, 2);
|
|
220
|
+
if (part.includes('>')) {
|
|
221
|
+
const [start, end] = part.split('>');
|
|
222
|
+
const startNum = parseInt(start.substring(3), 10);
|
|
223
|
+
const endNum = parseInt(end, 10);
|
|
224
|
+
for (let j = startNum; j <= endNum; j++) {
|
|
225
|
+
zones.push(`${state}${format}${j.toString().padStart(3, '0')}`);
|
|
226
|
+
}
|
|
227
|
+
} else {
|
|
228
|
+
zones.push(part);
|
|
229
|
+
}
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (part.includes('>')) {
|
|
233
|
+
const [start, end] = part.split('>');
|
|
234
|
+
const startNum = parseInt(start, 10);
|
|
235
|
+
const endNum = parseInt(end, 10);
|
|
236
|
+
for (let j = startNum; j <= endNum; j++) {
|
|
237
|
+
zones.push(`${state}${format}${j.toString().padStart(3, '0')}`);
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
zones.push(`${state}${format}${part}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return zones.filter(item => item !== '');
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export default UGCParser;
|