Passed
Push — master ( 5ede91...7b4de6 )
by Greg
08:33
created

LocationController   F

Complexity

Total Complexity 71

Size/Duplication

Total Lines 842
Duplicated Lines 0 %

Importance

Changes 7
Bugs 1 Features 0
Metric Value
eloc 462
c 7
b 1
f 0
dl 0
loc 842
rs 2.7199
wmc 71

17 Methods

Rating   Name   Duplication   Size   Complexity  
A mapDataDelete() 0 29 3
A childLocationStatus() 0 29 1
B getPlaceListLocation() 0 39 6
A exportCSV() 0 17 2
A getHierarchy() 0 17 2
A exportGeoJSON() 0 36 2
A isLocationActive() 0 19 2
B exportLocations() 0 58 6
A mapLocationData() 0 25 3
B mapDataEdit() 0 61 6
D importLocationsAction() 0 121 19
B importLocationsFromTree() 0 111 6
A mapDataSave() 0 51 4
A __construct() 0 4 1
A mapData() 0 21 2
A buildExport() 0 25 4
A importLocations() 0 26 2

How to fix   Complexity   

Complex Class

Complex classes like LocationController often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use LocationController, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2019 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\I18N;
27
use Fisharebest\Webtrees\Location;
28
use Fisharebest\Webtrees\Services\GedcomService;
29
use Fisharebest\Webtrees\Services\TreeService;
30
use Fisharebest\Webtrees\Tree;
31
use Illuminate\Database\Capsule\Manager as DB;
32
use Illuminate\Database\Eloquent\Collection;
33
use Illuminate\Database\Query\Expression;
34
use Illuminate\Database\Query\JoinClause;
35
use Illuminate\Database\QueryException;
36
use League\Flysystem\FilesystemInterface;
37
use Psr\Http\Message\ResponseInterface;
38
use Psr\Http\Message\ServerRequestInterface;
39
use Psr\Http\Message\UploadedFileInterface;
40
use stdClass;
41
42
use function abs;
43
use function addcslashes;
44
use function array_combine;
45
use function array_filter;
46
use function array_merge;
47
use function array_pad;
48
use function array_pop;
49
use function array_reverse;
50
use function array_shift;
51
use function array_slice;
52
use function assert;
53
use function count;
54
use function e;
55
use function explode;
56
use function fclose;
57
use function fgetcsv;
58
use function fopen;
59
use function fputcsv;
60
use function implode;
61
use function is_numeric;
62
use function is_string;
63
use function json_decode;
64
use function preg_replace;
65
use function redirect;
66
use function response;
67
use function rewind;
68
use function round;
69
use function route;
70
use function str_replace;
71
use function stream_get_contents;
72
use function stripos;
73
use function substr_count;
74
75
use const UPLOAD_ERR_OK;
76
77
/**
78
 * Controller for maintaining geographic data.
79
 */
