layout_builder_ipe-1.0.x-dev/src/LayoutBuilderIpeService.php

src/LayoutBuilderIpeService.php
<?php

namespace Drupal\layout_builder_ipe;

use Drupal\Component\Render\MarkupInterface;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\Html;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\CsrfTokenGenerator;
use Drupal\Core\Asset\LibraryDiscoveryInterface;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityDisplayRepositoryInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Link;
use Drupal\Core\Render\BubbleableMetadata;
use Drupal\Core\Render\RenderableInterface;
use Drupal\Core\Render\RenderContext;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Routing\RouteProviderInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\layout_builder\LayoutEntityHelperTrait;
use Drupal\layout_builder\LayoutTempstoreRepositoryInterface;
use Drupal\layout_builder\Plugin\SectionStorage\DefaultsSectionStorage;
use Drupal\layout_builder\Plugin\SectionStorage\OverridesSectionStorage;
use Drupal\layout_builder\SectionStorageInterface;
use Drupal\layout_builder_ipe\Traits\ConfirmDialogTrait;
use Drupal\layout_builder_ipe\Traits\SectionStorageTrait;
use Symfony\Component\HttpFoundation\RequestStack;

/**
 * General purpose service for IPE related functionality.
 *
 * @todo This class needs refactoring.
 */
class LayoutBuilderIpeService {

  use LayoutEntityHelperTrait {
    LayoutEntityHelperTrait::getSectionStorageForEntity as layoutEntityHelperGetSectionStorageForEntity;
  }
  use SectionStorageTrait;
  use StringTranslationTrait;
  use ConfirmDialogTrait;

  /**
   * The current route match.
   *
   * @var \Drupal\Core\Routing\RouteMatchInterface
   */
  protected $routeMatch;

  /**
   * The current request.
   *
   * @var \Symfony\Component\HttpFoundation\Request
   */
  protected $request;

  /**
   * The entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;

  /**
   * The entity display repository.
   *
   * @var \Drupal\Core\Entity\EntityDisplayRepositoryInterface
   */
  protected $entityDisplayRepository;

  /**
   * The current user.
   *
   * @var \Drupal\Core\Session\AccountInterface
   */
  protected $currentUser;

  /**
   * The library discovery service.
   *
   * @var \Drupal\Core\Asset\LibraryDiscoveryInterface
   */
  protected $libraryDiscovery;

  /**
   * Layout tempstore repository.
   *
   * @var \Drupal\layout_builder\LayoutTempstoreRepositoryInterface
   */
  protected $layoutTempstoreRepository;

  /**
   * The CSRF token generator.
   *
   * @var \Drupal\Core\Access\CsrfTokenGenerator
   */
  protected $csrfToken;

  /**
   * The layout builder ipe config object.
   *
   * @var \Drupal\layout_builder_ipe\LayoutBuilderIpeConfig
   */
  protected $config;

  /**
   * The layout builder ipe extensions service.
   *
   * @var \Drupal\layout_builder_ipe\LayoutBuilderIpeExtensions
   */
  protected $extensions;

  /**
   * The lock service generator.
   *
   * @var \Drupal\layout_builder_ipe\LayoutBuilderIpeLock
   */
  protected $lock;

  /**
   * The renderer service.
   *
   * @var \Drupal\Core\Render\Renderer
   */
  protected $renderer;

  /**
   * The route provider.
   *
   * @var \Drupal\Core\Routing\RouteProviderInterface
   */
  protected $routeProvider;

  /**
   * Public constructor.
   */
  public function __construct(RouteMatchInterface $route_match, RequestStack $request_stack, EntityTypeManagerInterface $entity_type_manager, EntityDisplayRepositoryInterface $entity_display_repository, AccountInterface $current_user, LibraryDiscoveryInterface $library_discovery, LayoutBuilderIpeConfig $config, LayoutBuilderIpeExtensions $extensions, LayoutTempstoreRepositoryInterface $layout_tempstore_repository, CsrfTokenGenerator $csrf_token, LayoutBuilderIpeLock $layout_builder_lock, RendererInterface $renderer, RouteProviderInterface $route_provider) {
    $this->routeMatch = $route_match;
    $this->request = $request_stack->getCurrentRequest();
    $this->entityTypeManager = $entity_type_manager;
    $this->entityDisplayRepository = $entity_display_repository;
    $this->currentUser = $current_user;
    $this->libraryDiscovery = $library_discovery;
    $this->config = $config;
    $this->extensions = $extensions;
    $this->layoutTempstoreRepository = $layout_tempstore_repository;
    $this->csrfToken = $csrf_token;
    $this->lock = $layout_builder_lock;
    $this->renderer = $renderer;
    $this->routeProvider = $route_provider;
  }

