Passed
Pull Request — master (#3652)
by
unknown
09:59 queued 03:53
created

LocationController::importLocations()   A

Complexity

Conditions 2
Paths 1

Size

Total Lines 23
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 15
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 23
rs 9.7666
1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2020 webtrees development team
6
 * This program is free software: you can redistribute it and/or modify
7
 * it under the terms of the GNU General Public License as published by
8
 * the Free Software Foundation, either version 3 of the License, or
9
 * (at your option) any later version.
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 * You should have received a copy of the GNU General Public License
15
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
16
 */
17
18
declare(strict_types=1);
19
20
namespace Fisharebest\Webtrees\Http\Controllers\Admin;
21
22
use Exception;
23
use Fisharebest\Webtrees\FlashMessages;
24
use Fisharebest\Webtrees\Gedcom;
25
use Fisharebest\Webtrees\Http\RequestHandlers\ControlPanel;
26
use Fisharebest\Webtrees\Http\RequestHandlers\MapDataList;
27
use Fisharebest\Webtrees\I18N;
28
use Fisharebest\Webtrees\PlaceLocation;
29
use Fisharebest\Webtrees\Registry;
30
use Fisharebest\Webtrees\Services\GedcomService;
31
use Illuminate\Database\Capsule\Manager as DB;
32
use Illuminate\Database\Eloquent\Collection;
33
use Illuminate\Database\Query\Expression;
34
use Psr\Http\Message\ResponseInterface;
35
use Psr\Http\Message\ServerRequestInterface;
36
use Psr\Http\Message\UploadedFileInterface;
37
use RuntimeException;
38
use stdClass;
39
40
use function abs;
41
use function addcslashes;
42
use function array_combine;
43
use function array_filter;
44
use function array_merge;
45
use function array_pad;
46
use function array_reverse;
47
use function array_slice;
48
use function assert;
49
use function count;
50
use function e;
51
use function fclose;
52
use function fgetcsv;
53
use function fopen;
54
use function fputcsv;
55
use function implode;
56
use function is_numeric;
57
use function json_decode;
58
use function preg_replace;
59
use function redirect;
60
use function response;
61
use function rewind;
62
use function round;
63
use function route;
64
use function str_replace;
65
use function stream_get_contents;
66
use function stripos;
67
use function substr_count;
68
69
use const UPLOAD_ERR_OK;
70
71
/**
72
 * Controller for maintaining geographic data.
73
 */
74
class LocationController extends AbstractAdminController
75
{
76
    // Location of files to import
77
    private const PLACES_FOLDER = 'places/';
78
79
    /** @var GedcomService */
80
    private $gedcom_service;
81
82
    /**
83
     * Dependency injection.
84
     *
85
     * @param GedcomService $gedcom_service
86
     */
87
    public function __construct(GedcomService $gedcom_service)
88
    {
89
        $this->gedcom_service = $gedcom_service;
90
    }
91
92
    /**
93
     * @param int $id
94
     *
95
     * @return array<stdClass>
96
     */
97
    private function getHierarchy(int $id): array
98
    {
99
        $arr  = [];
100
        $fqpn = [];
101
102
        while ($id !== 0) {
103
            $row = DB::table('placelocation')
104
                ->where('pl_id', '=', $id)
105
                ->first();
106
107
            // For static analysis tools.
108
            assert($row instanceof stdClass);
109
110
            $fqpn[]    = $row->pl_place;
111
            $row->fqpn = implode(Gedcom::PLACE_SEPARATOR, $fqpn);
112
            $id        = (int) $row->pl_parent_id;
113
            $arr[]     = $row;
114
        }
115
116
        return array_reverse($arr);
117
    }
118
119
    /**
120
     * @param ServerRequestInterface $request
121
     *
122
     * @return ResponseInterface
123
     */
124
    public function mapDataEdit(ServerRequestInterface $request): ResponseInterface
125
    {
126
        $parent_id = (int) $request->getQueryParams()['parent_id'];
127
        $hierarchy = $this->getHierarchy($parent_id);
128
        $fqpn      = $hierarchy === [] ? '' : $hierarchy[0]->fqpn;
129
        $parent    = new PlaceLocation($fqpn);
130
131
        $place_id  = (int) $request->getQueryParams()['place_id'];
132
        $hierarchy = $this->getHierarchy($place_id);
133
        $fqpn      = $hierarchy === [] ? '' : $hierarchy[0]->fqpn;
134
        $location  = new PlaceLocation($fqpn);
135
136
        if ($location->id() !== 0) {
137
            $title = e($location->locationName());
138
        } else {
139
            // Add a place
140
            if ($parent_id === 0) {
141
                // We're at the global level so create a minimal
142
                // place for the page title and breadcrumbs
143
                $title     = I18N::translate('World');
144
                $hierarchy = [];
145
            } else {
146
                $hierarchy = $this->getHierarchy($parent_id);
147
                $tmp       = new PlaceLocation($hierarchy[0]->fqpn);
148
                $title     = e($tmp->locationName());
149
            }
150
        }
151
152
        $breadcrumbs = [
153
            route(ControlPanel::class) => I18N::translate('Control panel'),
154
            route(MapDataList::class)  => I18N::translate('Geographic data'),
155
        ];
156
157
        foreach ($hierarchy as $row) {
158
            $breadcrumbs[route(MapDataList::class, ['parent_id' => $row->pl_id])] = e($row->pl_place);
159
        }
160
161
        if ($place_id === 0) {
162
            $title .= ' — ' . I18N::translate('Add');
163
            $breadcrumbs[] = I18N::translate('Add');
164
            $latitude      = null;
165
            $longitude     = null;
166
            $map_bounds    = $parent->boundingRectangle();
167
        } else {
168
            $title .= ' — ' . I18N::translate('Edit');
169
            $breadcrumbs[] = I18N::translate('Edit');
170
            $latitude      = $location->latitude();
171
            $longitude     = $location->longitude();
172
            $map_bounds    = $location->boundingRectangle();
173
        }
174
175
        // If the current co-ordinates are unknown, leave the input fields empty,
176
        // and show a marker in the middle of the map.
177
        if ($latitude === null || $longitude === null) {
178
            $latitude  = '';
179
            $longitude = '';
180
181
            $marker_position = [
182
                ($map_bounds[0][0] + $map_bounds[1][0]) / 2.0,
183
                ($map_bounds[0][1] + $map_bounds[1][1]) / 2.0,
184
            ];
185
        } else {
186
            $marker_position = [$latitude, $longitude];
187
        }
188
189
        return $this->viewResponse('admin/location-edit', [
190
            'breadcrumbs'     => $breadcrumbs,
191
            'title'           => $title,
192
            'location'        => $location,
193
            'latitude'        => $latitude,
194
            'longitude'       => $longitude,
195
            'map_bounds'      => $map_bounds,
196
            'marker_position' => $marker_position,
197
            'parent'          => $parent,
198
            'level'           => $parent_id,
199
            'provider'        => [
200
                'url'     => 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
201
                'options' => [
202
                    'attribution' => '<a href="https://www.openstreetmap.org/copyright">&copy; OpenStreetMap</a> contributors',
203
                    'max_zoom'    => 19
204
                ]
205
            ],
206
        ]);
207
    }
208
209
    /**
210
     * @param ServerRequestInterface $request
211
     *
212
     * @return ResponseInterface
213
     */
214
    public function mapDataSave(ServerRequestInterface $request): ResponseInterface
215
    {
216
        $params = (array) $request->getParsedBody();
217
218
        $parent_id = (int) $request->getQueryParams()['parent_id'];
219
        $place_id  = (int) $request->getQueryParams()['place_id'];
220
        $lat       = $this->gedcom_service->writeLatitude((float) $params['new_place_lati']);
221
        $lng       = $this->gedcom_service->writeLongitude((float) $params['new_place_long']);
222
        $hierarchy = $this->getHierarchy($parent_id);
223
        $level     = count($hierarchy);
224
        $icon      = $params['icon'];
225
        $zoom      = (int) $params['new_zoom_factor'];
226
227
        if ($place_id === 0) {
228
            $place_id = 1 + (int) DB::table('placelocation')->max('pl_id');
229
230
            DB::table('placelocation')->insert([
231
                'pl_id'        => $place_id,
232
                'pl_parent_id' => $parent_id,
233
                'pl_level'     => $level,
234
                'pl_place'     => mb_substr($params['new_place_name'], 0, 120),
235
                'pl_lati'      => $lat,
236
                'pl_long'      => $lng,
237
                'pl_zoom'      => $zoom,
238
                'pl_icon'      => $icon,
239
            ]);
240
        } else {
241
            DB::table('placelocation')
242
                ->where('pl_id', '=', $place_id)
243
                ->update([
244
                    'pl_place' => mb_substr($params['new_place_name'], 0, 120),
245
                    'pl_lati'  => $lat,
246
                    'pl_long'  => $lng,
247
                    'pl_zoom'  => $zoom,
248
                    'pl_icon'  => $icon,
249
                ]);
250
        }
251
252
        FlashMessages::addMessage(
253
            I18N::translate(
254
                'The details for “%s” have been updated.',
255
                e($params['new_place_name'])
256
            ),
257
            'success'
258
        );
259
260
        $url = route(MapDataList::class, ['parent_id' => $parent_id]);
261
262
        return redirect($url);
263
    }
264
265
    /**
266
     * @param ServerRequestInterface $request
267
     *
268
     * @return ResponseInterface
269
     */
270
    public function exportLocations(ServerRequestInterface $request): ResponseInterface
271
    {
272
        $parent_id = (int) $request->getQueryParams()['parent_id'];
273
        $format    = $request->getQueryParams()['format'];
274
        $hierarchy = $this->getHierarchy($parent_id);
275
276
        // Create the file name
277
        // $hierarchy[0] always holds the full placename
278
        $place_name = $hierarchy === [] ? 'Global' : $hierarchy[0]->fqpn;
279
        $place_name = str_replace(Gedcom::PLACE_SEPARATOR, '-', $place_name);
280
        $filename   = addcslashes('Places-' . preg_replace('/[^a-zA-Z0-9.-]/', '', $place_name), '"');
281
282
        // Fill in the place names for the starting conditions
283
        $startfqpn = [];
284
        foreach ($hierarchy as $record) {
285
            $startfqpn[] = $record->pl_place;
286
        }
287
288
        // Generate an array containing the data to output.
289
        $places = [];
290
        $this->buildExport($parent_id, $startfqpn, $places);
291
292
        // Pad all locations to the length of the longest.
293
        $max_level = 0;
294
        foreach ($places as $place) {
295
            $max_level = max($max_level, count($place->fqpn));
296
        }
297
298
        $places = array_map(static function (stdClass $place) use ($max_level): array {
299
            return array_merge(
300
                [count($place->fqpn) - 1],
301
                array_pad($place->fqpn, $max_level, ''),
302
                [$place->pl_long],
303
                [$place->pl_lati],
304
                [$place->pl_zoom],
305
                [$place->pl_icon]
306
            );
307
        }, $places);
308
309
        if ($format === 'csv') {
310
            // Create the header line for the output file (always English)
311
            $header = [
312
                I18N::translate('Level'),
313
            ];
314
315
            for ($i = 0; $i < $max_level; $i++) {
316
                $header[] = 'Place' . $i;
317
            }
318
319
            $header[] = 'Longitude';
320
            $header[] = 'Latitude';
321
            $header[] = 'Zoom';
322
            $header[] = 'Icon';
323
324
            return $this->exportCSV($filename . '.csv', $header, $places);
325
        }
326
327
        return $this->exportGeoJSON($filename . '.geojson', $places, $max_level);
328
    }
329
330
    /**
331
     * @param int             $parent_id
332
     * @param array<string>   $fqpn
333
     * @param array<stdClass> $places
334
     *
335
     * @return void
336
     * @throws Exception
337
     */
338
    private function buildExport(int $parent_id, array $fqpn, array &$places): void
339
    {
340
        // Current number of levels.
341
        $level = count($fqpn);
342
343
        // Data for the next level.
344
        $rows = DB::table('placelocation')
345
            ->where('pl_parent_id', '=', $parent_id)
346
            ->orderBy(new Expression('pl_place /*! COLLATE ' . I18N::collation() . ' */'))
347
            ->get()
348
            ->filter(function ($v) {
349
                return $v->pl_lati !== '' && $v->pl_long !== '';
350
            });
351
352
        foreach ($rows as $row) {
353
            $fqpn[$level] = $row->pl_place;
354
355
            $row->fqpn    = $fqpn;
356
            $row->pl_zoom = (int) $row->pl_zoom;
357
            $row->pl_icon = (string) $row->pl_icon;
358
359
            $places[] = $row;
360
361
            $this->buildExport((int) $row->pl_id, $fqpn, $places);
362
        }
363
    }
364
365
    /**
366
     * @param string     $filename
367
     * @param string[]   $columns
368
     * @param string[][] $places
369
     *
370
     * @return ResponseInterface
371
     */
372
    private function exportCSV(string $filename, array $columns, array $places): ResponseInterface
373
    {
374
        $resource = fopen('php://temp', 'wb+');
375
376
        if ($resource === false) {
377
            throw new RuntimeException('Failed to create temporary stream');
378
        }
379
380
        fputcsv($resource, $columns, ';');
381
382
        foreach ($places as $place) {
383
            fputcsv($resource, $place, ';');
384
        }
385
386
        rewind($resource);
387
388
        return response(stream_get_contents($resource))
389
            ->withHeader('Content-Type', 'text/csv; charset=utf-8')
390
            ->withHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
391
    }
392
393
    /**
394
     * @param string $filename
395
     * @param array  $rows
396
     * @param int    $maxlevel
397
     *
398
     * @return ResponseInterface
399
     */
400
    private function exportGeoJSON(string $filename, array $rows, int $maxlevel): ResponseInterface
401
    {
402
        $geojson = [
403
            'type'     => 'FeatureCollection',
404
            'features' => [],
405
        ];
406
        foreach ($rows as $place) {
407
            $fqpn = implode(
408
                Gedcom::PLACE_SEPARATOR,
409
                array_reverse(
410
                    array_filter(
411
                        array_slice($place, 1, $maxlevel)
412
                    )
413
                )
414
            );
415
416
            $geojson['features'][] = [
417
                'type'       => 'Feature',
418
                'geometry'   => [
419
                    'type'        => 'Point',
420
                    'coordinates' => [
421
                        $this->gedcom_service->readLongitude($place[$maxlevel + 1]),
422
                        $this->gedcom_service->readLatitude($place[$maxlevel + 2]),
423
                    ],
424
                ],
425
                'properties' => [
426
                    'name' => $fqpn,
427
                ],
428
            ];
429
        }
430
431
        return response($geojson)
432
            ->withHeader('Content-Type', 'application/vnd.geo+json')
433
            ->withHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
434
    }
435
436
    /**
437
     * @param ServerRequestInterface $request
438
     *
439
     * @return ResponseInterface
440
     */
441
    public function importLocations(ServerRequestInterface $request): ResponseInterface
442
    {
443
        $data_filesystem      = Registry::filesystem()->data();
444
        $data_filesystem_name = Registry::filesystem()->dataName();
445
446
        $parent_id = (int) $request->getQueryParams()['parent_id'];
447
448
        $files = Collection::make($data_filesystem->listContents('places'))
449
            ->filter(static function (array $metadata): bool {
450
                $extension = strtolower($metadata['extension'] ?? '');
451
452
                return $extension === 'csv' || $extension === 'geojson';
453
            })
454
            ->map(static function (array $metadata): string {
455
                return $metadata['basename'];
456
            })
457
            ->sort();
458
459
        return $this->viewResponse('admin/map-import-form', [
460
            'place_folder' => $data_filesystem_name . self::PLACES_FOLDER,
461
            'title'        => I18N::translate('Import geographic data'),
462
            'parent_id'    => $parent_id,
463
            'files'        => $files,
464
        ]);
465
    }
466
467
    /**
468
     * This function assumes the input file layout is
469
     * level followed by a variable number of placename fields
470
     * followed by Longitude, Latitude, Zoom & Icon
471
     *
472
     * @param ServerRequestInterface $request
473
     *
474
     * @return ResponseInterface
475
     * @throws Exception
476
     */
477
    public function importLocationsAction(ServerRequestInterface $request): ResponseInterface
478
    {
479
        $data_filesystem = Registry::filesystem()->data();
480
481
        $params = (array) $request->getParsedBody();
482
        $url    = route(MapDataList::class, ['parent_id' => 0]);
483
484
        $serverfile     = $params['serverfile'] ?? '';
485
        $options        = $params['import-options'] ?? '';
486
        $clear_database = (bool) ($params['cleardatabase'] ?? false);
487
        $local_file     = $request->getUploadedFiles()['localfile'] ?? null;
488
489
        $fp = false;
490
491
        if ($serverfile !== '' && $data_filesystem->has(self::PLACES_FOLDER . $serverfile)) {
492
            // first choice is file on server
493
            $fp = $data_filesystem->readStream(self::PLACES_FOLDER . $serverfile);
494
        } elseif ($local_file instanceof UploadedFileInterface && $local_file->getError() === UPLOAD_ERR_OK) {
495
            // 2nd choice is local file
496
            $fp = $local_file->getStream()->detach();
497
        }
498
499
        if ($fp === false) {
500
            return redirect($url);
501
        }
502
503
        $string = stream_get_contents($fp);
504
505
        $places = [];
506
507
        // Check the file type
508
        if (stripos($string, 'FeatureCollection') !== false) {
509
            $input_array = json_decode($string, false);
510
511
            foreach ($input_array->features as $feature) {
512
                $places[] = [
513
                    'pl_level' => $feature->properties->level ?? substr_count($feature->properties->name, ','),
514
                    'pl_long'  => $feature->geometry->coordinates[0],
515
                    'pl_lati'  => $feature->geometry->coordinates[1],
516
                    'pl_zoom'  => $feature->properties->zoom ?? null,
517
                    'pl_icon'  => $feature->properties->icon ?? null,
518
                    'fqpn'     => $feature->properties->name,
519
                ];
520
            }
521
        } else {
522
            rewind($fp);
523
            while (($row = fgetcsv($fp, 0, ';')) !== false) {
524
                // Skip the header
525
                if (!is_numeric($row[0])) {
526
                    continue;
527
                }
528
529
                $level = (int) $row[0];
530
                $count = count($row);
531
532
                // convert separate place fields into a comma separated placename
533
                $fqdn = implode(Gedcom::PLACE_SEPARATOR, array_reverse(array_slice($row, 1, 1 + $level)));
534
535
                $places[] = [
536
                    'pl_level' => $level,
537
                    'pl_long'  => (float) strtr($row[$count - 4], ['E' => '', 'W' => '-', ',' => '.']),
538
                    'pl_lati'  => (float) strtr($row[$count - 3], ['N' => '', 'S' => '-', ',' => '.']),
539
                    'pl_zoom'  => $row[$count - 2],
540
                    'pl_icon'  => $row[$count - 1],
541
                    'fqpn'     => $fqdn,
542
                ];
543
            }
544
        }
545
546
        fclose($fp);
547
548
        if ($clear_database) {
549
            DB::table('placelocation')->delete();
550
        }
551
552
        $added   = 0;
553
        $updated = 0;
554
555
        // Remove places with invalid coordinates
556
        $places = array_filter($places, function ($item) {
557
            return $item['pl_level'] === 0 || $item['pl_long'] !== 0.0 || $item['pl_lati'] !== 0.0;
558
        });
559
560
        foreach ($places as $place) {
561
            $location = new PlaceLocation($place['fqpn']);
562
            $exists   = $location->exists();
563
564
            // Only update existing records
565
            if ($options === 'update' && !$exists) {
566
                continue;
567
            }
568
569
            // Only add new records
570
            if ($options === 'add' && $exists) {
571
                continue;
572
            }
573
574
            if (!$exists) {
575
                $added++;
576
            }
577
578
            $updated += DB::table('placelocation')
579
                ->where('pl_id', '=', $location->id())
580
                ->update([
581
                    'pl_lati' => $this->gedcom_service->writeLatitude($place['pl_lati']),
582
                    'pl_long' => $this->gedcom_service->writeLongitude($place['pl_long']),
583
                    'pl_zoom' => $place['pl_zoom'] ?: null,
584
                    'pl_icon' => $place['pl_icon'] ?: null,
585
                ]);
586
        }
587
        FlashMessages::addMessage(
588
            I18N::translate('locations updated: %s, locations added: %s', I18N::number($updated), I18N::number($added)),
589
            'info'
590
        );
591
592
        return redirect($url);
593
    }
594
}
595