fisharebest/webtrees

View on GitHub
app/Module/PlaceHierarchyListModule.php

Summary

Maintainability
C
1 day
Test Coverage
<?php

/**
 * webtrees: online genealogy
 * Copyright (C) 2023 webtrees development team
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
 */

declare(strict_types=1);

namespace Fisharebest\Webtrees\Module;

use Fisharebest\Webtrees\Auth;
use Fisharebest\Webtrees\DB;
use Fisharebest\Webtrees\Family;
use Fisharebest\Webtrees\Http\RequestHandlers\MapDataEdit;
use Fisharebest\Webtrees\I18N;
use Fisharebest\Webtrees\Individual;
use Fisharebest\Webtrees\Location;
use Fisharebest\Webtrees\Place;
use Fisharebest\Webtrees\PlaceLocation;
use Fisharebest\Webtrees\Registry;
use Fisharebest\Webtrees\Services\LeafletJsService;
use Fisharebest\Webtrees\Services\ModuleService;
use Fisharebest\Webtrees\Services\SearchService;
use Fisharebest\Webtrees\Tree;
use Fisharebest\Webtrees\Validator;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Query\JoinClause;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;

use function array_chunk;
use function array_pop;
use function array_reverse;
use function ceil;
use function count;
use function redirect;
use function route;
use function view;

/**
 * Class IndividualListModule
 */
class PlaceHierarchyListModule extends AbstractModule implements ModuleListInterface, RequestHandlerInterface
{
    use ModuleListTrait;

    protected const ROUTE_URL = '/tree/{tree}/place-list{/place_id}';

    /** @var int The default access level for this module.  It can be changed in the control panel. */
    protected int $access_level = Auth::PRIV_USER;

    private LeafletJsService $leaflet_js_service;

    private ModuleService $module_service;

    private SearchService $search_service;

    /**
     * @param LeafletJsService $leaflet_js_service
     * @param ModuleService    $module_service
     * @param SearchService    $search_service
     */
    public function __construct(LeafletJsService $leaflet_js_service, ModuleService $module_service, SearchService $search_service)
    {
        $this->leaflet_js_service = $leaflet_js_service;
        $this->module_service     = $module_service;
        $this->search_service     = $search_service;
    }

    /**
     * Initialization.
     *
     * @return void
     */
    public function boot(): void
    {
        Registry::routeFactory()->routeMap()
            ->get(static::class, static::ROUTE_URL, $this);
    }

    /**
     * How should this module be identified in the control panel, etc.?
     *
     * @return string
     */
    public function title(): string
    {
        /* I18N: Name of a module/list */
        return I18N::translate('Place hierarchy');
    }

    /**
     * A sentence describing what this module does.
     *
     * @return string
     */
    public function description(): string
    {
        /* I18N: Description of the “Place hierarchy” module */
        return I18N::translate('The place hierarchy.');
    }

    /**
     * CSS class for the URL.
     *
     * @return string
     */
    public function listMenuClass(): string
    {
        return 'menu-list-plac';
    }

    /**
     * @return array<string>
     */
    public function listUrlAttributes(): array
    {
        return [];
    }

    /**
     * @param Tree $tree
     *
     * @return bool
     */
    public function listIsEmpty(Tree $tree): bool
    {
        return !DB::table('places')
            ->where('p_file', '=', $tree->id())
            ->exists();
    }

    /**
     * @param Tree                                      $tree
     * @param array<bool|int|string|array<string>|null> $parameters
     *
     * @return string
     */
    public function listUrl(Tree $tree, array $parameters = []): string
    {
        $parameters['tree'] = $tree->name();

        return route(static::class, $parameters);
    }

    /**
     * @param ServerRequestInterface $request
     *
     * @return ResponseInterface
     */
    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        $tree     = Validator::attributes($request)->tree();
        $user     = Validator::attributes($request)->user();
        $place_id = Validator::attributes($request)->integer('place_id', 0);

        Auth::checkComponentAccess($this, ModuleListInterface::class, $tree, $user);

        $action2  = Validator::queryParams($request)->string('action2', 'hierarchy');
        $place    = Place::find($place_id, $tree);

        // Request for a non-existent place?
        if ($place_id !== $place->id()) {
            return redirect($place->url());
        }

        $map_providers = $this->module_service->findByInterface(ModuleMapProviderInterface::class);

        $content = '';
        $showmap = $map_providers->isNotEmpty();
        $data    = null;

        if ($showmap) {
            $content .= view('modules/place-hierarchy/map', [
                'data'           => $this->mapData($place),
                'leaflet_config' => $this->leaflet_js_service->config(),
            ]);
        }

        switch ($action2) {
            case 'list':
            default:
                $alt_link = I18N::translate('Show place hierarchy');
                $alt_url  = $this->listUrl($tree, ['action2' => 'hierarchy', 'place_id' => $place_id]);
                $content .= view('modules/place-hierarchy/list', ['columns' => $this->getList($tree)]);
                break;
            case 'hierarchy':
            case 'hierarchy-e':
                $alt_link = I18N::translate('Show all places in a list');
                $alt_url  = $this->listUrl($tree, ['action2' => 'list', 'place_id' => 0]);
                $data     = $this->getHierarchy($place);
                $content .= ($data === null || $showmap) ? '' : view('place-hierarchy', $data);
                if ($data === null || $action2 === 'hierarchy-e') {
                    $content .= view('modules/place-hierarchy/events', [
                        'indilist' => $this->search_service->searchIndividualsInPlace($place),
                        'famlist'  => $this->search_service->searchFamiliesInPlace($place),
                        'tree'     => $place->tree(),
                    ]);
                }
        }

        if ($data !== null && $action2 !== 'hierarchy-e' && $place->gedcomName() !== '') {
            $events_link = $this->listUrl($tree, ['action2' => 'hierarchy-e', 'place_id' => $place_id]);
        } else {
            $events_link = '';
        }

        $breadcrumbs = $this->breadcrumbs($place);