  /**
   * Check access to the Layout Builder IPE frontend.
   *
   * @param \Drupal\layout_builder\SectionStorageInterface $section_storage
   *   The section storage.
   * @param \Drupal\Core\Entity\EntityInterface $entity
   *   The entity to check.
   * @param bool $check_lock
   *   Whether a lock should be checked too.
   *
   * @return \Drupal\Core\Access\AccessResult
   *   An access result object.
   */
  public function access(SectionStorageInterface $section_storage, ?EntityInterface $entity = NULL, ?bool $check_lock = TRUE) {
    if (!$section_storage->access('view')) {
      return AccessResult::forbidden();
    }

    $custom_tempstore = $this->layoutTempstoreRepository instanceof LayoutBuilderIpeTempstoreRepository;
    if ($check_lock && $this->lock->canLock() && !$custom_tempstore && $this->lock->isLocked($section_storage)) {
      return AccessResult::forbidden(Json::encode($this->lock->getLockReason($section_storage)));
    }

    if ($section_storage->getPluginId() == 'page_manager' && $this->currentUser->hasPermission('administer pages')) {
      return AccessResult::allowedIfHasPermission($this->currentUser, 'use layout builder ipe on editable page manager pages');
    }
    if ($entity === NULL) {
      $entity = $this->getEntityFromSectionStorage($section_storage);
    }
    if ($entity && !$this->isLayoutCompatibleEntity($entity)) {
      return AccessResult::forbidden();
    }
    $entity = $this->getEntityFromSectionStorage($section_storage) ?? $entity;
    if (!$entity || !$entity->access('update')) {
      return AccessResult::forbidden();
    }

    if ($entity->getEntityType()->hasKey('bundle')) {
      return AccessResult::allowedIfHasPermission($this->currentUser, "use layout builder ipe on editable {$entity->bundle()} {$entity->getEntityTypeId()} layout overrides");
    }
    else {
      return AccessResult::allowedIfHasPermission($this->currentUser, "use layout builder ipe on editable {$entity->getEntityTypeId()} layout overrides");
    }
  }

  /**
   * Check if IPE is enabled for the given entity.
   *
   * @param \Drupal\Core\Entity\EntityInterface $entity
   *   The entity to check.
   * @param string $view_mode
   *   Optional: The view mode.
   *
   * @return bool|null
   *   TRUE if IPE is enabled, FALSE otherwise.
   */
  public function ipeEnabled(EntityInterface $entity, $view_mode = NULL) {
    $section_storage = $this->getSectionStorageForEntity($entity);
    if (!$section_storage || !$this->access($section_storage, $entity)) {
      return NULL;
    }
    if ($section_storage->getPluginId() == 'page_manager') {
      // Page manager pages don't have a specific toggle for IPE, but access is
      // handled via permission. So count this as enabled.
      return TRUE;
    }
    $entity_type_id = $entity->getEntityTypeId();
    $entity_bundle = $entity->bundle();
    if ($view_mode === NULL) {
      // Now we trigger a view build, just so that we can see which view mode
      // (with which settings) would most probably be used if this was a full
      // page rendering. This doesn't seem neither elegant nor very performant,
      // but I didn't see another way of getting the view mode.
      // Luckily the result of this access check is cached.
      $view_builder = $this->entityTypeManager->getViewBuilder($entity_type_id);
      $build = $view_builder->view($entity);
      $view_mode = $build['#view_mode'];
    }
    // If the full view mode is not enabled and can therefor not be
    // configured separately, we have to look at the default view mode which
    // is used instead.
    $available_view_modes = $this->entityDisplayRepository->getViewModeOptionsByBundle($entity_type_id, $entity_bundle);
    if ($view_mode == 'full' && !array_key_exists('full', $available_view_modes)) {
      $display = $this->entityDisplayRepository->getViewDisplay($entity_type_id, $entity_bundle, 'default');
    }
    else {
      $display = $this->entityDisplayRepository->getViewDisplay($entity_type_id, $entity_bundle, $view_mode);
    }
    return $display->getThirdPartySetting('layout_builder_ipe', 'enabled', FALSE);
  }

