KItinerary

rct2ticket.cpp
1/*
2 SPDX-FileCopyrightText: 2018 Volker Krause <vkrause@kde.org>
3
4 SPDX-License-Identifier: LGPL-2.0-or-later
5*/
6
7#include "rct2ticket.h"
8#include "logging.h"
9#include "uic9183ticketlayout.h"
10
11#include "text/pricefinder_p.h"
12
13#include <QDateTime>
14#include <QDebug>
15#include <QRegularExpression>
16
17#include <cmath>
18#include <cstring>
19
20using namespace Qt::Literals::StringLiterals;
21using namespace KItinerary;
22
23namespace KItinerary {
24
25class Rct2TicketPrivate : public QSharedData
26{
27public:
28 QDate firstDayOfValidity() const;
29 QDateTime parseTime(const QString &dateStr, const QString &timeStr) const;
30 QString reservationPatternCapture(QStringView name) const;
31
33 QDateTime contextDt;
34};
35
36}
37
38QDate Rct2TicketPrivate::firstDayOfValidity() const
39{
40 const auto f = layout.text(3, 1, 48, 1);
41 const auto it = std::find_if(f.begin(), f.end(), [](QChar c) { return c.isDigit(); });
42 if (it == f.end()) {
43 return {};
44 }
45 const auto dtStr = QStringView(f).mid(std::distance(f.begin(), it));
46 auto dt = QDate::fromString(dtStr.left(10).toString(), QStringLiteral("dd.MM.yyyy"));
47 if (dt.isValid()) {
48 return dt;
49 }
50 dt = QDate::fromString(dtStr.left(8).toString(), QStringLiteral("dd.MM.yy"));
51 if (dt.isValid()) {
52 if (dt.year() < 2000) {
53 dt.setDate(dt.year() + 100, dt.month(), dt.day());
54 }
55 return dt;
56 }
57 dt = QDate::fromString(dtStr.left(4).toString(), QStringLiteral("yyyy"));
58 return dt;
59}
60
61QDateTime Rct2TicketPrivate::parseTime(const QString &dateStr, const QString &timeStr) const
62{
63 auto d = QDate::fromString(dateStr, QStringLiteral("dd.MM"));
64 if (!d.isValid()) {
65 d = QDate::fromString(dateStr, QStringLiteral("dd/MM"));
66 }
67 if (!d.isValid()) {
68 d = QDate::fromString(dateStr, QStringLiteral("dd-MM"));
69 }
70 auto t = QTime::fromString(timeStr, QStringLiteral("hh:mm"));
71 if (!t.isValid()) {
72 t = QTime::fromString(timeStr, QStringLiteral("hh.mm"));
73 }
74
75 const auto validDt = firstDayOfValidity();
76 const auto baseDate = validDt.isValid() ? validDt : contextDt.date();
77 auto dt = QDateTime({baseDate.year(), d.month(), d.day()}, t);
78 if (dt.isValid() && dt.date() < baseDate) {
79 dt = dt.addYears(1);
80 }
81 return dt;
82}
83
84static constexpr const char* res_patterns[] = {
85 "ZUG +(?P<train_number>\\d+) +(?P<train_category>[A-Z][A-Z0-9]+) +WAGEN +(?P<coach>\\d+) +PLATZ +(?P<seat>\\d[\\d, ]+)",
86 "ZUG +(?P<train_number>\\d+) +WAGEN +(?P<coach>\\d+) +PLATZ +(?P<seat>\\d[\\d, ]+)",
87};
88
89QString Rct2TicketPrivate::reservationPatternCapture(QStringView name) const
90{
91 const auto text = layout.text(8, 0, 72, 1);
92 for (const auto *pattern : res_patterns) {
95 Q_ASSERT(re.isValid());
96 const auto match = re.match(text);
97 if (match.hasMatch()) {
98 return match.captured(name);
99 }
100 }
101 return {};
102}
103
104
105// 6x "U_TLAY"
106// 2x version (always "01")
107// 4x record length, numbers as ASCII text
108// 4x ticket layout type ("RCT2")
109// 4x field count
110// Nx fields (see Rct2TicketField)
111Rct2Ticket::Rct2Ticket()
112 : d(new Rct2TicketPrivate)
113{
114}
115
116Rct2Ticket::Rct2Ticket(const Uic9183TicketLayout &layout)
117 : d(new Rct2TicketPrivate)
118{
119 d->layout = layout;
120}
121
122Rct2Ticket::Rct2Ticket(const Rct2Ticket&) = default;
123Rct2Ticket::~Rct2Ticket() = default;
124Rct2Ticket& Rct2Ticket::operator=(const Rct2Ticket&) = default;
125
127{
128 // RCT2 is the correct types, but Snälltâget has a typo in their tickets...
129 return d->layout.isValid() && (d->layout.type() == "RCT2"_L1 || d->layout.type() == "RTC2"_L1);
130}
131
133{
134 d->contextDt = contextDt;
135}
136
137QDate Rct2Ticket::firstDayOfValidity() const
138{
139 return d->firstDayOfValidity();
140}
141
142static constexpr const struct {
143 const char *name; // case folded
144 Rct2Ticket::Type type;
145} rct2_ticket_type_map[] = {
146 { "ticket+reservation", Rct2Ticket::TransportReservation },
147 { "ticket+reservati", Rct2Ticket::TransportReservation },
148 { "ticketwithreservation", Rct2Ticket::TransportReservation },
149 { "fahrschein+reservierung", Rct2Ticket::TransportReservation },
150 { "menetjegy+helyjegy", Rct2Ticket::TransportReservation },
151 { "upgrade", Rct2Ticket::Upgrade },
152 { "aufpreis", Rct2Ticket::Upgrade },
153 { "ticket", Rct2Ticket::Transport },
154 { "billet", Rct2Ticket::Transport },
155 { "fahrkarte", Rct2Ticket::Transport },
156 { "fahrschein", Rct2Ticket::Transport },
157 { "cestovny listok", Rct2Ticket::Transport },
158 { "jizdenka", Rct2Ticket::Transport },
159 { "menetjegy", Rct2Ticket::Transport },
160 { "reservation", Rct2Ticket::Reservation },
161 { "reservierung", Rct2Ticket::Reservation },
162 { "helyjegy", Rct2Ticket::Reservation },
163 { "interrail", Rct2Ticket::RailPass },
164};
165
166Rct2Ticket::Type Rct2Ticket::type() const
167{
168 // in theory: columns 15 - 18 blank, columns 19 - 51 ticket type (1-based indices!)
169 // however, some providers overrun and also use the blank columns, so consider those too
170 // if they are really empty, we trim them anyway.
171 const auto typeName1 = d->layout.text(0, 14, 38, 1).trimmed().remove(QLatin1Char(' ')).toCaseFolded();
172 const auto typeName2 = d->layout.text(1, 14, 38, 1).trimmed().remove(QLatin1Char(' ')).toCaseFolded(); // used for alternative language type name
173
174 // prefer exact matches
175 for (auto it = std::begin(rct2_ticket_type_map); it != std::end(rct2_ticket_type_map); ++it) {
176 if (typeName1 == QLatin1StringView(it->name) ||
177 typeName2 == QLatin1StringView(it->name)) {
178 return it->type;
179 }
180 }
181 for (auto it = std::begin(rct2_ticket_type_map); it != std::end(rct2_ticket_type_map); ++it) {
182 if (typeName1.contains(QLatin1StringView(it->name)) ||
183 typeName2.contains(QLatin1StringView(it->name))) {
184 return it->type;
185 }
186 }
187
188 // alternatively, check all fields covering the title area, for even more creative placements...
189 for (const auto &f : d->layout.fields(0, 14, 38, 2)) {
190 for (auto it = std::begin(rct2_ticket_type_map); it != std::end(rct2_ticket_type_map); ++it) {
191 if (f.text().toCaseFolded().contains(QLatin1StringView(it->name))) {
192 return it->type;
193 }
194 }
195 }
196
197 return Unknown;
198}
199
200QString Rct2Ticket::title() const
201{
202 // RPT has shorter title fields
203 if (type() == Rct2Ticket::RailPass) {
204 return d->layout.text(0, 18, 19, 1);
205 }
206
207 // somewhat standard compliant layout
208 if (d->layout.text(0, 15, 3, 1).trimmed().isEmpty()) {
209 const auto s = d->layout.text(0, 18, 33, 1).trimmed();
210 return s.isEmpty() ? d->layout.text(1, 18, 33, 1).trimmed() : s;
211 }
212
213 // "creative" layout
214 return d->layout.text(0, 0, 52, 1).trimmed();
215}
216
217QString Rct2Ticket::passengerName() const
218{
219 const auto name = d->layout.text(0, 52, 19, 1).trimmed();
220 // sanity-check if this is a plausible name, e.g. Renfe has random other stuff here
221 return std::any_of(name.begin(), name.end(), [](QChar c) { return c.isDigit(); }) ? QString() : name;
222}
223
224QDateTime Rct2Ticket::outboundDepartureTime() const
225{
226 return d->parseTime(d->layout.text(6, 1, 5, 1).trimmed(), d->layout.text(6, 7, 5, 1).trimmed());
227}
228
229QDateTime Rct2Ticket::outboundArrivalTime() const
230{
231 auto dt = d->parseTime(d->layout.text(6, 52, 5, 1).trimmed(), d->layout.text(6, 58, 5, 1).trimmed());
232 if (dt.isValid() && dt < outboundDepartureTime()) {
233 dt = dt.addYears(1);
234 }
235 return dt;
236}
237
238static QString rct2Clean(const QString &s)
239{
240 // * is used to mark unset fields
241 if (std::all_of(s.begin(), s.end(), [](QChar c) { return c == QLatin1Char('*'); })) {
242 return {};
243 }
244 return s;
245}
246
247QString Rct2Ticket::outboundDepartureStation() const
248{
249 if (type() == RailPass) {
250 return {};
251 }
252
253 // 6, 13, 17, 1 would be according to spec, but why stick to that...
254 const auto fields = d->layout.containedFields(6, 13, 17, 1);
255 if (fields.size() == 1) {
256 return rct2Clean(fields[0].text().trimmed());
257 }
258 return rct2Clean(d->layout.text(6, 12, 18, 1).trimmed());
259}
260
261QString Rct2Ticket::outboundArrivalStation() const
262{
263 return type() != RailPass ? rct2Clean(d->layout.text(6, 34, 17, 1).trimmed()) : QString();
264}
265
266QString Rct2Ticket::outboundClass() const
267{
268 return rct2Clean(d->layout.text(6, 66, 5, 1).trimmed());
269}
270
271QDateTime Rct2Ticket::returnDepartureTime() const
272{
273 return d->parseTime(d->layout.text(7, 1, 5, 1).trimmed(), d->layout.text(7, 7, 5, 1).trimmed());
274}
275
276QDateTime Rct2Ticket::returnArrivalTime() const
277{
278 auto dt = d->parseTime(d->layout.text(7, 52, 5, 1).trimmed(), d->layout.text(7, 58, 5, 1).trimmed());
279 if (dt.isValid() && dt < returnDepartureTime()) {
280 dt = dt.addYears(1);
281 }
282 return dt;
283}
284
285QString Rct2Ticket::returnDepartureStation() const
286{
287 // 7, 13, 17, 1 would be according to spec, but you can guess by now how well that is followed...
288 return type() != RailPass ? rct2Clean(d->layout.text(7, 12, 18, 1).trimmed()) : QString();
289}
290
291QString Rct2Ticket::returnArrivalStation() const
292{
293 return type() != RailPass ? rct2Clean(d->layout.text(7, 34, 17, 1).trimmed()) : QString();
294}
295
296QString Rct2Ticket::returnClass() const
297{
298 return rct2Clean(d->layout.text(7, 66, 5, 1).trimmed());
299}
300
301QString Rct2Ticket::trainNumber() const
302{
303 const auto t = type();
304 if (t == Reservation || t == TransportReservation || t == Upgrade) {
305 auto num = d->reservationPatternCapture(u"train_number");
306 if (!num.isEmpty()) {
307 return d->reservationPatternCapture(u"train_category") + QLatin1Char(' ') + num;
308 }
309
310 const auto cat = d->layout.text(8, 13, 3, 1).trimmed();
311 num = d->layout.text(8, 7, 5, 1).trimmed();
312
313 // check for train number bleeding into our left neighbour field (happens e.g. on ÖBB IRT/RES tickets)
314 if (num.isEmpty() || num.at(0).isDigit()) {
315 const auto numPrefix = d->layout.text(8, 1, 6, 1);
316 for (int i = numPrefix.size() - 1; i >= 0; --i) {
317 if (numPrefix.at(i).isDigit()) {
318 num.prepend(numPrefix.at(i));
319 } else {
320 break;
321 }
322 }
323 }
324 num = num.trimmed();
325
326 if (!cat.isEmpty()) {
327 return cat + QLatin1Char(' ') + num;
328 }
329 return num;
330 }
331 return {};
332}
333
334QString Rct2Ticket::coachNumber() const
335{
336 const auto t = type();
337 if (t == Reservation || t == TransportReservation) {
338 const auto coach = d->reservationPatternCapture(u"coach");
339 return coach.isEmpty() ? d->layout.text(8, 26, 3, 1).trimmed() : coach;
340 }
341 return {};
342}
343
344QString Rct2Ticket::seatNumber() const
345{
346 const auto t = type();
347 if (t == Reservation || t == TransportReservation) {
348 const auto seat = d->reservationPatternCapture(u"seat");
349 if (!seat.isEmpty()) {
350 return seat;
351 }
352
353 const auto row8 = d->layout.text(8, 48, 23, 1).trimmed();
354 if (!row8.isEmpty()) {
355 return row8;
356 }
357 // rows 9/10 can contain seating details, let's use those as fallback if we don't find a number in the right field
358 return d->layout.text(9, 32, 19, 2).simplified();
359 }
360 return {};
361}
362
363QString Rct2Ticket::currency() const
364{
365 std::vector<PriceFinder::Result> result;
366 PriceFinder finder;
367 finder.findAll(d->layout.text(13, 52, 19, 1).remove(QLatin1Char('*')), result);
368 return result.size() == 1 ? result[0].currency : QString();
369}
370
371double Rct2Ticket::price() const
372{
373 std::vector<PriceFinder::Result> result;
374 PriceFinder finder;
375 finder.findAll(d->layout.text(13, 52, 19, 1).remove(QLatin1Char('*')), result);
376 return result.size() == 1 ? result[0].value : NAN;
377}
378
379#include "moc_rct2ticket.cpp"
380
RCT2 ticket layout payload of an UIC 918.3 ticket token.
Definition rct2ticket.h:23
Type
Type of RCT2 ticket.
Definition rct2ticket.h:69
@ Transport
Non-integrated Reservation Ticket (NRT)
Definition rct2ticket.h:70
@ RailPass
Rail Pass Ticket (RPT)
Definition rct2ticket.h:74
@ Upgrade
Update Document (UPG)
Definition rct2ticket.h:73
@ TransportReservation
Integration Reservation Ticket (IRT)
Definition rct2ticket.h:71
@ Unknown
ticket type could not be detected, or ticket type not supported yet
Definition rct2ticket.h:75
@ Reservation
Reservation Only Document (RES)
Definition rct2ticket.h:72
void setContextDate(const QDateTime &contextDt)
Date/time this ticket was first encountered, to recover possibly missing year numbers.
bool isValid() const
Returns whether this is a valid RCT2 ticket layout block.
Abstract base class for reservations.
Definition reservation.h:25
Parser for a U_TLAY block in a UIC 918-3 ticket container, such as a ERA TLB ticket.
Q_INVOKABLE QString text(int row, int column, int width, int height) const
Returns the text in the given coordinates.
KCOREADDONS_EXPORT Result match(QStringView pattern, QStringView str)
Classes for reservation/travel data models, data extraction and data augmentation.
Definition berelement.h:17
QDate fromString(QStringView string, QStringView format, QCalendar cal)
QDate date() const const
iterator begin()
iterator end()
qsizetype size() const const
QStringView mid(qsizetype start, qsizetype length) const const
QTime fromString(QStringView string, QStringView format)
This file is part of the KDE documentation.
Documentation copyright © 1996-2024 The KDE developers.
Generated on Fri Oct 4 2024 12:00:26 by doxygen 1.12.0 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.