Passed
Pull Request — master (#3652)
by
unknown
06:22
created

LocationController::importLocationsAction()   F

Complexity

Conditions 21
Paths 123

Size

Total Lines 116
Code Lines 68

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 21
eloc 68
nc 123
nop 1
dl 0
loc 116
rs 3.975
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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