  /**
   * Check if the entity changed constraint is overridden.
   *
   * @return bool
   *   TRUE if overridden, FALSE otherwise.
   */
  public function useOverrideEntityChangedConstraint() {
    return $this->getConfig('override_entity_changed_constraint');
  }

  /**
   * Get the current path for an edited page.
   *
   * Only relevant for page manager pages.
   *
   * @return string|null
   *   The current path if found.
   */
  public function getCurrentEditPath() {
    $query = $this->request->query;

    // The current path from the query was the original idea. Unfortunately,
    // the contextual links in layout builder (for configure, remove, etc ...)
    // do not respect existing query arguments, not even when modified via an
    // outbound url processor. Fortunately though, they use the destination
    // argument consistently, so it should be safe to use that.
    if ($query->has('current_path')) {
      return $query->get('current_path');
    }
    if ($query->has('destination')) {
      return $query->get('destination');
    }
  }

  /**
   * Attach the IPE frontend to the given build array.
   *
   * @param array $build
   *   The render array to attach Layout Builder IPE to.
   * @param \Drupal\layout_builder\SectionStorageInterface $section_storage
   *   The section storage.
   * @param \Drupal\Core\Entity\EntityInterface $entity
   *   The main entity for this request.
   */
  public function attachIpe(array &$build, SectionStorageInterface $section_storage, EntityInterface $entity) {
    if (!$this->access($section_storage, $entity, FALSE)->isAllowed()) {
      return;
    }

    if ($entity->getEntityTypeId() == 'page_variant' && !$this->extensions->moduleEnabled('page_manager_ui')) {
      // We need the page manager ui module to be enabled to be able to access
      // the entity.page.edit_form route.
      return;
    }

    // Settings.
    $hide_local_tasks = $this->getConfig('hide_local_tasks_block');
    $enhance_ui = $this->getConfig('enhance_ui');

    $content_entity_plugins = ['overrides', 'defaults'];
    if ($entity instanceof ContentEntityInterface && in_array($section_storage->getPluginId(), $content_entity_plugins)) {
      $entity_target_class = Html::getClass('layout-builder-ipe-target');
      $build['#attributes']['class'][] = $entity_target_class;
      $entity_selector = '.' . $entity_target_class;

      if ($section_storage->getPluginId() == 'overrides') {
        $links = $this->buildOverridesCustomizeLink($section_storage, $entity);
      }
      if ($section_storage->getPluginId() == 'defaults') {
        $links = [
          'customize' => Link::createFromRoute($this->t('Customize'), 'layout_builder_ipe.entity.override', [
            'entity_type' => $entity->getEntityTypeId(),
            'entity' => $entity->id(),
          ], [
            'attributes' => [
              'class' => ['use-ajax'],
            ],
          ]),
        ];
      }
      $build['#attached']['drupalSettings']['layout_builder_ipe'] = [
        'links' => $this->renderFrontendLinks($links, $section_storage, $entity, $build),
        'entity_selector' => $entity_selector,
        'hide_local_tasks' => $hide_local_tasks,
        'enhance_ui' => $enhance_ui,
      ];
    }
    elseif ($entity->getEntityTypeId() == 'page_variant' && $section_storage->getPluginId() == 'page_manager') {
      $links = $this->buildOverridesCustomizeLink($section_storage, $entity);
      $entity_target_class = Html::getClass('layout-builder-ipe-target');
      $build['#attributes']['class'][] = $entity_target_class;
      $build['#attached']['drupalSettings']['layout_builder_ipe'] = [
        'links' => $this->renderFrontendLinks($links, $section_storage, $entity, $build),
        // This is tricky. We want to identify the main content element, which
        // can be different based on the used theme.
        'entity_selector' => '.' . $entity_target_class,
        'enhance_ui' => $enhance_ui,
      ];
    }

    $build['#attached']['library'][] = 'core/drupal.ajax';
    $build['#attached']['library'][] = 'layout_builder_ipe/ipe';
    // Never cache this.
    $build['#cache']['max-age'] = 0;
  }

