Skip to content

Commit

Permalink
✨ let user fetch wikidata entity for station (#2858)
Browse files Browse the repository at this point in the history
  • Loading branch information
MrKrisKrisu authored Aug 14, 2024
1 parent 31c0533 commit 2e85d73
Show file tree
Hide file tree
Showing 8 changed files with 297 additions and 51 deletions.
10 changes: 10 additions & 0 deletions app/Exceptions/Wikidata/FetchException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php declare(strict_types=1);

namespace App\Exceptions\Wikidata;

use Exception;

class FetchException extends Exception
{

}
59 changes: 59 additions & 0 deletions app/Http/Controllers/API/v1/ExperimentalController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

namespace App\Http\Controllers\API\v1;

use App\Exceptions\Wikidata\FetchException;
use App\Models\Station;
use App\Services\Wikidata\WikidataImportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\RateLimiter;

/**
* undocumented, unstable, experimental endpoints. don't use in external applications!
*/
class ExperimentalController extends Controller
{

public function fetchWikidata(int $stationId): JsonResponse {
if (!self::checkGeneralRateLimit()) {
return response()->json(['error' => 'You are requesting too fast. Please try again later.'], 429);
}

if (!self::checkStationRateLimit($stationId)) {
return response()->json(['error' => 'This station was already requested recently. Please try again later.'], 429);
}

$station = Station::findOrFail($stationId);
if ($station->wikidata_id) {
return response()->json(['error' => 'This station already has a wikidata id.'], 400);
}

try {
WikidataImportService::searchStation($station);
return response()->json(['message' => 'Wikidata information fetched successfully']);
} catch (FetchException $exception) {
return response()->json(['error' => $exception->getMessage()], 422);
}
}

private static function checkGeneralRateLimit(): bool {
$key = "fetch-wikidata-user:" . auth()->id();
if (RateLimiter::tooManyAttempts($key, 10)) {
return false;
}
RateLimiter::increment($key);
return true;
}

private static function checkStationRateLimit(int $stationId): bool {
// request a station 1 time per 5 minutes

$key = "fetch-wikidata-station:$stationId";
if (RateLimiter::tooManyAttempts($key, 1)) {
return false;
}
RateLimiter::increment($key, 5 * 60);
return true;
}

}
59 changes: 8 additions & 51 deletions app/Http/Controllers/Frontend/Admin/StationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
namespace App\Http\Controllers\Frontend\Admin;

use App\Dto\Coordinate;
use App\Exceptions\Wikidata\FetchException;
use App\Http\Controllers\Controller;
use App\Models\Station;
use App\Models\StationName;
use App\Objects\LineSegment;
use App\Services\Wikidata\WikidataImportService;
use App\Services\Wikidata\WikidataQueryService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
Expand Down Expand Up @@ -65,58 +65,15 @@ public function renderStation(int $id): View {
* Needs to be cleaned up and refactored, if it should be used consistently.
* Little testing if it works as expected.
*/
public function fetchWikidata(int $id): void {
public function fetchWikidata(int $id): JsonResponse {
$station = Station::findOrFail($id);
$this->authorize('update', $station);

// P054 = IBNR
$sparqlQuery = <<<SPARQL
SELECT ?item WHERE { ?item wdt:P954 "{$station->ibnr}". }
SPARQL;

$objects = (new WikidataQueryService())->setQuery($sparqlQuery)->execute()->getObjects();
if (count($objects) > 1) {
Log::debug('More than one object found for station ' . $station->ibnr . ' (' . $station->id . ') - skipping');
return;
}

if (empty($objects)) {
Log::debug('No object found for station ' . $station->ibnr . ' (' . $station->id . ') - skipping');
return;
}

$object = $objects[0];
$station->update(['wikidata_id' => $object->qId]);
Log::debug('Fetched object ' . $object->qId . ' for station ' . $station->name . ' (Trwl-ID: ' . $station->id . ')');

$ifopt = $object->getClaims('P12393')[0]['mainsnak']['datavalue']['value'] ?? null;
if ($station->ifopt_a === null && $ifopt !== null) {
$splitIfopt = explode(':', $ifopt);
$station->update([
'ifopt_a' => $splitIfopt[0] ?? null,
'ifopt_b' => $splitIfopt[1] ?? null,
'ifopt_c' => $splitIfopt[2] ?? null,
]);
}

$rl100 = $object->getClaims('P8671')[0]['mainsnak']['datavalue']['value'] ?? null;
if ($station->rilIdentifier === null && $rl100 !== null) {
$station->update(['rilIdentifier' => $rl100]);
}

//get names
foreach ($object->getClaims('P2561') as $property) {
$text = $property['mainsnak']['datavalue']['value']['text'] ?? null;
$language = $property['mainsnak']['datavalue']['value']['language'] ?? null;
if ($language === null || $text === null) {
continue;
}
StationName::updateOrCreate([
'station_id' => $station->id,
'language' => $language,
], [
'name' => $text
]);
try {
WikidataImportService::searchStation($station);
return response()->json(['success' => 'Wikidata information fetched successfully']);
} catch (FetchException $exception) {
return response()->json(['error' => $exception->getMessage()], 422);
}
}

Expand Down
26 changes: 26 additions & 0 deletions app/Http/Controllers/Frontend/OpenData/WikidataController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php declare(strict_types=1);

namespace App\Http\Controllers\Frontend\OpenData;

use App\Http\Controllers\Controller;
use App\Models\Station;
use Illuminate\View\View;

class WikidataController extends Controller
{
public function indexHelpPage(): View {

//get stations the user was travelling recently, without a wikidata id
$destinationStationsWithoutWikidata = Station::join('train_stopovers', 'train_stations.id', '=', 'train_stopovers.train_station_id')
->join('train_checkins', 'train_checkins.destination_stopover_id', '=', 'train_stopovers.id')
->where('train_checkins.user_id', auth()->id())
->whereNull('train_stations.wikidata_id')
->select('train_stations.*')
->limit(50)
->get();

return view('open-data.wikidata.index', [
'destinationStationsWithoutWikidata' => $destinationStationsWithoutWikidata
]);
}
}
59 changes: 59 additions & 0 deletions app/Services/Wikidata/WikidataImportService.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
namespace App\Services\Wikidata;

use App\Dto\Wikidata\WikidataEntity;
use App\Exceptions\Wikidata\FetchException;
use App\Models\Station;
use App\Models\StationName;
use Illuminate\Support\Facades\Log;

class WikidataImportService
{
Expand Down Expand Up @@ -55,4 +58,60 @@ public static function importStation(string $qId): Station {
);
}

/**
* @throws FetchException
*/
public static function searchStation(Station $station): void {
// P054 = IBNR
$sparqlQuery = <<<SPARQL
SELECT ?item WHERE { ?item wdt:P954 "{$station->ibnr}". }
SPARQL;

$objects = (new WikidataQueryService())->setQuery($sparqlQuery)->execute()->getObjects();
if (count($objects) > 1) {
Log::debug('More than one object found for station ' . $station->ibnr . ' (' . $station->id . ') - skipping');
throw new FetchException('There are multiple Wikidata entitied with IBNR ' . $station->ibnr);
}

if (empty($objects)) {
Log::debug('No object found for station ' . $station->ibnr . ' (' . $station->id . ') - skipping');
throw new FetchException('No Wikidata entity found for IBNR ' . $station->ibnr);
}

$object = $objects[0];
$station->update(['wikidata_id' => $object->qId]);
activity()->performedOn($station)->log('Linked wikidata entity ' . $object->qId);
Log::debug('Fetched object ' . $object->qId . ' for station ' . $station->name . ' (Trwl-ID: ' . $station->id . ')');

$ifopt = $object->getClaims('P12393')[0]['mainsnak']['datavalue']['value'] ?? null;
if ($station->ifopt_a === null && $ifopt !== null) {
$splitIfopt = explode(':', $ifopt);
$station->update([
'ifopt_a' => $splitIfopt[0] ?? null,
'ifopt_b' => $splitIfopt[1] ?? null,
'ifopt_c' => $splitIfopt[2] ?? null,
]);
}

$rl100 = $object->getClaims('P8671')[0]['mainsnak']['datavalue']['value'] ?? null;
if ($station->rilIdentifier === null && $rl100 !== null) {
$station->update(['rilIdentifier' => $rl100]);
}

//get names
foreach ($object->getClaims('P2561') as $property) {
$text = $property['mainsnak']['datavalue']['value']['text'] ?? null;
$language = $property['mainsnak']['datavalue']['value']['language'] ?? null;
if ($language === null || $text === null) {
continue;
}
StationName::updateOrCreate([
'station_id' => $station->id,
'language' => $language,
], [
'name' => $text
]);
}
}

}
122 changes: 122 additions & 0 deletions resources/views/open-data/wikidata/index.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
@extends('layouts.app')

@section('title', 'Open Data: Wikidata')

@section('content')
<div class="container">
<div class="row">
<div class="col-12">
<h1 class="fs-4">
Open Data - Wikidata: Missing station information
</h1>

@if(app()->getLocale() !== 'en')
<div class="alert alert-info" role="alert">
<i class="fas fa-info-circle"></i>
{{__('page-only-available-in-language', ['language' => __('language.en')])}}
</div>
@endif


<div class="alert alert-info" role="alert">
<i class="fas fa-info-circle"></i>
<b>What is this page for?</b>
<br/>
<p>
This page lists all stations you're träwelled to, that are missing a Wikidata link.
Click the "Fetch" button to fetch the Wikidata information for a station.
<br/>
If no Wikidata object was found, please help us to assign it by maintaining the data at
Wikidata.
We will search for the station using the IBNR known to us.
<br/>
If the station already exists in Wikidata, please add it to the object and "Fetch" again.
If not, please create an object.
</p>

<hr/>
<i class="fa-solid fa-file-circle-question"></i>
<b>What data is relevant for Träwelling?</b>

<p>
Träwelling uses the Wikidata object to enrich the station information.
The following properties are relevant for Träwelling:
</p>
<ul>
<li>
<a href="https://www.wikidata.org/wiki/Property:P954" target="P954">
IBNR
</a>
</li>
<li>
<a href="https://www.wikidata.org/wiki/Property:P12393" target="P12393">
IFOPT
</a>
</li>
<li>
<a href="https://www.wikidata.org/wiki/Property:P8671" target="P8671">
Ril 100 (DB-Betriebsstellenabkürzung)
</a>
</li>
<li>
<a href="https://www.wikidata.org/wiki/Property:P1448" target="P1448">
Official name
</a>
</li>
</ul>
</div>

<table class="table">
<thead>
<tr>
<th>Station</th>
<th>IBNR</th>
<th>IFOPT</th>
<th>Ril100</th>
<th>Wikidata</th>
</tr>
</thead>
<tbody>
@foreach($destinationStationsWithoutWikidata as $station)
<tr id="station-{{$station->id}}">
<td>{{$station->name}}</td>
<td>{{$station->ibnr}}</td>
<td>{{$station->ifopt}}</td>
<td>{{$station->rilIdentifier}}</td>
<td>
<button class="btn btn-primary btn-sm" onclick="fetchWikidata({{$station->id}})">
<i class="fas fa-link"></i>
Fetch
</button>
</td>
</tr>
@endforeach
</tbody>
</table>

<script>
function fetchWikidata(stationId) {
console.log('Fetching Wikidata for station ' + stationId);
fetch('/api/v1/experimental/station/' + stationId + '/wikidata', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
}
})
.then(response => response.json())
.then(data => {
console.log(data);
if (data.error) {
notyf.error(data.error || 'Error fetching Wikidata');
} else {
notyf.success(data.message || 'Wikidata fetched');
document.getElementById('station-' + stationId).remove();
}
})
}
</script>
</div>
</div>
</div>
@endsection
7 changes: 7 additions & 0 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use App\Http\Controllers\API\v1\AuthController as v1Auth;
use App\Http\Controllers\API\v1\EventController;
use App\Http\Controllers\API\v1\ExperimentalController;
use App\Http\Controllers\API\v1\ExportController;
use App\Http\Controllers\API\v1\FollowController;
use App\Http\Controllers\API\v1\IcsController;
Expand Down Expand Up @@ -177,6 +178,12 @@
Route::get('/user/self/trusted-by', [TrustedUserController::class, 'indexTrustedBy']);
Route::apiResource('report', ReportController::class);
Route::apiResource('operators', OperatorController::class)->only(['index']);

Route::prefix('experimental')->group(function() {
// undocumented, unstable, experimental endpoints. don't use in external applications!

Route::post('/station/{id}/wikidata', [ExperimentalController::class, 'fetchWikidata']);
});
});

Route::group(['middleware' => ['privacy-policy']], static function() {
Expand Down
Loading

0 comments on commit 2e85d73

Please sign in to comment.