aboutsummaryrefslogtreecommitdiffstats
path: root/lib.ts
blob: 4bbd4c33c013fa7de3ca3d04a9286e32828becc1 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
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}`;
  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() {
    // 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.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(/^(?<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.getSelfCheckDigit() !== 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 }