mimmi20/mezzio-navigation

View on GitHub
src/Page/PageTrait.php

Summary

Maintainability
A
35 mins
Test Coverage
A
100%
<?php
/**
 * This file is part of the mimmi20/mezzio-navigation package.
 *
 * Copyright (c) 2020-2024, Thomas Mueller <mimmi20@live.de>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

declare(strict_types = 1);

namespace Mimmi20\Mezzio\Navigation\Page;

use Mimmi20\Mezzio\Navigation\ContainerInterface;
use Mimmi20\Mezzio\Navigation\ContainerTrait;
use Mimmi20\Mezzio\Navigation\Exception;

use function array_keys;
use function array_merge;
use function is_int;
use function is_string;
use function mb_strtolower;
use function method_exists;
use function spl_object_hash;
use function sprintf;
use function str_replace;
use function ucwords;

/**
 * Base class for Mimmi20\Mezzio\Navigation\Page pages
 */
trait PageTrait
{
    use ContainerTrait {
        toArray as parentToArray;
    }

    /**
     * Page label
     */
    private string | null $label = null;

    /**
     * Fragment identifier (anchor identifier)
     *
     * The fragment identifier (anchor identifier) pointing to an anchor within
     * a resource that is subordinate to another, primary resource.
     * The fragment identifier introduced by a hash mark "#".
     * Example: http://www.example.org/foo.html#bar ("bar" is the fragment identifier)
     *
     * @see http://www.w3.org/TR/html401/intro/intro.html#fragment-uri
     */
    private string | null $fragment = null;

    /**
     * Page id
     */
    private string | null $id = null;

    /**
     * Style class for this page (CSS)
     */
    private string | null $class = null;

    /**
     * Style class for the container around this page (CSS)
     */
    private string | null $liClass = null;

    /**
     * A more descriptive title for this page
     */
    private string | null $title = null;

    /**
     * This page's target
     */
    private string | null $target = null;

    /**
     * Forward links to other pages
     *
     * @see http://www.w3.org/TR/html4/struct/links.html#h-12.3.1
     *
     * @var array<ContainerInterface<PageInterface>|iterable<string>|PageInterface|string>
     */
    private array $rel = [];

    /**
     * Reverse links to other pages
     *
     * @see http://www.w3.org/TR/html4/struct/links.html#h-12.3.1
     *
     * @var array<ContainerInterface<PageInterface>|iterable<string>|PageInterface|string>
     */
    private array $rev = [];

    /**
     * Page order used by parent container
     */
    private int | null $order = null;

    /**
     * resource associated with this page
     */
    private string | null $resource = null;

    /**
     * ACL privilege associated with this page
     */
    private string | null $privilege = null;

    /**
     * Permission associated with this page
     */
    private string | null $permission = null;

    /**
     * Text domain for Translator
     */
    private string | null $textDomain = null;

    /**
     * Whether this page should be considered active
     */
    private bool | null $active = null;

    /**
     * Whether this page should be considered visible
     */
    private bool $visible = true;

    /**
     * Parent container
     *
     * @var ContainerInterface<PageInterface>|null
     */
    private ContainerInterface | null $parent = null;

    /**
     * Custom page properties, used by __set(), __get() and __isset()
     *
     * @var array<string, bool|ContainerInterface<PageInterface>|float|int|iterable<string, (array<string>|string)>|PageInterface|string|null>
     */
    private array $properties = [];

    /**
     * @param iterable<string, array<string>|bool|string|null>|null $options [optional] page options. Default is null, which should set defaults.
     *
     * @throws Exception\InvalidArgumentException if invalid options are given
     */
    public function __construct(iterable | null $options = null)
    {
        if ($options === null) {
            return;
        }

        $this->setOptions($options);
    }

    // Magic overloads:

    /**
     * Sets a custom property
     *
     * Magic overload for enabling <code>$page->propname = $value</code>.
     *
     * @param string                                                                                                              $name  property name
     * @param bool|ContainerInterface<PageInterface>|float|int|iterable<string, (array<string>|string)>|PageInterface|string|null $value value to set
     *
     * @throws Exception\InvalidArgumentException if property name is invalid
     */
    public function __set(
        string $name,
        bool | float | int | iterable | ContainerInterface | PageInterface | string | null $value,
    ): void {
        $this->set($name, $value);
    }

