mirror of
https://gitlab.com/oeffi/public-transport-enabler.git
synced 2025-07-06 15:18:49 +00:00
VRS: Use endpoint eID=tx_ekap_here to query nearby locations because eID=tx_vrsinfo_ass2_timetable is no longer supported. Fixes #401.
This commit is contained in:
parent
5f159e4e3b
commit
a0207b3ba7
2 changed files with 102 additions and 148 deletions
|
@ -25,14 +25,11 @@ import java.text.SimpleDateFormat;
|
|||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Calendar;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.Currency;
|
||||
import java.util.Date;
|
||||
import java.util.EnumSet;
|
||||
import java.util.GregorianCalendar;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
@ -47,9 +44,6 @@ import javax.annotation.Nullable;
|
|||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.google.common.base.Strings;
|
||||
|
||||
import de.schildbach.pte.dto.Departure;
|
||||
|
@ -90,8 +84,6 @@ public class VrsProvider extends AbstractNetworkProvider {
|
|||
Capability.TRIPS_VIA
|
||||
);
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(VrsProvider.class);
|
||||
|
||||
@SuppressWarnings("serial")
|
||||
private static class Context implements QueryTripsContext {
|
||||
private boolean canQueryLater = true;
|
||||
|
@ -156,8 +148,7 @@ public class VrsProvider extends AbstractNetworkProvider {
|
|||
}
|
||||
|
||||
// valid host names: www.vrsinfo.de, android.vrsinfo.de, ios.vrsinfo.de, ekap.vrsinfo.de (only SSL
|
||||
// encrypted with
|
||||
// client certificate)
|
||||
// encrypted with client certificate)
|
||||
// performance comparison March 2015 showed www.vrsinfo.de to be fastest for trips
|
||||
protected static final HttpUrl API_BASE = HttpUrl.parse("http://android.vrsinfo.de/index.php");
|
||||
protected static final String SERVER_PRODUCT = "vrs";
|
||||
|
@ -354,68 +345,72 @@ public class VrsProvider extends AbstractNetworkProvider {
|
|||
return CAPABILITIES.contains(capability);
|
||||
}
|
||||
|
||||
// only stations supported
|
||||
@Override
|
||||
public NearbyLocationsResult queryNearbyLocations(Set<LocationType> types /* only STATION supported */,
|
||||
Location location, int maxDistance, int maxLocations) throws IOException {
|
||||
// g=p means group by product; not used here
|
||||
final HttpUrl.Builder url = API_BASE.newBuilder();
|
||||
url.addQueryParameter("eID", "tx_vrsinfo_ass2_timetable");
|
||||
public NearbyLocationsResult queryNearbyLocations(Set<LocationType> types, Location location,
|
||||
int maxDistance, int maxLocations) throws IOException {
|
||||
final Point queryCoord;
|
||||
if (location.hasCoord()) {
|
||||
url.addQueryParameter("r",
|
||||
String.format(Locale.ENGLISH, "%.6f,%.6f", location.getLatAsDouble(), location.getLonAsDouble()));
|
||||
queryCoord = location.coord;
|
||||
} else if (location.type == LocationType.STATION && location.hasId()) {
|
||||
url.addQueryParameter("i", location.id);
|
||||
queryCoord = stationToCoord(location.id);
|
||||
} else {
|
||||
throw new IllegalArgumentException("at least one of stationId or lat/lon must be given");
|
||||
}
|
||||
// limits the departures at each stop - we need that to guess available products
|
||||
url.addQueryParameter("c", "8");
|
||||
if (maxLocations > 0) {
|
||||
// s=number of stops, artificially limited by server
|
||||
url.addQueryParameter("s", Integer.toString(Math.min(16, maxLocations)));
|
||||
}
|
||||
|
||||
final HttpUrl.Builder url = API_BASE.newBuilder();
|
||||
url.addQueryParameter("eID", "tx_ekap_here");
|
||||
url.addQueryParameter("ta", "vrs");
|
||||
url.addQueryParameter("lat", String.format(Locale.ENGLISH, "%.6f", queryCoord.getLatAsDouble()));
|
||||
url.addQueryParameter("lon", String.format(Locale.ENGLISH, "%.6f", queryCoord.getLonAsDouble()));
|
||||
final CharSequence page = httpClient.get(url.build());
|
||||
|
||||
try {
|
||||
int num = 0;
|
||||
final List<Location> locations = new ArrayList<>();
|
||||
final JSONObject head = new JSONObject(page.toString());
|
||||
final String error = Strings.emptyToNull(head.optString("error", "").trim());
|
||||
if (error != null) {
|
||||
if (error.equals("Leere Koordinate.") || error.equals("Leere ASS-ID und leere Koordinate") || error.equals("Keine Abfahrten gefunden."))
|
||||
return new NearbyLocationsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT), locations);
|
||||
else if (error.equals("ASS2-Server lieferte leere Antwort."))
|
||||
return new NearbyLocationsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
|
||||
NearbyLocationsResult.Status.SERVICE_DOWN);
|
||||
else
|
||||
throw new IllegalStateException("unknown error: " + error);
|
||||
}
|
||||
final JSONArray timetable = head.getJSONArray("timetable");
|
||||
long serverTime = 0;
|
||||
for (int i = 0; i < timetable.length(); i++) {
|
||||
final JSONObject entry = timetable.getJSONObject(i);
|
||||
final JSONObject stop = entry.getJSONObject("stop");
|
||||
final JSONArray events = entry.getJSONArray("events");
|
||||
final Location loc = parseLocationAndPosition(stop, events).location;
|
||||
int distance = stop.getInt("distance");
|
||||
if (maxDistance > 0 && distance > maxDistance) {
|
||||
break; // we rely on the server side sorting by distance
|
||||
final JSONArray objects = head.getJSONArray("objects");
|
||||
for (int i = 0; i < objects.length(); i++) {
|
||||
final JSONObject entry = objects.getJSONObject(i);
|
||||
final LocationType type = parseLocationType(entry.getString("type"));
|
||||
if (!(types.contains(type) || types.contains(LocationType.ANY))) {
|
||||
continue;
|
||||
}
|
||||
if (types.contains(loc.type) || types.contains(LocationType.ANY)) {
|
||||
locations.add(loc);
|
||||
final Point coord = Point.fromDouble(entry.getDouble("lat"), entry.getDouble("lon"));
|
||||
if (maxDistance > 0 && entry.optInt("distance") > maxDistance) {
|
||||
continue;
|
||||
}
|
||||
// TODO "distance" is only given for stops. For other location types, calculate distance from coordinates
|
||||
String id = entry.optString("id");
|
||||
if (id == null || id.isEmpty()) {
|
||||
id = entry.getString("ifopt");
|
||||
}
|
||||
String place = entry.getString("municipality");
|
||||
final String locality = entry.optString("locality");
|
||||
if (locality != null && !locality.isEmpty()) {
|
||||
place += "-" + locality;
|
||||
}
|
||||
String name = entry.getString("name");
|
||||
if (entry.getString("type").equals("parkandride")) {
|
||||
name = "P+R " + name;
|
||||
}
|
||||
final JSONArray lines = entry.optJSONArray("lines");
|
||||
final EnumSet<Product> products = EnumSet.noneOf(Product.class);
|
||||
for (int j = 0; lines != null && j < lines.length(); j++) {
|
||||
final JSONObject line = lines.getJSONObject(j);
|
||||
products.add(parseProduct(line.getString("productCode"), line.getString("name")));
|
||||
}
|
||||
locations.add(new Location(type, id, coord, place, name, products));
|
||||
if (maxLocations > 0 && ++num >= maxLocations) {
|
||||
break;
|
||||
}
|
||||
serverTime = parseDateTime(entry.getString("generated")).getTime();
|
||||
}
|
||||
final ResultHeader header = new ResultHeader(NetworkId.VRS, SERVER_PRODUCT, null, null, serverTime, null);
|
||||
final ResultHeader header = new ResultHeader(NetworkId.VRS, SERVER_PRODUCT, null, null, new Date().getTime(), null);
|
||||
return new NearbyLocationsResult(header, locations);
|
||||
} catch (final JSONException | ParseException x) {
|
||||
} catch (final JSONException x) {
|
||||
throw new RuntimeException("cannot parse: '" + page + "' on " + url, x);
|
||||
}
|
||||
}
|
||||
|
||||
// VRS does not show LongDistanceTrains departures. Parameter p for product
|
||||
// filter is supported, but LongDistanceTrains filter seems to be ignored.
|
||||
// TODO equivs not supported; JSON result would support multiple timetables
|
||||
@Override
|
||||
public QueryDeparturesResult queryDepartures(final String stationId, @Nullable Date time, int maxDepartures,
|
||||
|
@ -431,6 +426,7 @@ public class VrsProvider extends AbstractNetworkProvider {
|
|||
if (time != null) {
|
||||
url.addQueryParameter("t", formatDate(time));
|
||||
}
|
||||
url.addQueryParameter("p", "LongDistanceTrains,RegionalTrains,SuburbanTrains,Underground,LightRail,Bus,CommunityBus,RailReplacementServices,Boat,OnDemandServices");
|
||||
final CharSequence page = httpClient.get(url.build());
|
||||
|
||||
try {
|
||||
|
@ -452,10 +448,10 @@ public class VrsProvider extends AbstractNetworkProvider {
|
|||
final JSONArray timetable = head.getJSONArray("timetable");
|
||||
final ResultHeader header = new ResultHeader(NetworkId.VRS, SERVER_PRODUCT);
|
||||
final QueryDeparturesResult result = new QueryDeparturesResult(header);
|
||||
// for all stations
|
||||
if (timetable.length() == 0) {
|
||||
return new QueryDeparturesResult(header, QueryDeparturesResult.Status.INVALID_STATION);
|
||||
}
|
||||
// for all stations
|
||||
for (int iStation = 0; iStation < timetable.length(); iStation++) {
|
||||
final List<Departure> departures = new ArrayList<>();
|
||||
final JSONObject station = timetable.getJSONObject(iStation);
|
||||
|
@ -478,7 +474,7 @@ public class VrsProvider extends AbstractNetworkProvider {
|
|||
Position position = null;
|
||||
final JSONObject post = event.optJSONObject("post");
|
||||
if (post != null) {
|
||||
final String postName = post.getString("name");
|
||||
String postName = post.getString("name");
|
||||
for (Pattern pattern : NAME_WITH_POSITION_PATTERNS) {
|
||||
Matcher matcher = pattern.matcher(postName);
|
||||
if (matcher.matches()) {
|
||||
|
@ -486,8 +482,11 @@ public class VrsProvider extends AbstractNetworkProvider {
|
|||
break;
|
||||
}
|
||||
}
|
||||
if (position == null)
|
||||
log.info("Could not extract position from '{}'", postName);
|
||||
if (position == null) {
|
||||
if (postName.startsWith("(") && postName.endsWith(")"))
|
||||
postName = postName.substring(1, postName.length() - 1);
|
||||
position = new Position(postName);
|
||||
}
|
||||
}
|
||||
final Location destination = new Location(LocationType.STATION, null /* id */, null /* place */,
|
||||
lineObj.getString("direction"));
|
||||
|
@ -501,8 +500,6 @@ public class VrsProvider extends AbstractNetworkProvider {
|
|||
departures.add(d);
|
||||
}
|
||||
|
||||
queryLinesForStation(location.id, lines);
|
||||
|
||||
result.stationDepartures.add(new StationDepartures(location, departures, lines));
|
||||
}
|
||||
|
||||
|
@ -512,62 +509,6 @@ public class VrsProvider extends AbstractNetworkProvider {
|
|||
}
|
||||
}
|
||||
|
||||
private void queryLinesForStation(String stationId, List<LineDestination> lineDestinations) throws IOException {
|
||||
Set<String> lineNumbersAlreadyKnown = new HashSet<>();
|
||||
for (LineDestination lineDestionation : lineDestinations) {
|
||||
lineNumbersAlreadyKnown.add(lineDestionation.line.label);
|
||||
}
|
||||
final HttpUrl.Builder url = API_BASE.newBuilder();
|
||||
url.addQueryParameter("eID", "tx_vrsinfo_his_info");
|
||||
url.addQueryParameter("i", stationId);
|
||||
|
||||
final CharSequence page = httpClient.get(url.build());
|
||||
|
||||
try {
|
||||
final JSONObject head = new JSONObject(page.toString());
|
||||
final JSONObject his = head.optJSONObject("his");
|
||||
if (his != null) {
|
||||
final JSONArray lines = his.optJSONArray("lines");
|
||||
if (lines != null) {
|
||||
for (int iLine = 0; iLine < lines.length(); iLine++) {
|
||||
final JSONObject line = lines.getJSONObject(iLine);
|
||||
final String number = processLineNumber(line.getString("number"));
|
||||
if (lineNumbersAlreadyKnown.contains(number)) {
|
||||
continue;
|
||||
}
|
||||
final Product product = productFromLineNumber(number);
|
||||
String direction = null;
|
||||
final JSONArray postings = line.optJSONArray("postings");
|
||||
if (postings != null) {
|
||||
for (int iPosting = 0; iPosting < postings.length(); iPosting++) {
|
||||
final JSONObject posting = (JSONObject) postings.get(iPosting);
|
||||
direction = posting.getString("direction");
|
||||
lineDestinations.add(new LineDestination(
|
||||
new Line(null /* id */, NetworkId.VRS.toString(), product, number,
|
||||
lineStyle("vrs", product, number)),
|
||||
new Location(LocationType.STATION, null /* id */, null /* place */,
|
||||
direction)));
|
||||
}
|
||||
} else {
|
||||
lineDestinations.add(new LineDestination(new Line(null /* id */, NetworkId.VRS.toString(),
|
||||
product, number, lineStyle("vrs", product, number)), null /* direction */));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (final JSONException x) {
|
||||
throw new RuntimeException("cannot parse: '" + page + "' on " + url, x);
|
||||
}
|
||||
Collections.sort(lineDestinations, new LineDestinationComparator());
|
||||
}
|
||||
|
||||
private static class LineDestinationComparator implements Comparator<LineDestination> {
|
||||
@Override
|
||||
public int compare(LineDestination o1, LineDestination o2) {
|
||||
return o1.line.compareTo(o2.line);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public SuggestLocationsResult suggestLocations(final CharSequence constraint,
|
||||
final @Nullable Set<LocationType> types, final int maxLocations) throws IOException {
|
||||
|
@ -718,6 +659,9 @@ public class VrsProvider extends AbstractNetworkProvider {
|
|||
else if (error.equals("Keine Verbindungen gefunden."))
|
||||
return new QueryTripsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
|
||||
QueryTripsResult.Status.NO_TRIPS);
|
||||
else if (error.equals("Es wurden keine gültigen Verbindungen für diese Anfrage gefunden."))
|
||||
return new QueryTripsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
|
||||
QueryTripsResult.Status.NO_TRIPS);
|
||||
else if (error.startsWith("Keine Verbindung gefunden."))
|
||||
return new QueryTripsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
|
||||
QueryTripsResult.Status.NO_TRIPS);
|
||||
|
@ -969,22 +913,6 @@ public class VrsProvider extends AbstractNetworkProvider {
|
|||
return new Point[] { Point.from1E6(50937531, 6960279) };
|
||||
}
|
||||
|
||||
private static Product productFromLineNumber(String number) {
|
||||
if (number.startsWith("I") || number.startsWith("E")) {
|
||||
return Product.HIGH_SPEED_TRAIN;
|
||||
} else if (number.startsWith("R") || number.startsWith("MRB") || number.startsWith("DPN")) {
|
||||
return Product.REGIONAL_TRAIN;
|
||||
} else if (number.startsWith("S") && !number.startsWith("SB") && !number.startsWith("SEV")) {
|
||||
return Product.SUBURBAN_TRAIN;
|
||||
} else if (number.startsWith("U")) {
|
||||
return Product.SUBWAY;
|
||||
} else if (number.length() <= 2 && !number.startsWith("N")) {
|
||||
return Product.TRAM;
|
||||
} else {
|
||||
return Product.BUS;
|
||||
}
|
||||
}
|
||||
|
||||
private Line parseLine(JSONObject line) throws JSONException {
|
||||
final String number = processLineNumber(line.getString("number"));
|
||||
final Product productObj = parseProduct(line.getString("product"), number);
|
||||
|
@ -1160,4 +1088,36 @@ public class VrsProvider extends AbstractNetworkProvider {
|
|||
return new SimpleDateFormat("yyyy-MM-dd'T'kk:mm:ssZ")
|
||||
.parse(dateTimeStr.substring(0, dateTimeStr.lastIndexOf(':')) + "00");
|
||||
}
|
||||
|
||||
private final Point stationToCoord(String id) throws IOException {
|
||||
final HttpUrl.Builder url = API_BASE.newBuilder();
|
||||
url.addQueryParameter("eID", "tx_vrsinfo_ass2_timetable");
|
||||
url.addQueryParameter("i", id);
|
||||
|
||||
final CharSequence page = httpClient.get(url.build());
|
||||
|
||||
try {
|
||||
final JSONObject head = new JSONObject(page.toString());
|
||||
final String error = Strings.emptyToNull(head.optString("error", "").trim());
|
||||
if (error != null) {
|
||||
throw new IllegalStateException(error);
|
||||
}
|
||||
final JSONArray timetable = head.getJSONArray("timetable");
|
||||
final JSONObject entry = timetable.getJSONObject(0);
|
||||
final JSONObject stop = entry.getJSONObject("stop");
|
||||
return Point.fromDouble(stop.getDouble("x"), stop.getDouble("y"));
|
||||
} catch (final JSONException x) {
|
||||
throw new RuntimeException("cannot parse: '" + page + "' on " + url, x);
|
||||
}
|
||||
}
|
||||
|
||||
private final static LocationType parseLocationType(String type) {
|
||||
if (type.equals("stop")) {
|
||||
return LocationType.STATION;
|
||||
} else if (type.equals("poi") || type.equals("parkandride")) {
|
||||
return LocationType.POI;
|
||||
} else {
|
||||
return LocationType.ANY;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -53,7 +53,6 @@ import de.schildbach.pte.dto.StationDepartures;
|
|||
import de.schildbach.pte.dto.Style;
|
||||
import de.schildbach.pte.dto.SuggestLocationsResult;
|
||||
import de.schildbach.pte.dto.TripOptions;
|
||||
import de.schildbach.pte.util.Iso8601Format;
|
||||
|
||||
/**
|
||||
* @author Michael Dyrna
|
||||
|
@ -71,7 +70,7 @@ public class VrsProviderLiveTest extends AbstractProviderLiveTest {
|
|||
|
||||
@Test
|
||||
public void nearbyStationsByCoordinate() throws Exception {
|
||||
final NearbyLocationsResult result = queryNearbyStations(Location.coord(51218693, 6777785));
|
||||
final NearbyLocationsResult result = queryNearbyStations(Location.coord(50942970, 6958570));
|
||||
print(result);
|
||||
|
||||
final NearbyLocationsResult result2 = queryNearbyStations(Location.coord(51719648, 8754330));
|
||||
|
@ -121,18 +120,18 @@ public class VrsProviderLiveTest extends AbstractProviderLiveTest {
|
|||
print(result);
|
||||
|
||||
final NearbyLocationsResult result2 = queryNearbyLocations(EnumSet.of(LocationType.STATION),
|
||||
Location.coord(50732100, 7096820), 0, 50);
|
||||
Location.coord(50732100, 7096820), 0, 1);
|
||||
print(result2);
|
||||
|
||||
final NearbyLocationsResult result3 = queryNearbyLocations(EnumSet.of(LocationType.STATION),
|
||||
Location.coord(50732100, 7096820), 1000, 50);
|
||||
Location.coord(50732100, 7096820), 100, 0);
|
||||
print(result3);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nearbyLocationsEmpty() throws Exception {
|
||||
final NearbyLocationsResult result = queryNearbyLocations(EnumSet.allOf(LocationType.class),
|
||||
Location.coord(1, 0), 0, 0);
|
||||
Location.coord(1, 1), 1000, 0);
|
||||
print(result);
|
||||
assertEquals(0, result.locations.size());
|
||||
}
|
||||
|
@ -165,7 +164,6 @@ public class VrsProviderLiveTest extends AbstractProviderLiveTest {
|
|||
@Test
|
||||
public void queryDeparturesGaussstr() throws Exception {
|
||||
final QueryDeparturesResult result = queryDepartures("8984", false);
|
||||
// will return {"error": "Keine Abfahrten gefunden."}
|
||||
print(result);
|
||||
printLineDestinations(result);
|
||||
}
|
||||
|
@ -319,9 +317,9 @@ public class VrsProviderLiveTest extends AbstractProviderLiveTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
public void tripEarlierLaterCologneBerlin() throws Exception {
|
||||
final QueryTripsResult result = queryTrips(new Location(LocationType.STATION, "1"), null,
|
||||
new Location(LocationType.STATION, "11458"), new Date(), true, null);
|
||||
public void tripEarlierLaterCologneBonn() throws Exception {
|
||||
final QueryTripsResult result = queryTrips(new Location(LocationType.STATION, "8"), null,
|
||||
new Location(LocationType.STATION, "687"), new Date(), true, null);
|
||||
assertEquals(QueryTripsResult.Status.OK, result.status);
|
||||
assertTrue(result.trips.size() > 0);
|
||||
print(result);
|
||||
|
@ -426,8 +424,7 @@ public class VrsProviderLiveTest extends AbstractProviderLiveTest {
|
|||
"Kerpen-Sindorf", "Erftstraße 43");
|
||||
final Location to = new Location(LocationType.ADDRESS, null /* id */, Point.from1E6(50923000, 6818440),
|
||||
"Frechen", "Zedernweg 1");
|
||||
final QueryTripsResult result = queryTrips(from, null, to, Iso8601Format.parseDateTime("2015-03-17 21:11:18"),
|
||||
true, null);
|
||||
final QueryTripsResult result = queryTrips(from, null, to, new Date(), true, null);
|
||||
print(result);
|
||||
assertEquals(QueryTripsResult.Status.OK, result.status);
|
||||
assertTrue(result.trips.size() > 0);
|
||||
|
@ -529,8 +526,7 @@ public class VrsProviderLiveTest extends AbstractProviderLiveTest {
|
|||
}
|
||||
Set<Line> lines = new TreeSet<>();
|
||||
for (Location station : stations) {
|
||||
QueryDeparturesResult qdr = provider.queryDepartures(station.id,
|
||||
Iso8601Format.parseDateTime("2015-03-16 02:00:00"), 100, false);
|
||||
QueryDeparturesResult qdr = provider.queryDepartures(station.id, new Date(), 100, false);
|
||||
if (qdr.status == QueryDeparturesResult.Status.OK) {
|
||||
for (StationDepartures stationDepartures : qdr.stationDepartures) {
|
||||
final List<LineDestination> stationDeparturesLines = stationDepartures.lines;
|
||||
|
@ -549,11 +545,9 @@ public class VrsProviderLiveTest extends AbstractProviderLiveTest {
|
|||
for (Line line : lines) {
|
||||
final Product product = line.product;
|
||||
if (product != null) {
|
||||
if (product.equals(Product.BUS)) {
|
||||
final Style style = line.style;
|
||||
if (style != null) {
|
||||
System.out.printf("%s %6x\n", line.label, style.backgroundColor);
|
||||
}
|
||||
final Style style = line.style;
|
||||
if (style != null) {
|
||||
System.out.printf("%s %s %6x\n", product, line.label, style.backgroundColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue