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 }