/* * Copyright (c) 2025 memdmp * * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ 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}`; public getVehicleType(): `${Digit}${Digit}` { return this.vehicleType } /** Only available if Country is null */ protected countryCode: Country extends null ? `${Digit}${Digit}` : undefined; protected vehicleFamily: `${Digit}${Digit}${Digit}${Digit}`; public getVehicleFamily(): `${Digit}${Digit}${Digit}${Digit}` { return this.vehicleFamily } protected serialNumber: `${Digit}${Digit}${Digit}`; public getSerialNumber(): `${Digit}${Digit}${Digit}` { return this.serialNumber } public getSelfCheckDigit(): Digit { return UICVehicle.getSelfCheckDigit(this.vehicleType + (this.country?.uicIdentifier ?? this.countryCode) + this.vehicleFamily + this.serialNumber).toString() as Digit } public toString() { const cc = this.country?.uicIdentifier ?? this.countryCode; const ccstr = (this.country?.treatyShort ?? UICCountry.countryMaps.byUICIdentificationNumber.get(this.countryCode!)?.treatyShort ?? null) // Separation of first character of vehicleFamily for is typically done in Switzerland for SBB and BLS (although not Thurbo?) for their EMUs (first digit often signifying wagon, 0 for entire vehicle) - and not in germany, unsure about elsewhere return `${this.vehicleType} ${cc} ${cc === '85' && [ // TODO: figure out if 5 is a reserved prefix (if so, where is it specified) for EMUs '500', '501', '502', '503', '510', '511', '512', '514', '515', '520', '521', '522', '523', '524', '525', '526', '528', '535', '565', '566' ].includes(this.vehicleFamily.substring(1)) ? `${this.vehicleFamily.charAt(1) + ' ' + this.vehicleFamily.substring(1)}` : this.vehicleFamily} ${this.serialNumber} ${this.getSelfCheckDigit()}${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.getSelfCheckDigit(), 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.getSelfCheckDigit() !== 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 }