    /**
     * Returns a property, or null if it doesn't exist
     *
     * Magic overload for enabling <code>$page->propname</code>.
     *
     * @param string $name property name
     *
     * @return bool|ContainerInterface<PageInterface>|float|int|iterable<string, (array<string>|string)>|PageInterface|string|null property value or null
     *
     * @throws Exception\InvalidArgumentException if property name is invalid
     */
    public function __get(
        string $name,
    ): bool | float | int | iterable | ContainerInterface | PageInterface | string | null {
        return $this->get($name);
    }

    /**
     * Checks if a property is set
     *
     * Magic overload for enabling <code>isset($page->propname)</code>.
     *
     * Returns true if the property is native (id, class, title, etc), and
     * true or false if it's a custom property (depending on whether the
     * property actually is set).
     *
     * @param string $name property name
     *
     * @return bool whether the given property exists
     *
     * @throws void
     */
    public function __isset(string $name): bool
    {
        $method = 'get' . static::normalizePropertyName($name);

        if (method_exists($this, $method)) {
            return true;
        }

        return isset($this->properties[$name]);
    }

    /**
     * Unsets the given custom property
     *
     * Magic overload for enabling <code>unset($page->propname)</code>.
     *
     * @param string $name property name
     *
     * @throws Exception\InvalidArgumentException if the property is native
     */
    public function __unset(string $name): void
    {
        $method = 'set' . static::normalizePropertyName($name);

        if (method_exists($this, $method)) {
            throw new Exception\InvalidArgumentException(
                sprintf(
                    'Unsetting native property "%s" is not allowed',
                    $name,
                ),
            );
        }

        if (!isset($this->properties[$name])) {
            return;
        }

        unset($this->properties[$name]);
    }

    /**
     * Returns page label
     *
     * Magic overload for enabling <code>echo $page</code>.
     *
     * @return string page label
     *
     * @throws void
     */
    public function __toString(): string
    {
        return (string) $this->label;
    }

    /**
     * Sets page properties using options from an associative array
     *
     * Each key in the array corresponds to the according set*() method, and
     * each word is separated by underscores, e.g. the option 'target'
     * corresponds to setTarget(), and the option 'reset_params' corresponds to
     * the method setResetParams().
     *
     * @param iterable<string, array<string>|bool|string|null> $options associative array of options to set
     *
     * @throws Exception\InvalidArgumentException if invalid options are given
     */
    public function setOptions(iterable $options): void
    {
        foreach ($options as $key => $value) {
            $this->set($key, $value);
        }
    }

    // Accessors:

    /**
     * Sets page label
     *
     * @param string|null $label new page label
     *
     * @throws void
     */
    public function setLabel(string | null $label = null): void
    {
        $this->label = $label;
    }

    /**
     * Returns page label
     *
     * @return string|null page label or null
     *
     * @throws void
     */
    public function getLabel(): string | null
    {
        return $this->label;
    }

    /**
     * Sets a fragment identifier
     *
     * @param string|null $fragment new fragment identifier
     *
     * @throws void
     */
    public function setFragment(string | null $fragment = null): void
    {
        $this->fragment = $fragment;
    }

    /**
     * Returns fragment identifier
     *
     * @return string|null fragment identifier
     *
     * @throws void
     */
    public function getFragment(): string | null
    {
        return $this->fragment;
    }

    /**
     * Sets page id
     *
     * @param string|null $id [optional] id to set. Default is null, which sets no id.
     *
     * @throws void
     */
    public function setId(string | null $id = null): void
    {
        $this->id = $id;
    }

    /**
     * Returns page id
     *
     * @return string|null page id or null
     *
     * @throws void
     */
    public function getId(): string | null
    {
        return $this->id;
    }

    /**
     * Sets page CSS class
     *
     * @param string|null $class [optional] CSS class to set. Default is null, which sets no CSS class.
     *
     * @throws void
     */
    public function setClass(string | null $class = null): void
    {
        $this->class = $class;
    }

    /**
     * Returns page class (CSS)
     *
     * @return string|null page's CSS class or null
     *
     * @throws void
     */
    public function getClass(): string | null
    {
        return $this->class;
    }

    /**
     * Sets page CSS class
     *
     * @param string|null $liClass [optional] CSS class to set. Default is null, which sets no CSS class.
     *
     * @throws void
     */
    public function setLiClass(string | null $liClass = null): void
    {
        $this->liClass = $liClass;
    }

    /**
     * Returns page class (CSS)
     *
     * @return string|null page's CSS class or null
     *
     * @throws void
     */
    public function getLiClass(): string | null
    {
        return $this->liClass;
    }

    /**
     * Sets page title
     *
     * @param string|null $title [optional] page title. Default is
     *                           null, which sets no title.
     *
     * @throws void
     */
    public function setTitle(string | null $title = null): void
    {
        $this->title = $title;
    }

    /**
     * Returns page title
     *
     * @return string|null page title or null
     *
     * @throws void
     */
    public function getTitle(): string | null
    {
        return $this->title;
    }

    /**
     * Sets page target
     *
     * @param string|null $target [optional] target to set. Default is
     *                            null, which sets no target.
     *
     * @throws void
     */
    public function setTarget(string | null $target = null): void
    {
        $this->target = $target;
    }

    /**
     * Returns page target
     *
     * @return string|null page target or null
     *
     * @throws void
     */
    public function getTarget(): string | null
    {
        return $this->target;
    }

    /**
     * Sets the page's forward links to other pages
     *
     * This method expects an associative array of forward links to other pages,
     * where each element's key is the name of the relation (e.g. alternate,
     * prev, next, help, etc), and the value is a mixed value that could somehow
     * be considered a page.
     *
     * @param iterable<ContainerInterface<PageInterface>|iterable<string>|PageInterface|string>|null $relations [optional] an associative array of forward links to other pages
     *
     * @throws void
     */
    public function setRel(iterable | null $relations = null): void
    {
        $this->rel = [];

        if ($relations === null) {
            return;
        }

        foreach ($relations as $name => $relation) {
            if (!is_string($name)) {
                continue;
            }

            $this->rel[$name] = $relation;
        }
    }

    /**
     * Returns the page's forward links to other pages
     *
     * This method returns an associative array of forward links to other pages,
     * where each element's key is the name of the relation (e.g. alternate,
     * prev, next, help, etc), and the value is a mixed value that could somehow
     * be considered a page.
     *
     * @param string|null $relation [optional] name of relation to return. If not
     *                              given, all relations will be returned.
     *
     * @return ContainerInterface<PageInterface>|iterable<ContainerInterface<PageInterface>|iterable<string>|PageInterface|string>|PageInterface|string|null an array of relations. If $relation is not
     *                           specified, all relations will be returned in
     *                           an associative array.
     *
     * @throws void
     */
    public function getRel(
        string | null $relation = null,
    ): iterable | ContainerInterface | PageInterface | string | null {
        if ($relation !== null) {
            return $this->rel[$relation] ?? null;
        }

        return $this->rel;
    }

    /**
     * Sets the page's reverse links to other pages
     *
     * This method expects an associative array of reverse links to other pages,
     * where each element's key is the name of the relation (e.g. alternate,
     * prev, next, help, etc), and the value is a mixed value that could somehow
     * be considered a page.
     *
     * @param iterable<ContainerInterface<PageInterface>|iterable<string>|PageInterface|string>|null $relations [optional] an associative array of reverse links to other pages
     *
     * @throws void
     */
    public function setRev(iterable | null $relations = null): void
    {
        $this->rev = [];

        if ($relations === null) {
            return;
        }

        foreach ($relations as $name => $relation) {
            if (!is_string($name)) {
                continue;
            }

            $this->rev[$name] = $relation;
        }
    }

    /**
     * Returns the page's reverse links to other pages
     *
     * This method returns an associative array of forward links to other pages,
     * where each element's key is the name of the relation (e.g. alternate,
     * prev, next, help, etc), and the value is a mixed value that could somehow
     * be considered a page.
     *
     * @param string|null $relation [optional] name of relation to return. If not
     *                              given, all relations will be returned.
     *
     * @return ContainerInterface<PageInterface>|iterable<ContainerInterface<PageInterface>|iterable<string>|PageInterface|string>|PageInterface|string|null an array of relations. If $relation is not
     *                           specified, all relations will be returned in
     *                           an associative array.
     *
     * @throws void
     */
    public function getRev(
        string | null $relation = null,
    ): iterable | ContainerInterface | PageInterface | string | null {
        if ($relation !== null) {
            return $this->rev[$relation] ?? null;
        }

        return $this->rev;
    }

    /**
     * Sets parent container
     *
     * @param ContainerInterface<PageInterface>|null $parent [optional] new parent to set.
     *                                        Default is null which will set no parent.
     *
     * @throws Exception\InvalidArgumentException
     */
    public function setParent(ContainerInterface | null $parent = null): void
    {
        if ($parent === $this) {
            throw new Exception\InvalidArgumentException('A page cannot have itself as a parent');
        }

        // return if the given parent already is parent
        if ($parent === $this->parent) {
            return;
        }

        // remove from old parent
        if ($this->parent !== null) {
            $this->parent->removePage($this);
        }

        // set new parent
        $this->parent = $parent;

        // add to parent if page and not already a child
        if (!$this->parent instanceof ContainerInterface || $this->parent->hasPage($this, false)) {
            return;
        }

        $this->parent->addPage($this);
    }

    /**
     * Returns parent container
     *
     * @return ContainerInterface<PageInterface>|null parent container or null
     *
     * @throws void
     */
    public function getParent(): ContainerInterface | null
    {
        return $this->parent;
    }

    /**
     * Sets page order to use in parent container
     *
     * @param float|int|string|null $order [optional] page order in container.
     *                               Default is null, which sets no
     *                               specific order.
     *
     * @throws void
     */
    public function setOrder(int | float | string | null $order = null): void
    {
        $this->order = is_int($order) || $order === null ? $order : (int) $order;

        // notify parent, if any
        if (!$this->parent instanceof ContainerInterface) {
            return;
        }

        $this->parent->notifyOrderUpdated();
    }

    /**
     * Returns page order used in parent container
     *
     * @return int|null page order or null
     *
     * @throws void
     */
    public function getOrder(): int | null
    {
        return $this->order;
    }

    /**
     * Sets ACL resource associated with this page
     *
     * @param string $resource [optional] resource to associate
     *                         with page. Default is null, which
     *                         sets no resource.
     *
     * @throws void
     */
    public function setResource(string $resource): void
    {
        $this->resource = $resource;
    }

    /**
     * Returns ACL resource associated with this page
     *
     * @return string|null ACL resource or null
     *
     * @throws void
     */
    public function getResource(): string | null
    {
        return $this->resource;
    }

    /**
     * Sets privilege associated with this page
     *
     * @param string $privilege [optional] ACL privilege to associate
     *                          with this page. Default is null, which
     *                          sets no privilege.
     *
     * @throws void
     */
    public function setPrivilege(string $privilege): void
    {
        $this->privilege = $privilege;
    }

    /**
     * Returns ACL privilege associated with this page
     *
     * @return string|null ACL privilege or null
     *
     * @throws void
     */
    public function getPrivilege(): string | null
    {
        return $this->privilege;
    }

    /**
     * Sets permission associated with this page
     *
     * @param string $permission [optional] permission to associate
     *                           with this page. Default is null, which
     *                           sets no permission.
     *
     * @throws void
     */
    public function setPermission(string $permission): void
    {
        $this->permission = $permission;
    }

    /**
     * Returns permission associated with this page
     *
     * @return string|null permission or null
     *
     * @throws void
     */
    public function getPermission(): string | null
    {
        return $this->permission;
    }

    /**
     * Sets text domain for translation
     *
     * @param string $textDomain [optional] text domain to associate
     *                           with this page. Default is null, which
     *                           sets no text domain.
     *
     * @throws void
     */
    public function setTextDomain(string $textDomain): void
    {
        $this->textDomain = $textDomain;
    }