  /**
   * Build the customize links for overrides.
   *
   * @param \Drupal\layout_builder\SectionStorageInterface $section_storage
   *   The section storage.
   * @param \Drupal\Core\Entity\EntityInterface $entity
   *   The main entity for this request.
   *
   * @return array
   *   An render array.
   */
  private function buildOverridesCustomizeLink(SectionStorageInterface $section_storage, EntityInterface $entity) {
    $route_name = match ($section_storage->getPluginId()) {
      'overrides' => 'layout_builder_ipe.entity.edit',
      'page_manager' => 'layout_builder_ipe.page_variant.edit',
    };
    $links['customize'] = Link::createFromRoute($this->t('Customize'), $route_name, [
      'section_storage_type' => $section_storage->getStorageType(),
      'section_storage' => $section_storage->getStorageId(),
    ], [
      'attributes' => [
        'class' => ['use-ajax'],
      ],
      'query' => [
        'current_path' => Url::fromRoute('<current>')->toString(),
      ],
    ]);

    // If there are changes that can be discarded, add a discard link.
    if ($this->layoutTempstoreRepository->has($section_storage) && $this->getConfig('show_discard_changes_link')) {
      $section_storage_identifier = $section_storage->getPluginId() . ($section_storage->getPluginId() != 'page_manager' ? '.' . $entity->getEntityTypeId() : '');
      $route_name = 'layout_builder.' . $section_storage_identifier . '.discard_changes';
      if (empty($this->routeProvider->getRoutesByNames([$route_name]))) {
        return $links;
      }
      $links['discard'] = Link::createFromRoute($this->t('Discard changes'), $route_name, [
        $entity->getEntityTypeId() => $entity->id(),
      ], [
        'query' => [
          'current_path' => Url::fromRoute('<current>')->toString(),
        ],
        'attributes' => [
          'class' => ['use-ajax'],
          'data-dialog-type' => 'dialog',
          'data-dialog-options' => Json::encode($this->getDialogOptions()),
        ],
      ]);
    }
    return $links;
  }

  /**
   * Render the frontend links.
   *
   * @param array $links
   *   An array of links in some renderable format.
   * @param \Drupal\layout_builder\SectionStorageInterface $section_storage
   *   The section storage.
   * @param \Drupal\Core\Entity\EntityInterface $entity
   *   The entity object.
   * @param array $build
   *   A build array.
   *
   * @return string[]
   *   An array of rendered links.
   */
  private function renderFrontendLinks(array $links, SectionStorageInterface $section_storage, EntityInterface $entity, array &$build) {
    $this->extensions->moduleHandler()->alter('layout_builder_ipe_links', $links, $section_storage, $entity, $build);
    return array_filter(array_map(function ($link) use (&$build) {
      $context = new RenderContext();
      $rendered = NULL;
      if (is_object($link) && $link instanceof RenderableInterface) {
        $renderable = $link->toRenderable();
        $rendered = $this->renderer->executeInRenderContext($context, function () use ($renderable) {
          return (string) $this->renderer->render($renderable);
        });
      }
      elseif (is_object($link) && $link instanceof MarkupInterface) {
        $rendered = (string) $link;
      }
      elseif (is_array($link)) {
        $rendered = $this->renderer->executeInRenderContext($context, function () use ($link) {
          return (string) $this->renderer->render($link);
        });
      }
      elseif (is_string($link)) {
        $rendered = $link;
      }

      // Handle any bubbled cacheability metadata.
      if (!$context->isEmpty()) {
        $bubbleable_metadata = $context->pop();
        BubbleableMetadata::createFromRenderArray($build)
          ->merge($bubbleable_metadata)
          ->applyTo($build);
      }

      return $rendered;
    }, $links));
  }

