Passed
Push — master ( d82780...94e359 )
by Greg
06:13
created

LocationController::mapData()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 21
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

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