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}.
* * 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 { 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(/^(?[0-9]{2})(?[0-9]{2})(?[0-9]{4})(?[0-9]{3})(?[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(/(?[a-zA-Z\u00C0-\u024F\u1E00-\u1EFF]+)[^a-zA-Z\u00C0-\u024F\u1E00-\u1EFF]+(?[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(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(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 }