  /**
   * Get an entity object for the current request.
   *
   * @return \Drupal\Core\Entity\EntityInterface|null
   *   The entity if found.
   */
  public function getEntity() {
    return $this->getLayoutBuilderCompatibleEntityFromRequest() ?? $this->getContentEntityFromRoute();
  }

  /**
   * Get the current section storage if available.
   *
   * @param string $view_mode
   *   The view mode for the section storage.
   *
   * @return \Drupal\layout_builder\SectionStorageInterface|null
   *   The section storage or NULL if its context requirements are not met.
   */
  public function getSectionStorage($view_mode = 'full') {
    if ($this->request->attributes->get('_page_manager_page_variant')) {
      /** @var \Drupal\page_manager\PageVariantInterface $page_variant */
      $page_variant = $this->request->attributes->get('_page_manager_page_variant');
      $section_storage = $this->getSectionStorageForEntity($page_variant);
    }
    elseif ($this->routeMatch->getParameter('section_storage')) {
      $section_storage = $this->routeMatch->getParameter('section_storage');
    }
    elseif ($this->request->attributes->has('section_storage')) {
      $section_storage = $this->request->attributes->get('section_storage');
    }
    else {
      $entity = $this->getEntity();
      $section_storage = $entity ? $this->getSectionStorageForEntity($entity, $view_mode) : NULL;
    }
    // If we found a section storage, also check if there is a version of it in
    // the tempstore.
    return $section_storage ? $this->layoutTempstoreRepository->get($section_storage) : NULL;
  }

  /**
   * Get the layout tempstore repository.
   *
   * @return \Drupal\layout_builder\LayoutTempstoreRepositoryInterface
   *   Layout tempstore repository.
   */
  public function getLayoutTempstoreRepository() {
    return $this->layoutTempstoreRepository;
  }

  /**
   * Get the section storage for an entity.
   *
   * @param \Drupal\Core\Entity\EntityInterface $entity
   *   The entity.
   * @param string $view_mode
   *   The view mode for the section storage.
   *
   * @return \Drupal\layout_builder\SectionStorageInterface|null
   *   The section storage or NULL if its context requirements are not met.
   */
  public function getSectionStorageForEntity(EntityInterface $entity, $view_mode = 'full') {
    return $this->layoutEntityHelperGetSectionStorageForEntity($entity, $view_mode);
  }

  /**
   * Check if the current route is a possible Layout Builder IPE route.
   *
   * @return bool
   *   TRUE if Layout Builder IPE can be or is used on the current route.
   */
  public function isLayoutBuilderIpeRoute() {
    $route_name = $this->routeMatch->getRouteName() ?? NULL;
    if ($route_name && strpos($route_name, 'layout_builder_ipe.') === 0) {
      return TRUE;
    }
    $entity = $this->getEntity();
    return $entity && $this->getSectionStorageForEntity($entity);
  }

  /**
   * Check if Gin LB is used.
   *
   * @return bool
   *   TRUE if Gin LB is installed and the current route qualifies.
   */
  public function isGinLb() {
    return $this->isGinLbEnabled() && $this->isLayoutBuilderIpeRoute();
  }

  /**
   * Check if Gin LB is enabled.
   *
   * @return bool
   *   TRUE if Gin LB is installed.
   */
  public function isGinLbEnabled() {
    return $this->isGinEnabled() && $this->extensions->moduleEnabled('gin_lb');
  }

  /**
   * Check if Gin is enabled.
   *
   * @return bool
   *   TRUE if Gin is installed.
   */
  public function isGinEnabled() {
    return $this->extensions->themeEnabled('gin') && $this->extensions->isAdminTheme('gin');
  }

  /**
   * Check if Gin Legacy CSS is needed.
   *
   * @return bool
   *   TRUE if Gin Legacy CSS is available, FALSE otherwise.
   */
  public function needsGinLegacy() {
    return $this->libraryDiscovery->getLibraryByName('gin', 'legacy_css');
  }

