aboutsummaryrefslogtreecommitdiffstats
path: root/lib.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib.ts')
-rw-r--r--lib.ts243
1 files changed, 243 insertions, 0 deletions
diff --git a/lib.ts b/lib.ts
new file mode 100644
index 0000000..281e9c9
--- /dev/null
+++ b/lib.ts
@@ -0,0 +1,243 @@
+import { type Digit, UICCountry } from './countries.ts';
+export type UICParserOptions = {
+ /**
+ * Fetch the operator name from the {@link https://en.wikipedia.org/wiki/Reporting_mark#Europe_since_2006 reporting mark}.
+ *
+ * If `false`, reporting mark will be ignored entirely. If `true`, it will be used to query an operator string. Note that Diacritical marks are kept by this library, {@link https://en.wikipedia.org/wiki/Reporting_mark#Europe_since_2006 they should be removed prior to parsing}.<br/>
+ *
+ * Ignored if reporting mark not in UIC or unidentifiable
+ *
+ * @default true
+ * @see allowUnknownReportingMarkCountry
+ * @see allowMissmatchedReportingMarkCountry
+ */
+ fetchOperatorFromReportingMark?: boolean,
+ /**
+ * Allow the country field in the {@link https://en.wikipedia.org/wiki/Reporting_mark#Europe_since_2006 reporting mark} to missmatch the UIC number.
+ *
+ * If `true`, reporting mark country will be ignored. If `false`, it will be validated.
+ *
+ * If {@link fetchOperatorFromReportingMark} is `false`, this value is ignored.
+ *
+ * @default true
+ * @see allowUnknownReportingMarkCountry
+ * @see fetchOperatorFromReportingMark
+ */
+ allowMissmatchedReportingMarkCountry?: boolean,
+ /**
+ * Allow the country field in the {@link https://en.wikipedia.org/wiki/Reporting_mark#Europe_since_2006 reporting mark} to be an unknown value.
+ *
+ * If {@link fetchOperatorFromReportingMark} is `false`, this value is ignored.
+ *
+ * If {@link allowMissmatchedReportingMarkCountry} is `true`, this value is ignored.
+ *
+ * If {@link _dangerous_allowUICCountryUnknown} is `true`, this value is ignored when the UIC country could not be identified.
+ *
+ * If `true`, an unknown country is treated as if it was the correct country. If `false`, countries we can't identify will error.
+ *
+ * @default true
+ * @see allowMissmatchedReportingMarkCountry
+ * @see fetchOperatorFromReportingMark
+ */
+ allowUnknownReportingMarkCountry?: boolean,
+ /**
+ * Allow the country field in the UIC code to be an unknown value.
+ *
+ * If `true`, an unknown country is given a value of null. If `false`, countries we can't identify will error.
+ *
+ * If `false`, a bunch of operations may become unexpectedly lossy, such as conversion to string and back.
+ *
+ * @default true
+ */
+ allowUICCountryUnknown?: boolean,
+ /**
+ * Validates the check digit.
+ *
+ * If `true`, an incorrect check digit throws. If `false`, an incorrect check digit is ignored and we override it.
+ *
+ * When no check digit is provided, this value is always treated as `false`.
+ *
+ * @default true
+ */
+ validateCheckDigit?: boolean,
+ // TODO: Add option to, when we fail to identify the country from the UIC, attempt to find it from the reporting mark and override the country code using that.
+}
+export default class UICVehicle<Operator extends string | null, Country extends UICCountry | null = UICCountry | null> {
+ public static getSelfCheckDigit(uic: string) {
+ if ((/[^0-9]/g).test(uic)) throw new Error('Expected a UIC with only digits.')
+ if (uic.length === 12)
+ throw new Error('This function assumes you pass a UIC *without* a self-check digit.')
+ if (uic.length !== 11)
+ throw new Error('UIC does not have length of 11.')
+
+ let uicSum = 0;
+ let iterator = 0;
+
+ for (let char of uic) {
+ let match = parseInt(char, 10);
+ if (iterator % 2 === 0) {
+ let oddNumber = 2 * match;
+ if (oddNumber > 9) {
+ uicSum += oddNumber - 9;
+ } else {
+ uicSum += oddNumber;
+ }
+ } else {
+ uicSum += match;
+ }
+ iterator++;
+ }
+
+ let uicSelfCheckDigit = (uicSum % 10) > 0 ? 10 - (uicSum % 10) : 0;
+ return uicSelfCheckDigit;
+ }
+ protected readonly country: Country;
+ public hasCountry(): Country extends null ? false : true {
+ // @ts-ignore
+ return this.country !== null;
+ }
+ public getCountry(): Country extends null ? never : string {
+ if (!this.hasCountry()) throw new Error('UIC has no country!')
+ // @ts-ignore
+ return this.country;
+ }
+ protected operator = null as Operator;
+ public hasOperator(): Operator extends null ? false : true {
+ // @ts-ignore
+ return this.operator !== null;
+ }
+ public getOperator(): Operator extends null ? never : string {
+ if (!this.hasOperator()) throw new Error('UIC has no operator!')
+ // @ts-ignore
+ return this.operator;
+ }
+ protected vehicleType: `${Digit}${Digit}`;
+ /** Only available if Country is null */
+ protected countryCode: Country extends null ? `${Digit}${Digit}` : undefined;
+ protected vehicleFamily: `${Digit}${Digit}${Digit}${Digit}`;
+ protected serialNumber: `${Digit}${Digit}${Digit}`;
+ public get selfCheckDigit(): Digit {
+ return UICVehicle.getSelfCheckDigit(this.vehicleType + (this.country?.uicIdentifier ?? this.countryCode) + this.vehicleFamily + this.serialNumber).toString() as Digit
+ }
+ public toString() {
+ // Separation of first character of vehicleFamily is typically done in Switzerland for SBB and BLS (although not Thurbo?) - and not in germany, unsure about elsewhere
+ const cc = this.country?.uicIdentifier ?? this.countryCode;
+ const ccstr = (this.country?.treatyShort ?? UICCountry.countryMaps.byUICIdentificationNumber.get(this.countryCode!)?.treatyShort ?? null)
+ return `${this.vehicleType} ${cc} ${cc === '85' ? `${this.vehicleFamily.charAt(1) + ' ' + this.vehicleFamily.substring(1)}` : this.vehicleFamily} ${this.serialNumber} ${this.selfCheckDigit}${ccstr && this.operator ? ` ${ccstr}-${this.operator}` : ''}`
+ }
+ public toJSON() {
+ return {
+ operator: this.operator,
+ vehicleType: this.vehicleType,
+ vehicleFamily: this.vehicleFamily,
+ serialNumber: this.serialNumber,
+ countryCode: this.country?.uicIdentifier ?? this.countryCode,
+ checkDigit: this.selfCheckDigit,
+ country: this.country
+ }
+ }
+ public constructor(uic: string, options?: UICParserOptions) {
+ if (!uic)
+ throw new Error('UIC is empty or null')
+ if (typeof uic !== 'string')
+ throw new Error('UIC is not a string')
+
+ // Parse Options
+ const willQueryReportingMark = (options?.fetchOperatorFromReportingMark ?? true)
+ const willValidateCountryCode = willQueryReportingMark ? !(options?.allowMissmatchedReportingMarkCountry ?? true) : false
+ const countryCodeValidationPermitsUnknowns = willValidateCountryCode ? (options?.allowUnknownReportingMarkCountry ?? true) : true
+ const allowUICCountryUnknown = options?.allowUICCountryUnknown ?? true;
+ const validateCheckDigit = options?.validateCheckDigit ?? true;
+
+ // 1st part of expression validates the length of the code
+ // 2nd part checks if a [Reporting Mark](https://en.wikipedia.org/wiki/Reporting_mark#Europe_since_2006) is present, to find operator information.
+ // Note: The first capture group *may* include random characters. We filter those out later.
+ // Note 2: This is resilient to *some* errors in *some* places; such as ignoring alphabetic characters in the number, or any non-characters before the reporting mark (or in it's separator)
+ // Note 3: Adding a space to the end of the UIC is really a bad hotfix, however it solves UICs like `94 85 1 511 052-6` not having the -6 parsed
+ const firstParse = `${uic} `.match(/((?:[^0-9]*?[0-9][-_]*){11,12})[^a-zA-Z\u00C0-\u024F\u1E00-\u1EFF]+([a-zA-Z\u00C0-\u024F\u1E00-\u1EFF]+[^a-zA-Z\u00C0-\u024F\u1E00-\u1EFF]+[a-zA-Z\u00C0-\u024F\u1E00-\u1EFF]{2,5})?/)
+ if (!firstParse) throw new Error('Invalid UIC code.')
+
+ // Now, extract the numeric part
+ const numeric = firstParse[1].replace(/[^0-9]/g, '').match(/^(?<vehicleType>[0-9]{2})(?<countryCode>[0-9]{2})(?<vehicleFamily>[0-9]{4})(?<serialNumber>[0-9]{3})(?<selfCheckDigit>[0-9])?$/) as (RegExpMatchArray & {
+ groups: {
+ vehicleType: `${Digit}${Digit}`,
+ countryCode: `${Digit}${Digit}`,
+ vehicleFamily: `${Digit}${Digit}${Digit}${Digit}`,
+ serialNumber: `${Digit}${Digit}${Digit}`,
+ selfCheckDigit: Digit,
+ }
+ }) | null;
+ if (!numeric) throw new Error('Failed to parse UIC code. Invalid length? Parser bug? Who knows at this point! Anyways, you fucked up bad to get here.')
+
+ // Calculate an expected check digit
+ const selfCheckDigit = UICVehicle.getSelfCheckDigit(`${(['vehicleType', 'countryCode', 'vehicleFamily', 'serialNumber'] as const).map(v => numeric.groups[v]).join('')}`)
+ if (validateCheckDigit && numeric.groups.selfCheckDigit !== undefined && parseInt(numeric.groups.selfCheckDigit) !== selfCheckDigit) throw new Error(`Incorrect Self-Check Digit! Expected ${selfCheckDigit}, got ${numeric.groups.selfCheckDigit}`)
+
+ // Aaand the reporting mark
+ const reportingMark = (willQueryReportingMark
+ ? ((firstParse[2]?.match(/(?<countryCode>[a-zA-Z\u00C0-\u024F\u1E00-\u1EFF]+)[^a-zA-Z\u00C0-\u024F\u1E00-\u1EFF]+(?<operator>[a-zA-Z\u00C0-\u024F\u1E00-\u1EFF]{2,5})/) ?? null))
+ : null) as (RegExpMatchArray & {
+ groups: {
+ countryCode: string,
+ operator: string
+ }
+ }) | null
+ if (reportingMark)
+ this.operator = reportingMark.groups.operator as Operator
+
+ // Query the country we have
+ this.country = (UICCountry.countryMaps.byUICIdentificationNumber.get(numeric.groups.countryCode) ?? null) as Country;
+ if (!this.country && !allowUICCountryUnknown)
+ throw new Error('allowUICCountryUnknown===false yet invalid country. Code ' + numeric.groups.countryCode + ' didn\'t get us anywhere. Time for an update?')
+
+ // Validate the reporting mark if options tell us to
+ if (willValidateCountryCode && reportingMark) {
+ const code = reportingMark.groups.countryCode
+ const markCountry = UICCountry.countryMaps.by1958TreatyShortCode.get(code) ?? UICCountry.countryMaps.byIso.get(code);
+ if (!markCountry) {
+ if (!countryCodeValidationPermitsUnknowns) throw new Error(`Country Code ${JSON.stringify(code)} not found in Country Code Store. Please validate.`)
+ } else if (this.country && !markCountry.equals(this.country))
+ throw new Error(`Reporting Mark Country Code ${JSON.stringify(code)} (${markCountry.name}) is not ${this.country.name}.`)
+ }
+
+ this.vehicleType = numeric.groups.vehicleType
+ this.vehicleFamily = numeric.groups.vehicleFamily
+ this.serialNumber = numeric.groups.serialNumber
+ if (!this.country)
+ // @ts-ignore
+ this.countryCode = numeric.groups.countryCode
+ else
+ // Hide from enumeration
+ delete this.countryCode
+
+ // Finally, verify we didn't go insane
+ if (this.selfCheckDigit !== selfCheckDigit.toString())
+ throw new Error('Self Check Digit Mismatch!')
+ }
+ public static fromJSON<Operator extends string | null>(json: {
+ operator: Operator;
+ vehicleType: `${Digit}${Digit}`;
+ vehicleFamily: `${Digit}${Digit}${Digit}${Digit}` | `${Digit} ${Digit}${Digit}${Digit}`;
+ serialNumber: `${Digit}${Digit}${Digit}`;
+ checkDigit?: Digit;
+ } & ({
+ countryCode: `${Digit}${Digit}`
+ } | {
+ country: UICCountry;
+ } | {
+ countryCode: `${Digit}${Digit}`
+ country: UICCountry;
+ }), _internalParserOptions?: UICParserOptions) {
+ const cc = (json.operator ? 'country' in json ? json.country.treatyShort : UICCountry.countryMaps.byUICIdentificationNumber.get(json.countryCode)?.treatyShort : null)
+ const reconstructedCode = (`${json.vehicleType} ${'countryCode' in json ? json.countryCode : json.country?.uicIdentifier} ${json.vehicleFamily} ${json.serialNumber}${json.checkDigit ? '-' + json.checkDigit : ''}${cc ? ` ${cc}-${json.operator}` : ''}`)
+ const reconstructedVehicle = new UICVehicle<Operator>(reconstructedCode, {
+ ..._internalParserOptions
+ })
+ if (reconstructedVehicle.operator !== json.operator && !cc)
+ // If our CC detection fucks up, it'll probably mismatch, so we fix that here.
+ reconstructedVehicle.operator = json.operator
+ return reconstructedVehicle
+ }
+}
+
+export { Digit, UICCountry }