fisharebest/webtrees

View on GitHub
app/Relationship.php

Summary

Maintainability
D
2 days
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;

use Closure;
use Fisharebest\Webtrees\Elements\PedigreeLinkageType;

use function abs;
use function array_slice;
use function count;
use function in_array;
use function intdiv;
use function min;

/**
 * Class Relationship - define a relationship for a language.
 */
class Relationship
{
    // The basic components of a relationship.
    // These strings are needed for compatibility with the legacy algorithm.
    // Once that has been replaced, it may be more efficient to use integers here.
    public const SISTER   = 'sis';
    public const BROTHER  = 'bro';
    public const SIBLING  = 'sib';
    public const MOTHER   = 'mot';
    public const FATHER   = 'fat';
    public const PARENT   = 'par';
    public const DAUGHTER = 'dau';
    public const SON      = 'son';
    public const CHILD    = 'chi';
    public const WIFE     = 'wif';
    public const HUSBAND  = 'hus';
    public const SPOUSE   = 'spo';

    public const SIBLINGS = ['F' => self::SISTER, 'M' => self::BROTHER, 'U' => self::SIBLING];
    public const PARENTS  = ['F' => self::MOTHER, 'M' => self::FATHER, 'U' => self::PARENT];
    public const CHILDREN = ['F' => self::DAUGHTER, 'M' => self::SON, 'U' => self::CHILD];
    public const SPOUSES  = ['F' => self::WIFE, 'M' => self::HUSBAND, 'U' => self::SPOUSE];

    // Generates a name from the matched relationship.
    private Closure $callback;

    /** @var array<Closure> List of rules that need to match */
    private array $matchers;

    /**
     * @param Closure $callback
     */
    private function __construct(Closure $callback)
    {
        $this->callback = $callback;
        $this->matchers = [];
    }

    /**
     * Allow fluent constructor.
     *
     * @param string $nominative
     * @param string $genitive
     *
     * @return Relationship
     */
    public static function fixed(string $nominative, string $genitive): Relationship
    {
        return new self(fn () => [$nominative, $genitive]);
    }

    /**
     * Allow fluent constructor.
     *
     * @param Closure $callback
     *
     * @return Relationship
     */
    public static function dynamic(Closure $callback): Relationship
    {
        return new self($callback);
    }

    /**
     * Does this relationship match the pattern?
     *
     * @param array<Individual|Family> $nodes
     * @param array<string>            $patterns
     *
     * @return array<string>|null [nominative, genitive] or null
     */
    public function match(array $nodes, array $patterns): array|null
    {
        $captures = [];

        foreach ($this->matchers as $matcher) {
            if (!$matcher($nodes, $patterns, $captures)) {
                return null;
            }
        }

        if ($patterns === []) {
            return ($this->callback)(...$captures);
        }

        return null;
    }

    /**
     * @return Relationship
     */
    public function adopted(): Relationship
    {
        $this->matchers[] = static fn (array $nodes): bool => count($nodes) > 2 && $nodes[2]
                ->facts(['FAMC'], false, Auth::PRIV_HIDE)
                ->contains(fn (Fact $fact): bool => $fact->value() === '@' . $nodes[1]->xref() . '@' && $fact->attribute('PEDI') === PedigreeLinkageType::VALUE_ADOPTED);

        return $this;
    }

    /**
     * @return Relationship
     */
    public function adoptive(): Relationship
    {
        $this->matchers[] = static fn (array $nodes): bool => $nodes[0]
            ->facts(['FAMC'], false, Auth::PRIV_HIDE)
            ->contains(fn (Fact $fact): bool => $fact->value() === '@' . $nodes[1]->xref() . '@' && $fact->attribute('PEDI') === PedigreeLinkageType::VALUE_ADOPTED);

        return $this;
    }

    /**
     * @return Relationship
     */
    public function brother(): Relationship
    {
        return $this->relation([self::BROTHER]);
    }

    /**
     * Match the next relationship in the path.
     *
     * @param array<string> $relationships
     *
     * @return Relationship
     */
    protected function relation(array $relationships): Relationship
    {
        $this->matchers[] = static function (array &$nodes, array &$patterns) use ($relationships): bool {
            if (in_array($patterns[0] ?? '', $relationships, true)) {
                $nodes    = array_slice($nodes, 2);
                $patterns = array_slice($patterns, 1);

                return true;
            }

            return false;
        };

        return $this;
    }

    /**
     * The number of ancestors may be different to the number of descendants
     *
     * @return Relationship
     */
    public function cousin(): Relationship
    {
        return $this->ancestor()->sibling()->descendant();
    }

    /**
     * @return Relationship
     */
    public function descendant(): Relationship
    {
        return $this->repeatedRelationship(self::CHILDREN);
    }

    /**
     * Match a repeated number of the same type of component
     *
     * @param array<string> $relationships
     *
     * @return Relationship
     */
    protected function repeatedRelationship(array $relationships): Relationship
    {
        $this->matchers[] = static function (array &$nodes, array &$patterns, array &$captures) use ($relationships): bool {
            $limit = min(intdiv(count($nodes), 2), count($patterns));

            for ($generations = 0; $generations < $limit; ++$generations) {
                if (!in_array($patterns[$generations], $relationships, true)) {
                    break;
                }
            }

            if ($generations > 0) {
                $nodes      = array_slice($nodes, 2 * $generations);
                $patterns   = array_slice($patterns, $generations);
                $captures[] = $generations;

                return true;
            }

            return false;
        };

        return $this;
    }

    /**
     * @return Relationship
     */
    public function sibling(): Relationship
    {
        return $this->relation(self::SIBLINGS);
    }

    /**
     * @return Relationship
     */
    public function ancestor(): Relationship
    {
        return $this->repeatedRelationship(self::PARENTS);
    }

    /**
     * @return Relationship
     */
    public function child(): Relationship
    {
        return $this->relation(self::CHILDREN);
    }

    /**
     * @return Relationship
     */
    public function daughter(): Relationship
    {
        return $this->relation([self::DAUGHTER]);
    }

    /**
     * @return Relationship
     */
    public function divorced(): Relationship
    {
        return $this->marriageStatus('DIV');
    }

    /**
     * Match a marriage status
     *
     * @param string $status
     *
     * @return Relationship
     */
    protected function marriageStatus(string $status): Relationship
    {
        $this->matchers[] = static function (array $nodes) use ($status): bool {
            $family = $nodes[1] ?? null;

            if ($family instanceof Family) {
                $fact = $family->facts(['ENGA', 'MARR', 'DIV', 'ANUL'], true, Auth::PRIV_HIDE)->last();

                if ($fact instanceof Fact) {
                    switch ($status) {
                        case 'MARR':
                            return $fact->tag() === 'FAM:MARR';

                        case 'DIV':
                            return $fact->tag() === 'FAM:DIV' || $fact->tag() === 'FAM:ANUL';

                        case 'ENGA':
                            return $fact->tag() === 'FAM:ENGA';
                    }
                }
            }

            return false;
        };

        return $this;
    }

    /**
     * @return Relationship
     */
    public function engaged(): Relationship
    {
        return $this->marriageStatus('ENGA');
    }

    /**
     * @return Relationship
     */
    public function father(): Relationship
    {
        return $this->relation([self::FATHER]);
    }

    /**
     * @return Relationship
     */
    public function female(): Relationship
    {
        return $this->sex('F');
    }

    /**
     * Match the sex of the current individual
     *
     * @param string $sex
     *
     * @return Relationship
     */
    protected function sex(string $sex): Relationship
    {
        $this->matchers[] = static fn (array $nodes): bool => $nodes[0]->sex() === $sex;

        return $this;
    }

    /**
     * @return Relationship
     */
    public function fostered(): Relationship
    {
        $this->matchers[] = static fn (array $nodes): bool => count($nodes) > 2 && $nodes[2]
                ->facts(['FAMC'], false, Auth::PRIV_HIDE)
                ->contains(fn (Fact $fact): bool => $fact->value() === '@' . $nodes[1]->xref() . '@' && $fact->attribute('PEDI') === PedigreeLinkageType::VALUE_FOSTER);

        return $this;
    }

    /**
     * @return Relationship
     */
    public function fostering(): Relationship
    {
        $this->matchers[] = static fn (array $nodes): bool => $nodes[0]
            ->facts(['FAMC'], false, Auth::PRIV_HIDE)
            ->contains(fn (Fact $fact): bool => $fact->value() === '@' . $nodes[1]->xref() . '@' && $fact->attribute('PEDI') === PedigreeLinkageType::VALUE_FOSTER);

        return $this;
    }