  /**
   * Extract the main content entity from the current route.
   *
   * @return \Drupal\Core\Entity\ContentEntityInterface|null
   *   An entity object or null if none is found.
   */
  public function getContentEntityFromRoute() {
    // Entity will be found in the route parameters.
    $route = $this->routeMatch->getRouteObject();
    $parameters = $route ? $route->getOption('parameters') : NULL;
    if (!$parameters) {
      return;
    }
    foreach ($parameters as $name => $options) {
      if (isset($options['type']) && strpos($options['type'], 'entity:') === 0) {
        $entity = $this->routeMatch->getParameter($name);
        if ($entity instanceof ContentEntityInterface && $entity->hasLinkTemplate('canonical')) {
          return $entity;
        }
      }
    }
  }

  /**
   * Get an entity from the request that we can attach to.
   *
   * @return \Drupal\core\Entity\EntityInterface|null
   *   An entity object or null if none is found.
   */
  public function getLayoutBuilderCompatibleEntityFromRequest() {
    $attributes = $this->request->attributes->all();
    foreach ($attributes as $attribute) {
      if (empty($attribute) || !is_object($attribute)) {
        continue;
      }
      if ($attribute instanceof EntityInterface && $this->isLayoutCompatibleEntity($attribute)) {
        return $attribute;
      }
    }
  }

  /**
   * Get the name of the active theme.
   *
   * @return string
   *   The name of currently active theme.
   */
  public function getActiveTheme() {
    return $this->extensions->getActiveTheme();
  }

  /**
   * Get the module config.
   *
   * @param string $key
   *   The settings key.
   *
   * @return mixed
   *   The value of the requested configuration setting.
   */
  public function getConfig($key) {
    return $this->config->get($key);
  }

  /**
   * Get a token that identifies this entity in Layout Builder IPE forms.
   *
   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
   *   The entity for which to get the token.
   *
   * @return string
   *   The generated token.
   */
  public function getEditToken(ContentEntityInterface $entity) {
    return $this->csrfToken->get($entity->getEntityTypeId() . '-' . $entity->id());
  }

  /**
   * Check if the given entity is the subject of a current form submission.
   *
   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
   *   The entity to check.
   *
   * @return bool
   *   TRUE or FALSE.
   */
  public function isLayoutBuilderIpeFormSubmission(ContentEntityInterface $entity) {
    $ipe_token = $this->request->get('layout_builder_ipe_token');
    if (empty($ipe_token)) {
      return FALSE;
    }
    $expected_token_value = $entity->getEntityTypeId() . '-' . $entity->id();
    return $this->csrfToken->validate($ipe_token, $expected_token_value);
  }

  /**
   * Calculate a hash for the given section storage.
   *
   * @param \Drupal\layout_builder\SectionStorageInterface $section_storage
   *   The section storage to calculate the hash for.
   *
   * @return string|null
   *   An md5 hash or NULL if no layout exists.
   */
  public function hashLayout(SectionStorageInterface $section_storage) {
    if ($section_storage instanceof DefaultsSectionStorage) {
      return 'default';
    }
    $sections = $section_storage->getSections();
    $layout_data = [];
    foreach ($sections as $section) {
      $section_data = $section->toArray();
      self::reduceArray($section_data);
      $layout_data[] = $section_data;
    }
    return md5(str_replace(['"', "\n"], '', json_encode($layout_data)));
  }

  /**
   * Calculate a hash for the current state of the given entity.
   *
   * @param \Drupal\Core\Entity\ContentEntityInterface $entity
   *   The entity object to calculate the hash for.
   *
   * @return string
   *   An md5 hash.
   */
  public function hashEntity(ContentEntityInterface $entity) {
    $entity_data = $entity->toArray();
    self::reduceArray($entity_data);
    unset($entity_data['changed']);
    unset($entity_data['revision_timestamp']);
    unset($entity_data[OverridesSectionStorage::FIELD_NAME]);
    return md5(str_replace(['"', "\n"], '', json_encode($entity_data)));
  }

  /**
   * Reduce an array by removing empty items.
   *
   * @param array $array
   *   The input array.
   */
  public static function reduceArray(array &$array) {
    foreach ($array as $key => &$a) {
      if (is_array($a)) {
        if (empty($a)) {
          unset($array[$key]);
        }
        else {
          self::reduceArray($a);
        }
      }
    }
    $array = array_filter($array);
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc