1 /*
2     SPDX-FileCopyrightText: 2018 Volker Krause <vkrause@kde.org>
3 
4     SPDX-License-Identifier: LGPL-2.0-or-later
5 */
6 
7 #include "mergeutil.h"
8 #include "logging.h"
9 #include "compare-logging.h"
10 #include "locationutil.h"
11 #include "stringutil.h"
12 #include "sortutil.h"
13 
14 #include <KItinerary/BusTrip>
15 #include <KItinerary/Event>
16 #include <KItinerary/Flight>
17 #include <KItinerary/JsonLdDocument>
18 #include <KItinerary/RentalCar>
19 #include <KItinerary/Organization>
20 #include <KItinerary/Place>
21 #include <KItinerary/Person>
22 #include <KItinerary/Reservation>
23 #include <KItinerary/Taxi>
24 #include <KItinerary/Ticket>
25 #include <KItinerary/TrainTrip>
26 #include <KItinerary/Visit>
27 
28 #include <QDate>
29 #include <QDebug>
30 #include <QMetaObject>
31 #include <QMetaProperty>
32 #include <QVariant>
33 
34 #include <cmath>
35 
36 using namespace KItinerary;
37 
38 /* Checks that @p lhs and @p rhs are non-empty and equal. */
equalAndPresent(const QString & lhs,const QString & rhs,Qt::CaseSensitivity caseSensitive=Qt::CaseSensitive)39 static bool equalAndPresent(const QString &lhs, const QString &rhs, Qt::CaseSensitivity caseSensitive = Qt::CaseSensitive)
40 {
41     return !lhs.isEmpty() && (lhs.compare(rhs, caseSensitive) == 0);
42 }
43 template <typename T>
equalAndPresent(const T & lhs,const T & rhs)44 static bool equalAndPresent(const T &lhs, const T &rhs)
45 {
46     return lhs.isValid() && lhs == rhs;
47 }
48 
49 /* Checks that @p lhs and @p rhs are not non-equal if both values are set. */
conflictIfPresent(const QString & lhs,const QString & rhs,Qt::CaseSensitivity caseSensitive=Qt::CaseSensitive)50 static bool conflictIfPresent(const QString &lhs, const QString &rhs, Qt::CaseSensitivity caseSensitive = Qt::CaseSensitive)
51 {
52     return !lhs.isEmpty() && !rhs.isEmpty() && lhs.compare(rhs, caseSensitive) != 0;
53 }
54 template <typename T>
conflictIfPresent(const T & lhs,const T & rhs)55 static bool conflictIfPresent(const T &lhs, const T &rhs)
56 {
57     return lhs.isValid() && rhs.isValid() && lhs != rhs;
58 }
59 
60 static bool isSameFlight(const Flight &lhs, const Flight &rhs);
61 static bool isSameTrainTrip(const TrainTrip &lhs, const TrainTrip &rhs);
62 static bool isSameBusTrip(const BusTrip &lhs, const BusTrip &rhs);
63 static bool isSameLodingBusiness(const LodgingBusiness &lhs, const LodgingBusiness &rhs);
64 static bool isSameFoodEstablishment(const FoodEstablishment &lhs, const FoodEstablishment &rhs);
65 static bool isSameTouristAttractionVisit(const TouristAttractionVisit &lhs, const TouristAttractionVisit &rhs);
66 static bool isSameTouristAttraction(const TouristAttraction &lhs, const TouristAttraction &rhs);
67 static bool isSameEvent(const Event &lhs, const Event &rhs);
68 static bool isSameRentalCar(const RentalCar &lhs, const RentalCar &rhs);
69 static bool isSameTaxiTrip(const Taxi &lhs, const Taxi &rhs);
70 static bool isMinimalCancelationFor(const QVariant &r, const Reservation &cancel);
71 
isSame(const QVariant & lhs,const QVariant & rhs)72 bool MergeUtil::isSame(const QVariant& lhs, const QVariant& rhs)
73 {
74     if (lhs.isNull() || rhs.isNull()) {
75         return false;
76     }
77     if (lhs.userType() != rhs.userType()) {
78         return false;
79     }
80 
81     // for all reservations check underName and ticket
82     if (JsonLd::canConvert<Reservation>(lhs)) {
83         const auto lhsRes = JsonLd::convert<Reservation>(lhs);
84         const auto rhsRes = JsonLd::convert<Reservation>(rhs);
85 
86         // for all: underName either matches or is not set
87         const auto lhsUN = lhsRes.underName().value<Person>();
88         const auto rhsUN = rhsRes.underName().value<Person>();
89         if (!lhsUN.name().isEmpty() && !rhsUN.name().isEmpty() &&  !isSamePerson(lhsUN, rhsUN)) {
90             return false;
91         }
92 
93         const auto lhsTicket = lhsRes.reservedTicket().value<Ticket>();
94         const auto rhsTicket = rhsRes.reservedTicket().value<Ticket>();
95         if (conflictIfPresent(lhsTicket.ticketedSeat().seatNumber(), rhsTicket.ticketedSeat().seatNumber(), Qt::CaseInsensitive)) {
96             return false;
97         }
98         // flight ticket tokens (IATA BCBP) can differ, so we need to compare the relevant bits in them manually
99         // this however happens automatically as they are unpacked to other fields by post-processing
100         // so we can simply skip this here for flights
101         if (!JsonLd::isA<FlightReservation>(lhs) && conflictIfPresent(lhsTicket.ticketTokenData(), rhsTicket.ticketTokenData())) {
102             return false;
103         }
104 
105         // one side is a minimal cancellation, matches the reservation number and has a plausible modification time
106         // in this case don't bother comparing content (which will fail), we accept this directly
107         if (isMinimalCancelationFor(lhs, rhsRes) || isMinimalCancelationFor(rhs, lhsRes)) {
108             return true;
109         }
110     }
111 
112     // flight: booking ref, flight number and departure day match
113     if (JsonLd::isA<FlightReservation>(lhs)) {
114         const auto lhsRes = lhs.value<FlightReservation>();
115         const auto rhsRes = rhs.value<FlightReservation>();
116         if (conflictIfPresent(lhsRes.reservationNumber(), rhsRes.reservationNumber()) || conflictIfPresent(lhsRes.passengerSequenceNumber(), rhsRes.passengerSequenceNumber())) {
117             return false;
118         }
119         return isSame(lhsRes.reservationFor(), rhsRes.reservationFor());
120     }
121     if (JsonLd::isA<Flight>(lhs)) {
122         const auto lhsFlight = lhs.value<Flight>();
123         const auto rhsFlight = rhs.value<Flight>();
124         return isSameFlight(lhsFlight, rhsFlight);
125     }
126 
127     // train: booking ref, train number and depature day match
128     if (JsonLd::isA<TrainReservation>(lhs)) {
129         const auto lhsRes = lhs.value<TrainReservation>();
130         const auto rhsRes = rhs.value<TrainReservation>();
131         if (conflictIfPresent(lhsRes.reservationNumber(), rhsRes.reservationNumber())) {
132             return false;
133         }
134         return isSame(lhsRes.reservationFor(), rhsRes.reservationFor());
135     }
136     if (JsonLd::isA<TrainTrip>(lhs)) {
137         const auto lhsTrip = lhs.value<TrainTrip>();
138         const auto rhsTrip = rhs.value<TrainTrip>();
139         return isSameTrainTrip(lhsTrip, rhsTrip);
140     }
141 
142     // bus: booking ref, number and depature time match
143     if (JsonLd::isA<BusReservation>(lhs)) {
144         const auto lhsRes = lhs.value<BusReservation>();
145         const auto rhsRes = rhs.value<BusReservation>();
146         if (lhsRes.reservationNumber() != rhsRes.reservationNumber()) {
147             return false;
148         }
149         return isSame(lhsRes.reservationFor(), rhsRes.reservationFor());
150     }
151     if (JsonLd::isA<BusTrip>(lhs)) {
152         const auto lhsTrip = lhs.value<BusTrip>();
153         const auto rhsTrip = rhs.value<BusTrip>();
154         return isSameBusTrip(lhsTrip, rhsTrip);
155     }
156 
157     // hotel: booking ref, checkin day, name match
158     if (JsonLd::isA<LodgingReservation>(lhs)) {
159         const auto lhsRes = lhs.value<LodgingReservation>();
160         const auto rhsRes = rhs.value<LodgingReservation>();
161         if (lhsRes.reservationNumber() != rhsRes.reservationNumber()) {
162             return false;
163         }
164         return isSame(lhsRes.reservationFor(), rhsRes.reservationFor()) && lhsRes.checkinTime().date() == rhsRes.checkinTime().date();
165     }
166     if (JsonLd::isA<LodgingBusiness>(lhs)) {
167         const auto lhsHotel = lhs.value<LodgingBusiness>();
168         const auto rhsHotel = rhs.value<LodgingBusiness>();
169         return isSameLodingBusiness(lhsHotel, rhsHotel);
170     }
171 
172     // Rental Car
173     if (JsonLd::isA<RentalCarReservation>(lhs)) {
174         const auto lhsRes = lhs.value<RentalCarReservation>();
175         const auto rhsRes = rhs.value<RentalCarReservation>();
176         if (lhsRes.reservationNumber() != rhsRes.reservationNumber()) {
177             return false;
178         }
179         return isSame(lhsRes.reservationFor(), rhsRes.reservationFor()) && lhsRes.pickupTime().date() == rhsRes.pickupTime().date();
180     }
181     if (JsonLd::isA<RentalCar>(lhs)) {
182         const auto lhsEv = lhs.value<RentalCar>();
183         const auto rhsEv = rhs.value<RentalCar>();
184         return isSameRentalCar(lhsEv, rhsEv);
185     }
186 
187     // Taxi
188     if (JsonLd::isA<TaxiReservation>(lhs)) {
189         const auto lhsRes = lhs.value<TaxiReservation>();
190         const auto rhsRes = rhs.value<TaxiReservation>();
191         if (lhsRes.reservationNumber() != rhsRes.reservationNumber()) {
192             return false;
193         }
194         return isSame(lhsRes.reservationFor(), rhsRes.reservationFor()) && lhsRes.pickupTime().date() == rhsRes.pickupTime().date();
195     }
196     if (JsonLd::isA<Taxi>(lhs)) {
197         const auto lhsEv = lhs.value<Taxi>();
198         const auto rhsEv = rhs.value<Taxi>();
199         return isSameTaxiTrip(lhsEv, rhsEv);
200     }
201 
202     // restaurant reservation: same restaurant, same booking ref, same day
203     if (JsonLd::isA<FoodEstablishmentReservation>(lhs)) {
204         const auto lhsRes = lhs.value<FoodEstablishmentReservation>();
205         const auto rhsRes = rhs.value<FoodEstablishmentReservation>();
206         if (lhsRes.reservationNumber() != rhsRes.reservationNumber()) {
207             return false;
208         }
209         auto endTime = rhsRes.endTime();
210         if (!endTime.isValid()) {
211             endTime = QDateTime(rhsRes.startTime().date(), QTime(23, 59, 59));
212         }
213 
214         return isSame(lhsRes.reservationFor(), rhsRes.reservationFor()) && lhsRes.startTime().date() == endTime.date();
215     }
216     if (JsonLd::isA<FoodEstablishment>(lhs)) {
217         const auto lhsRestaurant = lhs.value<FoodEstablishment>();
218         const auto rhsRestaurant = rhs.value<FoodEstablishment>();
219         return isSameFoodEstablishment(lhsRestaurant, rhsRestaurant);
220     }
221 
222     // event reservation
223     if (JsonLd::isA<EventReservation>(lhs)) {
224         const auto lhsRes = lhs.value<EventReservation>();
225         const auto rhsRes = rhs.value<EventReservation>();
226         if (lhsRes.reservationNumber() != rhsRes.reservationNumber()) {
227             return false;
228         }
229         return isSame(lhsRes.reservationFor(), rhsRes.reservationFor());
230     }
231     if (JsonLd::isA<Event>(lhs)) {
232         const auto lhsEv = lhs.value<Event>();
233         const auto rhsEv = rhs.value<Event>();
234         return isSameEvent(lhsEv, rhsEv);
235     }
236 
237     // tourist attraction visit
238     if (JsonLd::isA<TouristAttractionVisit>(lhs)) {
239         const auto l = lhs.value<TouristAttractionVisit>();
240         const auto r = rhs.value<TouristAttractionVisit>();
241         return isSameTouristAttractionVisit(l, r);
242     }
243 
244     return true;
245 }
246 
isSameFlight(const Flight & lhs,const Flight & rhs)247 static bool isSameFlight(const Flight& lhs, const Flight& rhs)
248 {
249     // if there is a conflict on where this is going, or when, this is obviously not the same flight
250     if (conflictIfPresent(lhs.departureAirport().iataCode(), rhs.departureAirport().iataCode()) ||
251         conflictIfPresent(lhs.arrivalAirport().iataCode(), rhs.arrivalAirport().iataCode()) ||
252         !equalAndPresent(lhs.departureDay(), rhs.departureDay())) {
253         return false;
254     }
255 
256     // same flight number and airline (on the same day) -> we assume same flight
257     if (equalAndPresent(lhs.flightNumber(), rhs.flightNumber()) && equalAndPresent(lhs.airline().iataCode(), rhs.airline().iataCode())) {
258         return true;
259     }
260 
261     // we get here if we have matching origin/destination on the same day, but mismatching flight numbers
262     // so this might be a codeshare flight
263     // our caller checks for matching booking ref, so just look for a few counter-indicators here
264     // (that is, if this is ever made available as standalone API, the last return should not be true)
265     if (conflictIfPresent(lhs.departureTime(), rhs.departureTime())) {
266         return false;
267     }
268 
269     return true;
270 }
271 
272 // see kpublictrainport, line.cpp
273 template <typename Iter>
isSameLineName(const Iter & lBegin,const Iter & lEnd,const Iter & rBegin,const Iter & rEnd)274 static bool isSameLineName(const Iter &lBegin, const Iter &lEnd, const Iter &rBegin, const Iter &rEnd)
275 {
276     auto lIt = lBegin;
277     auto rIt = rBegin;
278     while (lIt != lEnd && rIt != rEnd) {
279         // ignore spaces etc.
280         if (!(*lIt).isLetter() && !(*lIt).isDigit()) {
281             ++lIt;
282             continue;
283         }
284         if (!(*rIt).isLetter() && !(*rIt).isDigit()) {
285             ++rIt;
286             continue;
287         }
288 
289         if ((*lIt).toCaseFolded() != (*rIt).toCaseFolded()) {
290             return false;
291         }
292 
293         ++lIt;
294         ++rIt;
295     }
296 
297     if (lIt == lEnd && rIt == rEnd) { // both inputs fully consumed, and no mismatch found
298         return true;
299     }
300 
301     // one input is prefix of the other, that is ok if there's a separator
302     return (lIt != lEnd && (*lIt).isSpace()) || (rIt != rEnd && (*rIt).isSpace());
303 }
304 
isSameLineName(const QString & lhs,const QString & rhs)305 static bool isSameLineName(const QString &lhs, const QString &rhs)
306 {
307     return isSameLineName(lhs.begin(), lhs.end(), rhs.begin(), rhs.end())
308         || isSameLineName(lhs.rbegin(), lhs.rend(), rhs.rbegin(), rhs.rend());
309 }
310 
isSameTrainTrip(const TrainTrip & lhs,const TrainTrip & rhs)311 static bool isSameTrainTrip(const TrainTrip &lhs, const TrainTrip &rhs)
312 {
313     if (lhs.departureDay() != rhs.departureDay()) {
314         return false;
315     }
316 
317     // for unbound tickets, comparing the line number below won't help
318     // so we have to use the slightly less robust location comparison
319     if (!lhs.departureTime().isValid() && !rhs.departureTime().isValid()) {
320         qCDebug(CompareLog) << "unbound trip" << lhs.departureStation().name() << rhs.departureStation().name() << lhs.arrivalStation().name() << rhs.arrivalStation().name();
321         return lhs.departureStation().name() == rhs.departureStation().name() && lhs.arrivalStation().name() == rhs.arrivalStation().name();
322     } else if (!equalAndPresent(lhs.departureTime(), rhs.departureTime())) {
323         return false;
324     }
325 
326     if (lhs.trainNumber().isEmpty() || rhs.trainNumber().isEmpty()) {
327         qCDebug(CompareLog) << "missing train number" << lhs.trainNumber() << rhs.trainNumber();
328         return false;
329     }
330 
331     const auto isSameLine = isSameLineName(lhs.trainNumber(), rhs.trainNumber());
332     qCDebug(CompareLog) << "left:" << lhs.trainName() << lhs.trainNumber() << lhs.departureTime();
333     qCDebug(CompareLog) << "right:" << rhs.trainName() << rhs.trainNumber() << rhs.departureTime();
334     qCDebug(CompareLog) << "same line:" << isSameLine;
335     return !conflictIfPresent(lhs.trainName(),rhs.trainName()) && isSameLine && lhs.departureTime().date() == rhs.departureTime().date();
336 }
337 
isSameBusTrip(const BusTrip & lhs,const BusTrip & rhs)338 static bool isSameBusTrip(const BusTrip &lhs, const BusTrip &rhs)
339 {
340     if (lhs.busNumber().isEmpty() || rhs.busNumber().isEmpty()) {
341         return false;
342     }
343 
344     return lhs.busName() == rhs.busName() && lhs.busNumber() == rhs.busNumber() && lhs.departureTime() == rhs.departureTime();
345 }
346 
isSameLodingBusiness(const LodgingBusiness & lhs,const LodgingBusiness & rhs)347 static bool isSameLodingBusiness(const LodgingBusiness &lhs, const LodgingBusiness &rhs)
348 {
349     if (lhs.name().isEmpty() || rhs.name().isEmpty()) {
350         return false;
351     }
352 
353     return lhs.name() == rhs.name();
354 }
355 
isSameFoodEstablishment(const FoodEstablishment & lhs,const FoodEstablishment & rhs)356 static bool isSameFoodEstablishment(const FoodEstablishment &lhs, const FoodEstablishment &rhs)
357 {
358     if (lhs.name().isEmpty() || rhs.name().isEmpty()) {
359         return false;
360     }
361 
362     return lhs.name() == rhs.name();
363 }
364 
isSameTouristAttractionVisit(const TouristAttractionVisit & lhs,const TouristAttractionVisit & rhs)365 static bool isSameTouristAttractionVisit(const TouristAttractionVisit &lhs, const TouristAttractionVisit &rhs)
366 {
367     return lhs.arrivalTime() == rhs.arrivalTime() && isSameTouristAttraction(lhs.touristAttraction(), rhs.touristAttraction());
368 }
369 
isSameTouristAttraction(const TouristAttraction & lhs,const TouristAttraction & rhs)370 static bool isSameTouristAttraction(const TouristAttraction &lhs, const TouristAttraction &rhs)
371 {
372     return lhs.name() == rhs.name();
373 }
374 
375 // compute the "difference" between @p lhs and @p rhs
diffString(const QString & lhs,const QString & rhs)376 static QString diffString(const QString &lhs, const QString &rhs)
377 {
378     QString diff;
379     // this is just a basic linear-time heuristic, this would need to be more something like
380     // the Levenstein Distance algorithm
381     for (int i = 0, j = 0; i < lhs.size() || j < rhs.size();) {
382         if (i < lhs.size() && j < rhs.size() && StringUtil::normalize(lhs[i]) == StringUtil::normalize(rhs[j])) {
383             ++i;
384             ++j;
385             continue;
386         }
387         if ((j < rhs.size() && (lhs.size() < rhs.size() || (lhs.size() == rhs.size() && j < i))) || i == lhs.size()) {
388             diff += rhs[j];
389             ++j;
390         } else {
391             diff += lhs[i];
392             ++i;
393         }
394     }
395     return diff.trimmed();
396 }
397 
isNameEqualish(const QString & lhs,const QString & rhs)398 static bool isNameEqualish(const QString &lhs, const QString &rhs)
399 {
400     if (lhs.isEmpty() || rhs.isEmpty()) {
401         return false;
402     }
403 
404     auto diff = diffString(lhs, rhs).toUpper();
405 
406     // remove honoric prefixes from the diff, in case the previous check didn't catch that
407     diff.remove(QLatin1String("MRS"));
408     diff.remove(QLatin1String("MR"));
409     diff.remove(QLatin1String("MS"));
410 
411     // if there's letters in the diff, we assume this is different
412     for (const auto c : diff) {
413         if (c.isLetter()) {
414             return false;
415         }
416     }
417 
418     return true;
419 }
420 
isSamePerson(const Person & lhs,const Person & rhs)421 bool MergeUtil::isSamePerson(const Person& lhs, const Person& rhs)
422 {
423     return isNameEqualish(lhs.name(), rhs.name()) ||
424         (isNameEqualish(lhs.givenName(), rhs.givenName()) && isNameEqualish(lhs.familyName(), rhs.familyName()));
425 }
426 
isSameEvent(const Event & lhs,const Event & rhs)427 static bool isSameEvent(const Event &lhs, const Event &rhs)
428 {
429     if (!equalAndPresent(lhs.startDate(), rhs.startDate())) {
430         return false;
431     }
432 
433     // event names can contain additional qualifiers, like for Adult/Child tickets,
434     // those don't change the event though
435     const auto namePrefix = StringUtil::prefixSimilarity(lhs.name(), rhs.name());
436     return namePrefix == 1.0f || (namePrefix > 0.65f && LocationUtil::isSameLocation(lhs.location(), rhs.location(), LocationUtil::Exact));
437 }
438 
isSameRentalCar(const RentalCar & lhs,const RentalCar & rhs)439 static bool isSameRentalCar(const RentalCar &lhs, const RentalCar &rhs)
440 {
441     return lhs.name() == rhs.name();
442 }
443 
isSameTaxiTrip(const Taxi & lhs,const Taxi & rhs)444 static bool isSameTaxiTrip(const Taxi &lhs, const Taxi &rhs)
445 {
446     //TODO verify
447     return lhs.name() == rhs.name();
448 }
449 
mergeValue(const Airline & lhs,const Airline & rhs)450 static Airline mergeValue(const Airline &lhs, const Airline &rhs)
451 {
452     auto a = JsonLdDocument::apply(lhs, rhs).value<Airline>();
453     a.setName(StringUtil::betterString(lhs.name(), rhs.name()).toString());
454     return a;
455 }
456 
mergeValue(const QDateTime & lhs,const QDateTime & rhs)457 static QDateTime mergeValue(const QDateTime &lhs, const QDateTime &rhs)
458 {
459     // prefer value with timezone
460     return lhs.isValid() && lhs.timeSpec() == Qt::TimeZone && rhs.timeSpec() != Qt::TimeZone ? lhs : rhs;
461 }
462 
mergeValue(const Person & lhs,const Person & rhs)463 static Person mergeValue(const Person &lhs, const Person &rhs)
464 {
465     auto p = JsonLdDocument::apply(lhs, rhs).value<Person>();
466     p.setFamilyName(StringUtil::betterString(lhs.familyName(), rhs.familyName()).toString());
467     p.setGivenName(StringUtil::betterString(lhs.givenName(), rhs.givenName()).toString());
468     p.setName(StringUtil::betterString(lhs.name(), rhs.name()).toString());
469     return p;
470 }
471 
mergeValue(const Ticket & lhs,const Ticket & rhs)472 static Ticket mergeValue(const Ticket &lhs, const Ticket &rhs)
473 {
474     auto t = JsonLdDocument::apply(lhs, rhs).value<Ticket>();
475     // prefer barcode ticket tokens over URLs
476     if (t.ticketTokenType() == Ticket::Url && lhs.ticketTokenType() != Ticket::Url && lhs.ticketTokenType() != Ticket::Unknown) {
477         t.setTicketToken(lhs.ticketToken());
478     }
479     return t;
480 }
481 
checkValueIsNull(const QVariant & v)482 static bool checkValueIsNull(const QVariant &v)
483 {
484     if (v.type() == qMetaTypeId<float>()) {
485         return std::isnan(v.toFloat());
486     }
487     return v.isNull();
488 }
489 
merge(const QVariant & lhs,const QVariant & rhs)490 QVariant MergeUtil::merge(const QVariant &lhs, const QVariant &rhs)
491 {
492     if (rhs.isNull()) {
493         return lhs;
494     }
495     if (lhs.isNull()) {
496         return rhs;
497     }
498     if (lhs.userType() != rhs.userType()) {
499         qCWarning(Log) << "type mismatch during merging:" << lhs << rhs;
500         return {};
501     }
502 
503     // prefer the element with the newer mtime, if we have that information
504     if (JsonLd::canConvert<Reservation>(lhs) && JsonLd::canConvert<Reservation>(rhs)) {
505         const auto lhsDt = JsonLd::convert<Reservation>(lhs).modifiedTime();
506         const auto rhsDt = JsonLd::convert<Reservation>(rhs).modifiedTime();
507         if (lhsDt.isValid() && rhsDt.isValid() && rhsDt < lhsDt) {
508             return MergeUtil::merge(rhs, lhs);
509         }
510     }
511 
512     auto res = lhs;
513     const auto mo = QMetaType(res.userType()).metaObject();
514     for (int i = 0; i < mo->propertyCount(); ++i) {
515         const auto prop = mo->property(i);
516         if (!prop.isStored()) {
517             continue;
518         }
519 
520         auto lv = prop.readOnGadget(lhs.constData());
521         auto rv = prop.readOnGadget(rhs.constData());
522         auto mt = rv.userType();
523 
524         if (mt == qMetaTypeId<Airline>()) {
525             rv = mergeValue(lv.value<Airline>(), rv.value<Airline>());
526         } else if (mt == qMetaTypeId<Person>()) {
527             rv = mergeValue(lv.value<Person>(), rv.value<Person>());
528         } else if (mt == qMetaTypeId<QDateTime>()) {
529             rv = mergeValue(lv.toDateTime(), rv.toDateTime());
530         } else if (mt == qMetaTypeId<Ticket>()) {
531             rv = mergeValue(lv.value<Ticket>(), rv.value<Ticket>());
532         } else if (QMetaType(mt).metaObject()) {
533             rv = merge(prop.readOnGadget(lhs.constData()), rv);
534         }
535 
536         if (!checkValueIsNull(rv)) {
537             prop.writeOnGadget(res.data(), rv);
538         }
539     }
540 
541     return res;
542 }
543 
isMinimalCancelationFor(const QVariant & r,const Reservation & cancel)544 bool isMinimalCancelationFor(const QVariant &r, const Reservation &cancel)
545 {
546     const auto res = JsonLd::convert<Reservation>(r);
547     if (res.reservationStatus() == Reservation::ReservationCancelled || cancel.reservationStatus() != Reservation::ReservationCancelled) {
548         return false;
549     }
550     if (!equalAndPresent(res.reservationNumber(), cancel.reservationNumber())) {
551         return false;
552     }
553     if (!cancel.modifiedTime().isValid() || !cancel.reservationFor().isNull()) {
554         return false;
555     }
556     return SortUtil::startDateTime(r) > cancel.modifiedTime();
557 }
558