    /**
     * @return Relationship
     */
    public function husband(): Relationship
    {
        return $this->married()->relation([self::HUSBAND]);
    }

    /**
     * @return Relationship
     */
    public function married(): Relationship
    {
        return $this->marriageStatus('MARR');
    }

    /**
     * @return Relationship
     */
    public function male(): Relationship
    {
        return $this->sex('M');
    }

    /**
     * @return Relationship
     */
    public function mother(): Relationship
    {
        return $this->relation([self::MOTHER]);
    }

    /**
     * @return Relationship
     */
    public function older(): Relationship
    {
        $this->matchers[] = static function (array $nodes): bool {
            $date1 = $nodes[0]->facts(['BIRT'], false, Auth::PRIV_HIDE)->map(fn (Fact $fact): Date => $fact->date())->first() ?? new Date('');
            $date2 = $nodes[2]->facts(['BIRT'], false, Auth::PRIV_HIDE)->map(fn (Fact $fact): Date => $fact->date())->first() ?? new Date('');

            return Date::compare($date1, $date2) > 0;
        };

        return $this;
    }

    /**
     * @return Relationship
     */
    public function parent(): Relationship
    {
        return $this->relation(self::PARENTS);
    }

    /**
     * @return Relationship
     */
    public function sister(): Relationship
    {
        return $this->relation([self::SISTER]);
    }

    /**
     * @return Relationship
     */
    public function son(): Relationship
    {
        return $this->relation([self::SON]);
    }

    /**
     * @return Relationship
     */
    public function spouse(): Relationship
    {
        return $this->married()->partner();
    }

    /**
     * @return Relationship
     */
    public function partner(): Relationship
    {
        return $this->relation(self::SPOUSES);
    }

    /**
     * The number of ancestors must be the same as the number of descendants
     *
     * @return Relationship
     */
    public function symmetricCousin(): Relationship
    {
        $this->matchers[] = static function (array &$nodes, array &$patterns, array &$captures): bool {
            $count = count($patterns);

            $n = 0;

            // Ancestors
            while ($n < $count && in_array($patterns[$n], Relationship::PARENTS, true)) {
                $n++;
            }

            // No ancestors?  Not enough path left for descendants?
            if ($n === 0 || $n * 2 + 1 !== $count) {
                return false;
            }

            // Siblings
            if (!in_array($patterns[$n], Relationship::SIBLINGS, true)) {
                return false;
            }

            // Descendants
            for ($descendants = $n + 1; $descendants < $count; ++$descendants) {
                if (!in_array($patterns[$descendants], Relationship::CHILDREN, true)) {
                    return false;
                }
            }

            $nodes      = array_slice($nodes, 2 * (2 * $n + 1));
            $patterns   = [];
            $captures[] = $n;

            return true;
        };

        return $this;
    }

    /**
     * @return Relationship
     */
    public function twin(): Relationship
    {
        $this->matchers[] = static function (array $nodes): bool {
            $date1 = $nodes[0]->facts(['BIRT'], false, Auth::PRIV_HIDE)->map(fn (Fact $fact): Date => $fact->date())->first() ?? new Date('');
            $date2 = $nodes[2]->facts(['BIRT'], false, Auth::PRIV_HIDE)->map(fn (Fact $fact): Date => $fact->date())->first() ?? new Date('');

            return
                $date1->isOK() &&
                $date2->isOK() &&
                abs($date1->julianDay() - $date2->julianDay()) < 2 &&
                $date1->minimumDate()->day > 0 &&
                $date2->minimumDate()->day > 0;
        };

        return $this;
    }

    /**
     * @return Relationship
     */
    public function wife(): Relationship
    {
        return $this->married()->relation([self::WIFE]);
    }

    /**
     * @return Relationship
     */
    public function younger(): Relationship
    {
        $this->matchers[] = static function (array $nodes): bool {
            $date1 = $nodes[0]->facts(['BIRT'], false, Auth::PRIV_HIDE)->map(fn (Fact $fact): Date => $fact->date())->first() ?? new Date('');
            $date2 = $nodes[2]->facts(['BIRT'], false, Auth::PRIV_HIDE)->map(fn (Fact $fact): Date => $fact->date())->first() ?? new Date('');

            return Date::compare($date1, $date2) < 0;
        };

        return $this;
    }
}