diff options
author | 2025-07-06 06:25:16 +0200 | |
---|---|---|
committer | 2025-07-06 06:25:16 +0200 | |
commit | d96467f63367c73f5f750e1c72de7ba1f05ef3ff (patch) | |
tree | fb9da935f0e6c23b730b79b1a1fc0551b4bcbd79 /lib.ts | |
download | uic-parse-d96467f63367c73f5f750e1c72de7ba1f05ef3ff.tar.gz uic-parse-d96467f63367c73f5f750e1c72de7ba1f05ef3ff.tar.bz2 uic-parse-d96467f63367c73f5f750e1c72de7ba1f05ef3ff.tar.lz uic-parse-d96467f63367c73f5f750e1c72de7ba1f05ef3ff.zip |
feat: initial commit
Diffstat (limited to 'lib.ts')
-rw-r--r-- | lib.ts | 243 |
1 files changed, 243 insertions, 0 deletions
@@ -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 } |