80
class LocationController extends AbstractAdminController
81
{
82
    // Location of files to import
83
    private const PLACES_FOLDER = 'places/';
84
85
    /** @var GedcomService */
86
    private $gedcom_service;
87
88
    /** @var TreeService */
89
    private $tree_service;
90
91
    /**
92
     * Dependency injection.
93
     *
94
     * @param GedcomService $gedcom_service
95
     * @param TreeService   $tree_service
96
     */
97
    public function __construct(GedcomService $gedcom_service, TreeService $tree_service)
98
    {
99
        $this->gedcom_service = $gedcom_service;
100
        $this->tree_service   = $tree_service;
101
    }
102
103
    /**
104
     * @param ServerRequestInterface $request
105
     *
106
     * @return ResponseInterface
107
     */
108
    public function mapData(ServerRequestInterface $request): ResponseInterface
109
    {
110
        $parent_id   = (int) ($request->getQueryParams()['parent_id'] ?? 0);
111
        $hierarchy   = $this->getHierarchy($parent_id);
112
        $title       = I18N::translate('Geographic data');
113
        $breadcrumbs = [
114
            route(ControlPanel::class) => I18N::translate('Control panel'),
115
            route('map-data')            => $title,
116
        ];
117
118
        foreach ($hierarchy as $row) {
119
            $breadcrumbs[route('map-data', ['parent_id' => $row->pl_id])] = $row->pl_place;
120
        }
121
        $breadcrumbs[] = array_pop($breadcrumbs);
122
123
        return $this->viewResponse('admin/locations', [
124
            'title'       => $title,
125
            'breadcrumbs' => $breadcrumbs,
126
            'parent_id'   => $parent_id,
127
            'placelist'   => $this->getPlaceListLocation($parent_id),
128
            'tree_titles' => $this->tree_service->titles(),
129
        ]);
130
    }
131
132
    /**
133
     * @param int $id
134
     *
135
     * @return array
136
     */
137
    private function getHierarchy(int $id): array
138
    {
139
        $arr  = [];
140
        $fqpn = [];
141
142
        while ($id !== 0) {
143
            $row = DB::table('placelocation')
144
                ->where('pl_id', '=', $id)
145
                ->first();
146
147
            $fqpn[]    = $row->pl_place;
148
            $row->fqpn = implode(Gedcom::PLACE_SEPARATOR, $fqpn);
149
            $id        = (int) $row->pl_parent_id;
150
            $arr[]     = $row;
151
        }
152
153
        return array_reverse($arr);
154
    }
155
156
    /**
157
     * Find all of the places in the hierarchy
158
     *
159
     * @param int $id
160
     *
161
     * @return stdClass[]
162
     */
163
    private function getPlaceListLocation(int $id): array
164
    {
165
        // We know the id of the place in the placelocation table,
166
        // now get the id of the same place in the places table
167
        if ($id === 0) {
168
            $fqpn = '';
169
        } else {
170
            $hierarchy = $this->getHierarchy($id);
171
            $fqpn      = ', ' . $hierarchy[0]->fqpn;
172
        }
173
174
        $rows = DB::table('placelocation')
175
            ->where('pl_parent_id', '=', $id)
176
            ->orderBy('pl_place')
177
            ->get();
178
179
        $list = [];
180
        foreach ($rows as $row) {
181
            // Find/count places without co-ordinates
182
            $children = $this->childLocationStatus((int) $row->pl_id);
183
            $active   = $this->isLocationActive($row->pl_place . $fqpn);
184
185
            if (!$active) {
186
                $badge = 'danger';
187
            } elseif ((int) $children->no_coord > 0) {
188
                $badge = 'warning';
189
            } elseif ((int) $children->child_count > 0) {
190
                $badge = 'info';
191
            } else {
192
                $badge = 'secondary';
193
            }
194
195
            $row->child_count = (int) $children->child_count;
196
            $row->badge       = $badge;
197
198
            $list[] = $row;
199
        }
200
201
        return $list;
202
    }
203
204
    /**
205
     * How many children does place have?  How many have co-ordinates?
206
     *
207
     * @param int $parent_id
208
     *
209
     * @return stdClass
210
     */
211
    private function childLocationStatus(int $parent_id): stdClass
212
    {
213
        $prefix = DB::connection()->getTablePrefix();
214
215
        $expression =
216
            $prefix . 'p0.pl_place IS NOT NULL AND ' . $prefix . 'p0.pl_lati IS NULL OR ' .
217
            $prefix . 'p1.pl_place IS NOT NULL AND ' . $prefix . 'p1.pl_lati IS NULL OR ' .
218
            $prefix . 'p2.pl_place IS NOT NULL AND ' . $prefix . 'p2.pl_lati IS NULL OR ' .
219
            $prefix . 'p3.pl_place IS NOT NULL AND ' . $prefix . 'p3.pl_lati IS NULL OR ' .
220
            $prefix . 'p4.pl_place IS NOT NULL AND ' . $prefix . 'p4.pl_lati IS NULL OR ' .
221
            $prefix . 'p5.pl_place IS NOT NULL AND ' . $prefix . 'p5.pl_lati IS NULL OR ' .
222
            $prefix . 'p6.pl_place IS NOT NULL AND ' . $prefix . 'p6.pl_lati IS NULL OR ' .
223
            $prefix . 'p7.pl_place IS NOT NULL AND ' . $prefix . 'p7.pl_lati IS NULL OR ' .
224
            $prefix . 'p8.pl_place IS NOT NULL AND ' . $prefix . 'p8.pl_lati IS NULL OR ' .
225
            $prefix . 'p9.pl_place IS NOT NULL AND ' . $prefix . 'p9.pl_lati IS NULL';
226
227
        return DB::table('placelocation AS p0')
228
            ->leftJoin('placelocation AS p1', 'p1.pl_parent_id', '=', 'p0.pl_id')
229
            ->leftJoin('placelocation AS p2', 'p2.pl_parent_id', '=', 'p1.pl_id')
230
            ->leftJoin('placelocation AS p3', 'p3.pl_parent_id', '=', 'p2.pl_id')
231
            ->leftJoin('placelocation AS p4', 'p4.pl_parent_id', '=', 'p3.pl_id')
232
            ->leftJoin('placelocation AS p5', 'p5.pl_parent_id', '=', 'p4.pl_id')
233
            ->leftJoin('placelocation AS p6', 'p6.pl_parent_id', '=', 'p5.pl_id')
234
            ->leftJoin('placelocation AS p7', 'p7.pl_parent_id', '=', 'p6.pl_id')
235
            ->leftJoin('placelocation AS p8', 'p8.pl_parent_id', '=', 'p7.pl_id')
236
            ->leftJoin('placelocation AS p9', 'p9.pl_parent_id', '=', 'p8.pl_id')
237
            ->where('p0.pl_parent_id', '=', $parent_id)
238
            ->select([new Expression('COUNT(*) AS child_count'), new Expression('SUM(' . $expression . ') AS no_coord')])
239
            ->first();
240
    }
241
242
    /**
243
     * Is a place name used in any tree?
244
     *
245
     * @param string $place_name
246
     *
247
     * @return bool
248
     */
249
    private function isLocationActive(string $place_name): bool
250
    {
251
        $places = explode(Gedcom::PLACE_SEPARATOR, $place_name);
252
253
        $query = DB::table('places AS p0')
254
            ->where('p0.p_place', '=', $places[0])
255
            ->select(['p0.*']);
256
257
        array_shift($places);
258
259
        foreach ($places as $n => $place) {
260
            $query->join('places AS p' . ($n + 1), static function (JoinClause $join) use ($n, $place): void {
261
                $join
262
                    ->on('p' . ($n + 1) . '.p_id', '=', 'p' . $n . '.p_parent_id')
263
                    ->where('p' . ($n + 1) . '.p_place', '=', $place);
264
            });
265
        }
266
267
        return $query->exists();
268
    }
269
270
    /**
271
     * @param ServerRequestInterface $request
272
     *
273
     * @return ResponseInterface
274
     */
275
    public function mapDataEdit(ServerRequestInterface $request): ResponseInterface
276
    {
277
        $parent_id = (int) $request->getQueryParams()['parent_id'];
278
        $place_id  = (int) $request->getQueryParams()['place_id'];
279
        $hierarchy = $this->getHierarchy($place_id);
280
        $fqpn      = $hierarchy === [] ? '' : $hierarchy[0]->fqpn;
281
        $location  = new Location($fqpn);
282
283
        if ($location->id() !== 0) {
284
            $lat   = $location->latitude();
285
            $lng   = $location->longitude();
286
            $id    = $place_id;
287
            $title = e($location->locationName());
288
        } else {
289
            // Add a place
290
            $lat       = '';
291
            $lng       = '';
292
            $id        = $parent_id;
293
            if ($parent_id === 0) {
294
                // We're at the global level so create a minimal
295
                // place for the page title and breadcrumbs
296
                $title         =  I18N::translate('World');
297
                $hierarchy     =  [];
298
            } else {
299
                $hierarchy = $this->getHierarchy($parent_id);
300
                $tmp       = new Location($hierarchy[0]->fqpn);
301
                $title     = e($tmp->locationName());
302
            }
303
        }
304
305
        $breadcrumbs = [
306
            route(ControlPanel::class) => I18N::translate('Control panel'),
307
            route('map-data')          => I18N::translate('Geographic data'),
308
        ];
309
310
        foreach ($hierarchy as $row) {
311
            $breadcrumbs[route('map-data', ['parent_id' => $row->pl_id])] = e($row->pl_place);
312
        }
313
314
        if ($place_id === 0) {
315
            $breadcrumbs[] = I18N::translate('Add');
316
            $title         .= ' — ' . I18N::translate('Add');
317
        } else {
318
            $breadcrumbs[] = I18N::translate('Edit');
319
            $title         .= ' — ' . I18N::translate('Edit');
320
        }
321
322
        return $this->viewResponse('admin/location-edit', [
323
            'breadcrumbs' => $breadcrumbs,
324
            'title'       => $title,
325
            'location'    => $location,
326
            'place_id'    => $place_id,
327
            'parent_id'   => $parent_id,
328
            'lat'         => $lat,
329
            'lng'         => $lng,
330
            'data'        => $this->mapLocationData($id),
331
            'provider'    => [
332
                'url'     => 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
333
                'options' => [
334
                    'attribution' => '<a href="https://www.openstreetmap.org/copyright">&copy; OpenStreetMap</a> contributors',
335
                    'max_zoom'    => 19
336
                ]
337
            ],
338
        ]);
339
    }
340
341
    /**
342
     * @param int $id
343
     *
344
     * @return array
345
     */
346
    private function mapLocationData(int $id): array
347
    {
348
        $row = DB::table('placelocation')
349
            ->where('pl_id', '=', $id)
350
            ->first();
351
352
        if ($row === null) {
353
            $json = [
354
                'zoom'        => 2,
355
                'coordinates' => [
356
                    0.0,
357
                    0.0,
358
                ],
359
            ];
360
        } else {
361
            $json = [
362
                'zoom'        => (int) $row->pl_zoom ?: 2,
363
                'coordinates' => [
364
                    (float) strtr($row->pl_lati ?? '0', ['N' => '', 'S' => '-', ',' => '.']),
365
                    (float) strtr($row->pl_long ?? '0', ['E' => '', 'W' => '-', ',' => '.']),
366
                ],
367
            ];
368
        }
369
370
        return $json;
371
    }
372
373
    /**
374
     * @param ServerRequestInterface $request
375
     *
376
     * @return ResponseInterface
377
     */
378
    public function mapDataSave(ServerRequestInterface $request): ResponseInterface
379
    {
380
        $params = (array) $request->getParsedBody();
381
382
        $parent_id = (int) $request->getQueryParams()['parent_id'];
383
        $place_id  = (int) $request->getQueryParams()['place_id'];
384
        $lat       = round($params['new_place_lati'], 5); // 5 decimal places (locate to within about 1 metre)
385
        $lat       = ($lat < 0 ? 'S' : 'N') . abs($lat);
386
        $lng       = round($params['new_place_long'], 5);
387
        $lng       = ($lng < 0 ? 'W' : 'E') . abs($lng);
388
        $hierarchy = $this->getHierarchy($parent_id);
389
        $level     = count($hierarchy);
390
        $icon      = $params['icon'];
391
        $zoom      = (int) $params['new_zoom_factor'];
392
393
        if ($place_id === 0) {
394
            $place_id = 1 + (int) DB::table('placelocation')->max('pl_id');
395
396
            DB::table('placelocation')->insert([
397
                'pl_id'        => $place_id,
398
                'pl_parent_id' => $parent_id,
399
                'pl_level'     => $level,
400
                'pl_place'     => $params['new_place_name'],
401
                'pl_lati'      => $lat,
402
                'pl_long'      => $lng,
403
                'pl_zoom'      => $zoom,
404
                'pl_icon'      => $icon,
405
            ]);
406
        } else {
407
            DB::table('placelocation')
408
                ->where('pl_id', '=', $place_id)
409
                ->update([
410
                    'pl_place' => $params['new_place_name'],
411
                    'pl_lati'  => $lat,
412
                    'pl_long'  => $lng,
413
                    'pl_zoom'  => $zoom,
414
                    'pl_icon'  => $icon,
415
                ]);
416
        }
417
418
        FlashMessages::addMessage(
419
            I18N::translate(
420
                'The details for “%s” have been updated.',
421
                e($params['new_place_name'])
422
            ),
423
            'success'
424
        );
425
426
        $url = route('map-data', ['parent_id' => $parent_id]);
427
428
        return redirect($url);
429
    }
430
431
    /**
432
     * @param ServerRequestInterface $request
433
     *
434
     * @return ResponseInterface
435
     */
436
    public function mapDataDelete(ServerRequestInterface $request): ResponseInterface
437
    {
438
        $place_id  = (int) $request->getQueryParams()['place_id'];
439
        $parent_id = (int) $request->getQueryParams()['parent_id'];
440
441
        try {
442
            DB::table('placelocation')
443
                ->where('pl_id', '=', $place_id)
444
                ->delete();
445
        } catch (Exception $ex) {
446
            FlashMessages::addMessage(
447
                I18N::translate('Location not removed: this location contains sub-locations'),
448
                'danger'
449
            );
450
        }
451
        // If after deleting there are no more places at this level then go up a level
452
        $children = DB::table('placelocation')
453
            ->where('pl_parent_id', '=', $parent_id)
454
            ->count();
455
456
        if ($children === 0) {
457
            $parent_id = (int) DB::table('placelocation')
458
                ->where('pl_id', '=', $parent_id)
459
                ->value('pl_parent_id');
460
        }
461
462
        $url = route('map-data', ['parent_id' => $parent_id]);
463
464
        return redirect($url);
465
    }
466
467
    /**
468
     * @param ServerRequestInterface $request
469
     *
470
     * @return ResponseInterface
471
     */
472
    public function exportLocations(ServerRequestInterface $request): ResponseInterface
473
    {
474
        $parent_id = (int) $request->getQueryParams()['parent_id'];
475
        $format    = $request->getQueryParams()['format'];
476
        $hierarchy = $this->getHierarchy($parent_id);
477
478
        // Create the file name
479
        // $hierarchy[0] always holds the full placename
480
        $place_name = $hierarchy === [] ? 'Global' : $hierarchy[0]->fqpn;
481
        $place_name = str_replace(Gedcom::PLACE_SEPARATOR, '-', $place_name);
482
        $filename   = 'Places-' . preg_replace('/[^a-zA-Z0-9.-]/', '', $place_name);
483
484
        // Fill in the place names for the starting conditions
485
        $startfqpn = [];
486
        foreach ($hierarchy as $record) {
487
            $startfqpn[] = $record->pl_place;
488
        }
489
490
        // Generate an array containing the data to output.
491
        $places = [];
492
        $this->buildExport($parent_id, $startfqpn, $places);
493
494
        // Pad all locations to the length of the longest.
495
        $max_level = 0;
496
        foreach ($places as $place) {
497
            $max_level = max($max_level, count($place->fqpn));
498
        }
499
500
        $places = array_map(static function (stdClass $place) use ($max_level): array {
501
            return array_merge(
502
                [count($place->fqpn) - 1],
503
                array_pad($place->fqpn, $max_level, ''),
504
                [$place->pl_long],
505
                [$place->pl_lati],
506
                [$place->pl_zoom],
507
                [$place->pl_icon]
508
            );
509
        }, $places);
510
511
        if ($format === 'csv') {
512
            // Create the header line for the output file (always English)
513
            $header = [
514
                I18N::translate('Level'),
515
            ];
516
517
            for ($i = 0; $i < $max_level; $i++) {
518
                $header[] = 'Place' . $i;
519
            }
520
521
            $header[] = 'Longitude';
522
            $header[] = 'Latitude';
523
            $header[] = 'Zoom';
524
            $header[] = 'Icon';
525
526
            return $this->exportCSV($filename . '.csv', $header, $places);
527
        }
528
529
        return $this->exportGeoJSON($filename . '.geojson', $places, $maxlevel);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $maxlevel does not exist. Did you maybe mean $max_level?
Loading history...
530
    }
531
532
    /**
533
     * @param int             $parent_id
534
     * @param array<string>   $fqpn
535
     * @param array<stdClass> $places
536
     *
537
     * @return void
538
     * @throws Exception
539
     */
540
    private function buildExport(int $parent_id, array $fqpn, array &$places): void
541
    {
542
        // Current number of levels.
543
        $level = count($fqpn);
544
545
        // Data for the next level.
546
        $rows = DB::table('placelocation')
547
            ->where('pl_parent_id', '=', $parent_id)
548
            ->orderBy('pl_place')
549
            ->get();
550
551
        foreach ($rows as $row) {
552
            $fqpn[$level] = $row->pl_place;
553
554
            $row->fqpn    = $fqpn;
555
            $row->pl_long = $row->pl_long ?? 'E0';
556
            $row->pl_lati = $row->pl_lati ?? 'N0';
557
            $row->pl_zoom = (int) $row->pl_zoom;
558
            $row->pl_icon = (string) $row->pl_icon;
559
560
            if ($row->pl_long !== 'E0' || $row->pl_lati !== 'N0') {
561
                $places[] = $row;
562
            }
563
564
            $this->buildExport((int) $row->pl_id, $fqpn, $places);
565
        }
566
    }
567
568
    /**
569
     * @param string     $filename
570
     * @param string[]   $columns
571
     * @param string[][] $places
572
     *
573
     * @return ResponseInterface
574
     */
575
    private function exportCSV(string $filename, array $columns, array $places): ResponseInterface
576
    {
577
        $resource = fopen('php://temp', 'rb+');
578
579
        fputcsv($resource, $columns, ';');
0 ignored issues
show
Bug introduced by
It seems like $resource can also be of type false; however, parameter $handle of fputcsv() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

579
        fputcsv(/** @scrutinizer ignore-type */ $resource, $columns, ';');
Loading history...
580
581
        foreach ($places as $place) {
582
            fputcsv($resource, $place, ';');
583
        }
584
585
        rewind($resource);
0 ignored issues
show
Bug introduced by
It seems like $resource can also be of type false; however, parameter $handle of rewind() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

585
        rewind(/** @scrutinizer ignore-type */ $resource);
Loading history...
586
587
        $filename = addcslashes($filename, '"');
588
589
        return response(stream_get_contents($resource))
0 ignored issues
show
Bug introduced by
It seems like $resource can also be of type false; however, parameter $handle of stream_get_contents() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

589
        return response(stream_get_contents(/** @scrutinizer ignore-type */ $resource))
Loading history...
590
            ->withHeader('Content-Type', 'text/csv; charset=utf-8')
591
            ->withHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
592
    }
593
594
    /**
595
     * @param string $filename
596
     * @param array  $rows
597
     * @param int    $maxlevel
598
     *
599
     * @return ResponseInterface
600
     */
601
    private function exportGeoJSON(string $filename, array $rows, int $maxlevel): ResponseInterface
602
    {
603
        $geojson = [
604
            'type'     => 'FeatureCollection',
605
            'features' => [],
606
        ];
607
        foreach ($rows as $place) {
608
            $fqpn = implode(
609
                Gedcom::PLACE_SEPARATOR,
610
                array_reverse(
611
                    array_filter(
612
                        array_slice($place, 1, $maxlevel + 1)
613
                    )
614
                )
615
            );
616
617
            $geojson['features'][] = [
618
                'type'       => 'Feature',
619
                'geometry'   => [
620
                    'type'        => 'Point',
621
                    'coordinates' => [
622
                        $this->gedcom_service->readLongitude($place['pl_long'] ?? ''),
623
                        $this->gedcom_service->readLatitude($place['pl_lati'] ?? ''),
624
                    ],
625
                ],
626
                'properties' => [
627
                    'name' => $fqpn,
628
                ],
629
            ];
630
        }
631
632
        $filename = addcslashes($filename, '"');
633
634
        return response($geojson)
635
            ->withHeader('Content-Type', 'application/vnd.geo+json')
636
            ->withHeader('Content-Disposition', 'attachment; filename="' . $filename . '"');
637
    }
638
639
    /**
640
     * @param ServerRequestInterface $request
641
     *
642
     * @return ResponseInterface
643
     */
644
    public function importLocations(ServerRequestInterface $request): ResponseInterface
645
    {
646
        $data_filesystem = $request->getAttribute('filesystem.data');
647
        assert($data_filesystem instanceof FilesystemInterface);
648
649
        $data_filesystem_name = $request->getAttribute('filesystem.data.name');
650
        assert(is_string($data_filesystem_name));
651
652
        $parent_id = (int) $request->getQueryParams()['parent_id'];
653
654
        $files = Collection::make($data_filesystem->listContents('places'))
655
            ->filter(static function (array $metadata): bool {
656
                $extension = strtolower($metadata['extension'] ?? '');
657
658
                return $extension === 'csv' || $extension === 'geojson';
659
            })
660
            ->map(static function (array $metadata): string {
661
                return $metadata['basename'];
662
            })
663
            ->sort();
664
665
        return $this->viewResponse('admin/map-import-form', [
666
            'place_folder' => $data_filesystem_name . self::PLACES_FOLDER,
667
            'title'        => I18N::translate('Import geographic data'),
668
            'parent_id'    => $parent_id,
669
            'files'        => $files,
670
        ]);
671
    }
672
673
    /**
674
     * This function assumes the input file layout is
675
     * level followed by a variable number of placename fields
676
     * followed by Longitude, Latitude, Zoom & Icon
677
     *
678
     * @param ServerRequestInterface $request
679
     *
680
     * @return ResponseInterface
681
     * @throws Exception
682
     */
683
    public function importLocationsAction(ServerRequestInterface $request): ResponseInterface
684
    {
685
        $data_filesystem = $request->getAttribute('filesystem.data');
686
        assert($data_filesystem instanceof FilesystemInterface);
687
688
        $params = (array) $request->getParsedBody();
689
690
        $serverfile     = $params['serverfile'] ?? '';
691
        $options        = $params['import-options'] ?? '';
692
        $clear_database = (bool) ($params['cleardatabase'] ?? false);
693
        $local_file     = $request->getUploadedFiles()['localfile'] ?? null;
694
695
        $places      = [];
696
        $field_names = [
697
            'pl_level',
698
            'pl_long',
699
            'pl_lati',
700
            'pl_zoom',
701
            'pl_icon',
702
            'fqpn',
703
        ];
704
705
        $url = route('map-data', ['parent_id' => 0]);
706
707
        $fp = false;
708
709
        if ($serverfile !== '' && $data_filesystem->has(self::PLACES_FOLDER . $serverfile)) {
710
            // first choice is file on server
711
            $fp = $data_filesystem->readStream(self::PLACES_FOLDER . $serverfile);
712
        } elseif ($local_file instanceof UploadedFileInterface && $local_file->getError() === UPLOAD_ERR_OK) {
713
            // 2nd choice is local file
714
            $fp = $local_file->getStream()->detach();
715
        }
716
717
        if ($fp === false) {
718
            return redirect($url);
719
        }
720
721
        $string = stream_get_contents($fp);
722
723
        // Check the file type
724
        if (stripos($string, 'FeatureCollection') !== false) {
725
            $input_array = json_decode($string, false);
726
727
            foreach ($input_array->features as $feature) {
728
                $places[] = array_combine($field_names, [
729
                    $feature->properties->level ?? substr_count($feature->properties->name, ','),
730
                    $this->gedcom_service->writeLongitude($feature->geometry->coordinates[0]),
731
                    $this->gedcom_service->writeLatitude($feature->geometry->coordinates[1]),
732
                    $feature->properties->zoom ?? null,
733
                    $feature->properties->icon ?? null,
734
                    $feature->properties->name,
735
                ]);
736
            }
737
        } else {
738
            rewind($fp);
739
            while (($row = fgetcsv($fp, 0, ';')) !== false) {
740
                // Skip the header
741
                if (!is_numeric($row[0])) {
742
                    continue;
743
                }
744
745
                $level = (int) $row[0];
746
                $count = count($row);
747
748
                // convert separate place fields into a comma separated placename
749
                $fqdn = implode(Gedcom::PLACE_SEPARATOR, array_reverse(array_slice($row, 1, 1 + $level)));
750
751
                $places[] = [
752
                    'pl_level' => $level,
753
                    'pl_long'  => $row[$count - 4],
754
                    'pl_lati'  => $row[$count - 3],
755
                    'pl_zoom'  => $row[$count - 2],
756
                    'pl_icon'  => $row[$count - 1],
757
                    'fqpn'     => $fqdn,
758
                ];
759
            }
760
        }
761
762
        fclose($fp);
763
764
        if ($clear_database) {
765
            DB::table('placelocation')->delete();
766
        }
767
768
        $added   = 0;
769
        $updated = 0;
770
771
        foreach ($places as $place) {
772
            $location = new Location($place['fqpn']);
773
            $exists   = $location->exists();
774
775
            // Only update existing records
776
            if ($options === 'update' && !$exists) {
777
                continue;
778
            }
779
780
            // Only add new records
781
            if ($options === 'add' && $exists) {
782
                continue;
783
            }
784
785
            if (!$exists) {
786
                $added++;
787
            }
788
789
            $updated += DB::table('placelocation')
790
                ->where('pl_id', '=', $location->id())
791
                ->update([
792
                    'pl_lati' => $place['pl_lati'],
793
                    'pl_long' => $place['pl_long'],
794
                    'pl_zoom' => $place['pl_zoom'] ?: null,
795
                    'pl_icon' => $place['pl_icon'] ?: null,
796
                ]);
797
        }
798
        FlashMessages::addMessage(
799
            I18N::translate('locations updated: %s, locations added: %s', I18N::number($updated), I18N::number($added)),
800
            'info'
801
        );
802
803
        return redirect($url);
804
    }
805
806
    /**
807
     * @param ServerRequestInterface $request
808
     *
809
     * @return ResponseInterface
810
     */
811
    public function importLocationsFromTree(ServerRequestInterface $request): ResponseInterface
812
    {
813
        $params = (array) $request->getParsedBody();
814
815
        $ged  = $params['ged'] ?? '';
816
        $tree = $this->tree_service->all()->get($ged);
817
        assert($tree instanceof Tree);
818
819
        // Get all the places from the places table ...
820
        $places = DB::table('places AS p0')
821
            ->leftJoin('places AS p1', 'p1.p_id', '=', 'p0.p_parent_id')
822
            ->leftJoin('places AS p2', 'p2.p_id', '=', 'p1.p_parent_id')
823
            ->leftJoin('places AS p3', 'p3.p_id', '=', 'p2.p_parent_id')
824
            ->leftJoin('places AS p4', 'p4.p_id', '=', 'p3.p_parent_id')
825
            ->leftJoin('places AS p5', 'p5.p_id', '=', 'p4.p_parent_id')
826
            ->leftJoin('places AS p6', 'p6.p_id', '=', 'p5.p_parent_id')
827
            ->leftJoin('places AS p7', 'p7.p_id', '=', 'p6.p_parent_id')
828
            ->leftJoin('places AS p8', 'p8.p_id', '=', 'p7.p_parent_id')
829
            ->where('p0.p_file', '=', $tree->id())
830
            ->select([
831
                'p0.p_place AS place0',
832
                'p1.p_place AS place1',
833
                'p2.p_place AS place2',
834
                'p3.p_place AS place3',
835
                'p4.p_place AS place4',
836
                'p5.p_place AS place5',
837
                'p6.p_place AS place6',
838
                'p7.p_place AS place7',
839
                'p8.p_place AS place8',
840
            ])
841
            ->get()
842
            ->map(static function (stdClass $row): string {
843
                return implode(', ', array_filter((array) $row));
844
            });
845
846
        // ... and the placelocation table
847
        $locations = DB::table('placelocation AS p0')
848
            ->leftJoin('placelocation AS p1', 'p1.pl_id', '=', 'p0.pl_parent_id')
849
            ->leftJoin('placelocation AS p2', 'p2.pl_id', '=', 'p1.pl_parent_id')
850
            ->leftJoin('placelocation AS p3', 'p3.pl_id', '=', 'p2.pl_parent_id')
851
            ->leftJoin('placelocation AS p4', 'p4.pl_id', '=', 'p3.pl_parent_id')
852
            ->leftJoin('placelocation AS p5', 'p5.pl_id', '=', 'p4.pl_parent_id')
853
            ->leftJoin('placelocation AS p6', 'p6.pl_id', '=', 'p5.pl_parent_id')
854
            ->leftJoin('placelocation AS p7', 'p7.pl_id', '=', 'p6.pl_parent_id')
855
            ->leftJoin('placelocation AS p8', 'p8.pl_id', '=', 'p7.pl_parent_id')
856
            ->select([
857
                'p0.pl_id',
858
                'p0.pl_place AS place0',
859
                'p1.pl_place AS place1',
860
                'p2.pl_place AS place2',
861
                'p3.pl_place AS place3',
862
                'p4.pl_place AS place4',
863
                'p5.pl_place AS place5',
864
                'p6.pl_place AS place6',
865
                'p7.pl_place AS place7',
866
                'p8.pl_place AS place8',
867
            ])
868
            ->get()
869
            ->map(static function (stdClass $row): stdClass {
870
                $row->place = implode(', ', array_filter(array_slice((array) $row, 1)));
871
872
                return $row;
873
            })
874
            ->pluck('place', 'pl_id');
875
876
        // Compare the two ...
877
        $diff = $places->diff($locations);
878
879
        // ... and process the differences
880
        $inserted = 0;
881
        if ($diff->isNotEmpty()) {
882
            $nextRecordId = 1 + (int) DB::table('placelocation')->max('pl_id');
883
884
            foreach ($diff as $place) {
885
                // For Westminster, London, England, we must also create England and London, England
886
                $place_parts = explode(', ', $place);
887
                $count       = count($place_parts);
888
889
                try {
890
                    $parent_id = 0;
891
                    for ($i = $count - 1; $i >= 0; $i--) {
892
                        $parent   = implode(', ', array_slice($place_parts, $i));
893
                        $place_id = $locations->search($parent);
894
895
                        if ($place_id === false) {
896
                            DB::table('placelocation')->insert([
897
                                'pl_id'        => $nextRecordId,
898
                                'pl_parent_id' => $parent_id,
899
                                'pl_level'     => $count - $i,
900
                                'pl_place'     => $place_parts[$i],
901
                            ]);
902
903
                            $parent_id             = $nextRecordId;
904
                            $locations[$parent_id] = $parent;
905
                            $inserted++;
906
                            $nextRecordId++;
907
                        } else {
908
                            $parent_id = $place_id;
909
                        }
910
                    }
911
                } catch (QueryException $ex) {
912
                    // Duplicates are expected due to collation differences.  e.g. Quebec / Québec
913
                }
914
            }
915
        }
916
917
        FlashMessages::addMessage(I18N::plural('%s location has been imported.', '%s locations have been imported.', $inserted, I18N::number($inserted)), 'success');
918
919
        $url = route('map-data');
920
921
        return redirect($url);
922
    }
923
}
924