    /**
     * Returns text domain for translation
     *
     * @return string|null text domain or null
     *
     * @throws void
     */
    public function getTextDomain(): string | null
    {
        return $this->textDomain;
    }

    /**
     * Sets whether page should be considered active or not
     *
     * @param bool|string $active [optional] whether page should be
     *                            considered active or not. Default is true.
     *
     * @throws void
     */
    public function setActive(bool | string $active = true): void
    {
        if (is_string($active) && mb_strtolower($active) === 'false') {
            $active = false;
        }

        $this->active = (bool) $active;
    }

    /**
     * Returns whether page should be considered active or not
     *
     * @param bool $recursive [optional] whether page should be considered
     *                        active if any child pages are active. Default is
     *                        false.
     *
     * @return bool whether page should be considered active
     *
     * @throws void
     */
    public function isActive(bool $recursive = false): bool
    {
        if ($this->active === null && $recursive) {
            foreach ($this->pages as $page) {
                if ($page->isActive(true)) {
                    return true;
                }
            }

            return false;
        }

        return (bool) $this->active;
    }

    /**
     * Proxy to isActive()
     *
     * @param bool $recursive [optional] whether page should be considered
     *                        active if any child pages are active. Default
     *                        is false.
     *
     * @return bool whether page should be considered active
     *
     * @throws void
     */
    public function getActive(bool $recursive = false): bool
    {
        return $this->isActive($recursive);
    }

    /**
     * Sets whether the page should be visible or not
     *
     * @param bool|string $visible [optional] whether page should be
     *                             considered visible or not. Default is true.
     *
     * @throws void
     */
    public function setVisible(bool | string $visible = true): void
    {
        if (is_string($visible) && mb_strtolower($visible) === 'false') {
            $visible = false;
        }

        $this->visible = (bool) $visible;
    }

    /**
     * Returns a boolean value indicating whether the page is visible
     *
     * @param bool $recursive [optional] whether page should be considered
     *                        invisible if parent is invisible. Default is
     *                        false.
     *
     * @return bool whether page should be considered visible
     *
     * @throws void
     */
    public function isVisible(bool $recursive = false): bool
    {
        if (
            $recursive
            && $this->parent instanceof PageInterface
            && !$this->parent->isVisible($recursive)
        ) {
            return false;
        }

        return $this->visible;
    }

    /**
     * Proxy to isVisible()
     *
     * Returns a boolean value indicating whether the page is visible
     *
     * @param bool $recursive [optional] whether page should be considered
     *                        invisible if parent is invisible. Default is
     *                        false.
     *
     * @return bool whether page should be considered visible
     *
     * @throws void
     */
    public function getVisible(bool $recursive = false): bool
    {
        return $this->isVisible($recursive);
    }

    /**
     * Sets the given property
     *
     * If the given property is native (id, class, title, etc), the matching
     * set method will be used. Otherwise, it will be set as a custom property.
     *
     * @param string                                                                                                              $property property name
     * @param bool|ContainerInterface<PageInterface>|float|int|iterable<string, (array<string>|string)>|PageInterface|string|null $value    value to set
     *
     * @throws Exception\InvalidArgumentException if property name is invalid
     */
    public function set(
        string $property,
        bool | float | int | iterable | ContainerInterface | PageInterface | string | null $value,
    ): void {
        if ($property === '') {
            throw new Exception\InvalidArgumentException(
                'Invalid argument: $property must be a non-empty string',
            );
        }

        $method = 'set' . static::normalizePropertyName($property);

        if ($method !== 'setOptions' && method_exists($this, $method)) {
            $this->{$method}($value);
        } else {
            $this->properties[$property] = $value;
        }
    }