        return $this->viewResponse('modules/place-hierarchy/page', [
            'alt_link'    => $alt_link,
            'alt_url'     => $alt_url,
            'breadcrumbs' => $breadcrumbs['breadcrumbs'],
            'content'     => $content,
            'current'     => $breadcrumbs['current'],
            'events_link' => $events_link,
            'place'       => $place,
            'title'       => I18N::translate('Place hierarchy'),
            'tree'        => $tree,
            'world_url'   => $this->listUrl($tree),
        ]);
    }

    /**
     * @param Place $place
     *
     * @return array<mixed>
     */
    protected function mapData(Place $place): array
    {
        $children  = $place->getChildPlaces();
        $features  = [];
        $sidebar   = '';
        $show_link = true;

        // No children?  Show ourself on the map instead.
        if ($children === []) {
            $children[] = $place;
            $show_link  = false;
        }

        foreach ($children as $id => $child) {
            $location = new PlaceLocation($child->gedcomName());

            if (Auth::isAdmin()) {
                $this_url = route(self::class, ['tree' => $child->tree()->name(), 'place_id' => $place->id()]);
                $edit_url = route(MapDataEdit::class, ['location_id' => $location->id(), 'url' => $this_url]);
            } else {
                $edit_url = '';
            }

            if ($location->latitude() === null || $location->longitude() === null) {
                $sidebar_class = 'unmapped';
            } else {
                $sidebar_class = 'mapped';
                $features[]    = [
                    'type'       => 'Feature',
                    'id'         => $id,
                    'geometry'   => [
                        'type'        => 'Point',
                        'coordinates' => [$location->longitude(), $location->latitude()],
                    ],
                    'properties' => [
                        'tooltip' => $child->gedcomName(),
                        'popup'   => view('modules/place-hierarchy/popup', [
                            'edit_url'  => $edit_url,
                            'place'     => $child,
                            'latitude'  => $location->latitude(),
                            'longitude' => $location->longitude(),
                            'showlink'  => $show_link,
                        ]),
                    ],
                ];
            }

            $stats = [
                Family::RECORD_TYPE     => $this->familyPlaceLinks($child)->count(),
                Individual::RECORD_TYPE => $this->individualPlaceLinks($child)->count(),
                Location::RECORD_TYPE   => $this->locationPlaceLinks($child)->count(),
            ];

            $sidebar .= view('modules/place-hierarchy/sidebar', [
                'edit_url'      => $edit_url,
                'id'            => $id,
                'place'         => $child,
                'showlink'      => $show_link,
                'sidebar_class' => $sidebar_class,
                'stats'         => $stats,
            ]);
        }

        return [
            'bounds'  => (new PlaceLocation($place->gedcomName()))->boundingRectangle(),
            'sidebar' => $sidebar,
            'markers' => [
                'type'     => 'FeatureCollection',
                'features' => $features,
            ],
        ];
    }

    /**
     * @param Tree $tree
     *
     * @return array<array<Place>>
     */
    private function getList(Tree $tree): array
    {
        $places = $this->search_service->searchPlaces($tree, '')
            ->sort(static fn (Place $x, Place $y): int => I18N::comparator()($x->gedcomName(), $y->gedcomName()))
            ->all();

        $count = count($places);

        if ($places === []) {
            return [];
        }

        $columns = $count > 20 ? 3 : 2;

        return array_chunk($places, (int) ceil($count / $columns));
    }

    /**
     * @param Place $place
     *
     * @return array{columns:array<array<Place>>,place:Place,tree:Tree,col_class:string}|null
     */
    private function getHierarchy(Place $place): array|null
    {
        $child_places = $place->getChildPlaces();
        $numfound     = count($child_places);

        if ($numfound > 0) {
            $divisor = $numfound > 20 ? 3 : 2;

            return [
                'tree'      => $place->tree(),
                'col_class' => 'w-' . ($divisor === 2 ? '25' : '50'),
                'columns'   => array_chunk($child_places, (int) ceil($numfound / $divisor)),
                'place'     => $place,
            ];
        }

        return null;
    }

    /**
     * @param Place $place
     *
     * @return array{breadcrumbs:array<Place>,current:Place|null}
     */
    private function breadcrumbs(Place $place): array
    {
        $breadcrumbs = [];
        if ($place->gedcomName() !== '') {
            $breadcrumbs[] = $place;
            $parent_place  = $place->parent();
            while ($parent_place->gedcomName() !== '') {
                $breadcrumbs[] = $parent_place;
                $parent_place  = $parent_place->parent();
            }
            $breadcrumbs = array_reverse($breadcrumbs);
            $current     = array_pop($breadcrumbs);
        } else {
            $current = null;
        }

        return [
            'breadcrumbs' => $breadcrumbs,
            'current'     => $current,
        ];
    }

    /**
     * @param Place $place
     *
     * @return Builder
     */
    private function placeLinks(Place $place): Builder
    {
        return DB::table('places')
            ->join('placelinks', static function (JoinClause $join): void {
                $join
                    ->on('pl_file', '=', 'p_file')
                    ->on('pl_p_id', '=', 'p_id');
            })
            ->where('p_file', '=', $place->tree()->id())
            ->where('p_id', '=', $place->id());
    }

    /**
     * @param Place $place
     *
     * @return Builder
     */
    private function familyPlaceLinks(Place $place): Builder
    {
        return $this->placeLinks($place)
            ->join('families', static function (JoinClause $join): void {
                $join
                    ->on('pl_file', '=', 'f_file')
                    ->on('pl_gid', '=', 'f_id');
            });
    }

    /**
     * @param Place $place
     *
     * @return Builder
     */
    private function individualPlaceLinks(Place $place): Builder
    {
        return $this->placeLinks($place)
            ->join('individuals', static function (JoinClause $join): void {
                $join
                    ->on('pl_file', '=', 'i_file')
                    ->on('pl_gid', '=', 'i_id');
            });
    }

    /**
     * @param Place $place
     *
     * @return Builder
     */
    private function locationPlaceLinks(Place $place): Builder
    {
        return $this->placeLinks($place)
            ->join('other', static function (JoinClause $join): void {
                $join
                    ->on('pl_file', '=', 'o_file')
                    ->on('pl_gid', '=', 'o_id');
            })
            ->where('o_type', '=', Location::RECORD_TYPE);
    }
}