    /**
     * Returns the value of the given property
     *
     * If the given property is native (id, class, title, etc), the matching
     * get method will be used. Otherwise, it will return the matching custom
     * property, or null if not found.
     *
     * @param string $property property name
     *
     * @return bool|ContainerInterface<PageInterface>|float|int|iterable<string, (array<string>|string)>|PageInterface|string|null the property's value or null
     *
     * @throws Exception\InvalidArgumentException if property name is invalid
     */
    public function get(
        string $property,
    ): bool | float | int | iterable | ContainerInterface | PageInterface | string | null {
        if ($property === '') {
            throw new Exception\InvalidArgumentException(
                'Invalid argument: $property must be a non-empty string',
            );
        }

        $method = 'get' . static::normalizePropertyName($property);

        if (method_exists($this, $method)) {
            return $this->{$method}();
        }

        return $this->properties[$property] ?? null;
    }

    // Public methods:

    /**
     * Adds a forward relation to the page
     *
     * @param string                                                                  $relation relation name (e.g. alternate, glossary,
     *                                                                                          canonical, etc)
     * @param ContainerInterface<PageInterface>|iterable<string>|PageInterface|string $value    value to set for relation
     *
     * @throws void
     */
    public function addRel(string $relation, iterable | ContainerInterface | PageInterface | string $value): void
    {
        $this->rel[$relation] = $value;
    }

    /**
     * Adds a reverse relation to the page
     *
     * @param string                                                                  $relation relation name (e.g. alternate, glossary,
     *                                                                                          canonical, etc)
     * @param ContainerInterface<PageInterface>|iterable<string>|PageInterface|string $value    value to set for relation
     *
     * @throws void
     */
    public function addRev(string $relation, iterable | ContainerInterface | PageInterface | string $value): void
    {
        $this->rev[$relation] = $value;
    }

    /**
     * Removes a forward relation from the page
     *
     * @param string $relation name of relation to remove
     *
     * @throws void
     */
    public function removeRel(string $relation): void
    {
        if (!isset($this->rel[$relation])) {
            return;
        }

        unset($this->rel[$relation]);
    }

    /**
     * Removes a reverse relation from the page
     *
     * @param string $relation name of relation to remove
     *
     * @throws void
     */
    public function removeRev(string $relation): void
    {
        if (!isset($this->rev[$relation])) {
            return;
        }

        unset($this->rev[$relation]);
    }

    /**
     * Returns an array containing the defined forward relations
     *
     * @return array<int, (int|string)> defined forward relations
     *
     * @throws void
     */
    public function getDefinedRel(): array
    {
        return array_keys($this->rel);
    }

    /**
     * Returns an array containing the defined reverse relations
     *
     * @return array<int, (int|string)> defined reverse relations
     *
     * @throws void
     */
    public function getDefinedRev(): array
    {
        return array_keys($this->rev);
    }

    /**
     * Returns custom properties as an array
     *
     * @return array<string, bool|ContainerInterface<PageInterface>|float|int|iterable<string, (array<string>|string)>|PageInterface|string|null> an array containing custom properties
     *
     * @throws void
     */
    public function getCustomProperties(): array
    {
        return $this->properties;
    }

    /**
     * Returns a hash code value for the page
     *
     * @return string a hash code value for this page
     *
     * @throws void
     */
    public function hashCode(): string
    {
        return spl_object_hash($this);
    }

    /**
     * Returns an array representation of the page
     *
     * @return array<mixed> associative array containing all page properties
     *
     * @throws void
     */
    public function toArray(): array
    {
        return array_merge(
            $this->getCustomProperties(),
            [
                'label' => $this->getLabel(),
                'fragment' => $this->getFragment(),
                'id' => $this->getId(),
                'class' => $this->getClass(),
                'title' => $this->getTitle(),
                'target' => $this->getTarget(),
                'rel' => $this->getRel(),
                'rev' => $this->getRev(),
                'order' => $this->getOrder(),
                'resource' => $this->getResource(),
                'privilege' => $this->getPrivilege(),
                'permission' => $this->getPermission(),
                'active' => $this->isActive(),
                'visible' => $this->isVisible(),
                'type' => static::class,
                'pages' => $this->parentToArray(),
            ],
        );
    }

    // Internal methods:

    /**
     * Normalizes a property name
     *
     * @param string $property property name to normalize
     *
     * @return string normalized property name
     *
     * @throws void
     */
    private static function normalizePropertyName(string $property): string
    {
        return str_replace(' ', '', ucwords(str_replace('_', ' ', $property)));
    }
}