api-8.x-1.x-dev/src/Controller/OutputController.php

src/Controller/OutputController.php
<?php

namespace Drupal\api\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Url;
use Drupal\api\Entity\Branch;
use Drupal\api\Entity\DocBlock;
use Drupal\api\Entity\DocBlock\DocFunction;
use Drupal\api\ExtendedQueries;
use Drupal\api\Formatter;
use Drupal\api\Interfaces\ProjectInterface;
use Drupal\api\Traits\RouteElementsTrait;
use Drupal\api\Utilities;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

/**
 * Output pages.
 *
 * @package Drupal\api\Controller
 */
class OutputController extends ControllerBase {

  use RouteElementsTrait;

  /**
   * Hierarchy of elements and subelements allowed in URLs arguments on routes.
   *
   * @var array[]
   */
  protected $elementsHierarchy = [
    'function' => [
      'calls',
      'implementations',
      'references',
      'invokes',
      'theme_invokes',
      'theme_references',
      'overrides',
    ],
    'constant' => [
      'constants',
    ],
    'global' => [],
    'property' => [],
    'class' => [
      'hierarchy',
      'uses',
      'references',
      'annotations',
      'services',
      'element_invokes',
    ],
    'interface' => [
      'hierarchy',
      'implements',
      'uses',
      'references',
      'services',
    ],
    'trait' => [
      'uses',
      'references',
    ],
    'service' => [
      'use',
    ],
    'group' => [],
  ];

  /**
   * Types of objects allowed in legacy routes.
   *
   * @var string[]
   */
  protected $legacyObjectTypes = [
    'function',
    'constant',
    'global',
    'group',
  ];

  /**
   * Types of listings allowed in legacy routes.
   *
   * @var string[]
   */
  protected $legacyListingTypes = [
    'functions',
    'files',
    'constants',
    'globals',
    'groups',
  ];

  /**
   * Utilities service.
   *
   * @var \Drupal\api\Utilities
   */
  protected $utilities;

  /**
   * Construct method.
   *
   * @param \Drupal\api\Utilities $utilities
   *   Utilities service.
   */
  public function __construct(Utilities $utilities) {
    $this->utilities = $utilities;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    $utilities = $container->get('api.utilities');
    return new static($utilities);
  }

  /**
   * Outputs a full list of elements for a branch / project.
   *
   * @param string|\Drupal\api\Interfaces\ProjectInterface $project
   *   Project, ID or slug where the branch belongs to.
   * @param string|\Drupal\api\Interfaces\BranchInterface $branch
   *   Branch, ID or slug.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   Request object.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   Json output.
   */
  public function fullList($project, $branch, Request $request) {
    $project = $this->getProject($project);
    $branch = ($project instanceof ProjectInterface) ?
      $this->getBranch($branch, $project) :
      NULL;
    $range = $this->getRangeFromParams($request);

    if ($range === FALSE || !$project || !$branch) {
      return new JsonResponse([]);
    }

    $ids = DocBlock::getFullList($branch, $range);
    $output = [];
    foreach ($ids as $id) {
      /** @var \Drupal\api\Interfaces\DocBlockInterface $docBlock */
      $docBlock = DocBlock::load($id);
      $output[] = $docBlock->toBasicArray();
    }

    return new JsonResponse($output);
  }

  /**
   * Checks the parameters for range options.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   Request object.
   *
   * @return array|null|false
   *   Range parameters, null or false if there is an error.
   */
  protected function getRangeFromParams(Request $request) {
    $range = NULL;
    if ($request->query->has('limit') || $request->query->has('page')) {
      $limit = (int) $request->query->get('limit', 50);
      $offset = (int) $request->query->get('page', 0) * $limit;
      if ($limit <= 0 || $offset < 0) {
        $range = FALSE;
      }
      else {
        $range = [
          'limit' => $limit,
          'offset' => $offset,
        ];
      }
    }

    return $range;
  }

  /**
   * Outputs a full list of elements for a project's default branch.
   *
   * @param string $project
   *   Project ID or slug where the branch belongs to.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   Request object.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   Json output.
   */
  public function fullListProject($project, Request $request) {
    $project = $this->getProject($project);
    if (!$project) {
      return new JsonResponse([]);
    }

    $branch = $project->getDefaultBranch();
    return $this->fullList($project, $branch, $request);
  }

  /**
   * Outputs a full dump of function elements for a branch.
   *
   * @param string $branch
   *   Branch to give elements from.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   Request object.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse|\Symfony\Component\HttpFoundation\Response
   *   Json output or plain text, depending on request object.
   */
  public function functionDump($branch, Request $request) {
    $format = $request->query->get('format', FALSE);
    $defaultBranch = $this->utilities->getDefaultBranchProject();
    if (!$defaultBranch) {
      if ($format == 'text') {
        return new Response($this->t('Not found'), Response::HTTP_NOT_FOUND);
      }
      return new JsonResponse([], Response::HTTP_NOT_FOUND);
    }
    $branch = $this->getBranch($branch, $defaultBranch->getProject(), TRUE);
    $dump = DocFunction::getFunctionDumpByBranch($branch);
    if ($format == 'text') {
      $string = '';
      foreach ($dump as $item) {
        $string .= $item['signature'] . ' ### ' . $item['summary'] . PHP_EOL;
      }

      return new Response($string);
    }
    return new JsonResponse($dump);
  }

  /**
   * Gets the default project/branch and redirects to it.
   *
   * @param string $argument
   *   Type of the object user is trying to see.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   Request object.
   *
   * @return \Symfony\Component\HttpFoundation\RedirectResponse
   *   Redirect to the branch and project or front page.
   */
  public function default($argument, Request $request) {
    $argument = $this->validateObjectType($argument);
    if ($argument === FALSE) {
      throw new NotFoundHttpException();
    }

    $branch = $this->utilities->getDefaultBranchProject();
    if ($branch) {
      if ($argument) {
        $url = Url::fromRoute('api.branch_explicit_route', [
          'project' => $branch->getProject()->getSlug(),
          'argument' => $argument,
          'branch' => $branch->getSlug(),
        ]);
      }
      else {
        $url = Url::fromRoute('api.branch_default_route', [
          'project' => $branch->getProject()->getSlug(),
          'argument' => $branch->getSlug(),
        ]);
      }
      return new RedirectResponse($url->toString());
    }

    $this->messenger()->addMessage($this->t('Default project and branch is not set.'));
    return $this->redirect('api.projects');
  }

  /**
   * Validates the parameter and returns it back if is valid.
   *
   * @param string $argument
   *   Type of the object.
   * @param bool $extended
   *   Validate over an extended set of choices.
   *
   * @return string
   *   Correct object_type or empty string if not valid.
   */
  protected function validateObjectType(string $argument, $extended = FALSE) {
    $choices = [
      '',
      'functions',
      'files',
      'constants',
      'globals',
      'groups',
    ];
    if ($extended) {
      $choices = array_merge($choices, [
        'classes',
        'namespaces',
        'deprecated',
        'services',
        'elements',
      ]);
    }
    return in_array($argument, $choices) ? $argument : FALSE;
  }

  /**
   * Page callback: Displays a list of links to projects using a pager.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   Request object.
   *
   * @return array
   *   Render array.
   */
  public function projectList(Request $request) {
    $output = [];

    $links = views_embed_view('api_projects', 'block_project_list');
    if ($links) {
      $output['project_list'] = $links;
    }

    return $output;
  }

  /**
   * Entry point route to determine what to show given the argument.
   *
   * It will take the default branch of the project and redirect to the right
   * place.
   *
   * @param string $project
   *   Project slug.
   * @param string $argument
   *   Rest of the route.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   Request object.
   *
   * @return array|null|\Symfony\Component\HttpFoundation\RedirectResponse
   *   Render array for the page, NULL if the page doesn't work or redirect.
   */
  public function branchDefaultRoute($project, $argument, Request $request) {
    $project_argument = $project;
    $project = $this->getProject($project_argument);
    if (!$project) {
      // Some legacy routes could reach this endpoint.
      // ie: api/{listing_type}/{branch}.
      return $this->legacyListing($project_argument, $argument);
    }

    // Try branch first, but don't throw exception.
    $branch = $this->getBranch($argument, $project);
    if ($branch) {
      return $this->branchView($branch, $project, $request);
    }

    // Not a branch, so validate the argument.
    $argument = $this->validateObjectType($argument, TRUE);
    if ($argument === FALSE) {
      throw new NotFoundHttpException();
    }

    // Valid argument and project, so take the default branch for the project.
    $branch = $project->getDefaultBranch();
    if ($branch) {
      $url = Url::fromRoute('api.branch_explicit_route', [
        'project' => $branch->getProject()->getSlug(),
        'argument' => $argument,
        'branch' => $branch->getSlug(),
      ]);
      return new RedirectResponse($url->toString());
    }

    $this->messenger()->addMessage($this->t('Default branch is not set for %project project.', [
      '%project' => $project->getTitle(),
    ]));
    return $this->redirect('<front>');
  }

  /**
   * Entry point route to determine what to show given the argument and branch.
   *
   * @param string $project
   *   Project slug.
   * @param string $argument
   *   Rest of the route.
   * @param string $branch
   *   Branch slug.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   Request object.
   *
   * @return array|null|\Symfony\Component\HttpFoundation\RedirectResponse
   *   Render array for the page, or NULL if the page doesn't work.
   */
  public function branchExplicitRoute($project, $argument, $branch, Request $request) {
    $project_argument = $project;
    $project = $this->getProject($project_argument);
    if (!$project) {
      // Some legacy routes could reach this endpoint.
      // ie: api/{type}/{object}/{branch}.
      return $this->legacyObject($project_argument, $argument, $branch);
    }
    $branch_argument = $branch;
    $branch = $this->getBranch($branch, $project);
    if (!$branch) {
      $guess_branch = $this->guessClosestBranch($branch_argument, $project);
      if (!empty($guess_branch)) {
        // Redirect to itself with the correct guess.
        return $this->redirect('api.branch_explicit_route', [
          'project' => $project->getSlug(),
          'argument' => $argument,
          'branch' => $guess_branch,
        ]);
      }

      if ($argument == 'namespace') {
        return $this->redirect('api.namespace_route', [
          'project' => $project->getSlug(),
          'namespace' => $branch_argument,
          'branch' => $project->getDefaultBranch(TRUE)->getSlug(),
        ]);
      }

      throw new NotFoundHttpException();
    }
    $object_type = $this->validateObjectType($argument, TRUE);
    if ($object_type === FALSE) {
      // It could be a full-file path.
      $file = $this->getFile($argument, $branch, TRUE);
      return Formatter::preparePageFileVariables($file);
    }

    return Formatter::preparePageListingVariables($branch, $object_type);
  }

  /**
   * Process object legacy route and redirect them to the right place.
   *
   * @param string $type
   *   Function, constant, global...
   * @param string $object_name
   *   Name of the object to load.
   * @param string $branch_slug
   *   Slug of the branch.
   *
   * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpKernel\Exception\NotFoundHttpException
   *   Either redirect to the right place or throw a not found exception.
   */
  protected function legacyObject($type, $object_name, $branch_slug) {
    if (!in_array($type, $this->legacyObjectTypes)) {
      throw new NotFoundHttpException();
    }
    $branches_ids = Branch::findBySlug($branch_slug);
    if ($branches_ids) {
      $branches = Branch::loadMultiple($branches_ids);
      foreach ($branches as $branch) {
        $object = ExtendedQueries::loadExtendedWithOverrides($object_name, $branch, $type);
        if ($object && !empty($object->id)) {
          $docBlock = DocBlock::load($object->id);
          $url = Formatter::objectUrl($docBlock);
          if ($url) {
            return new RedirectResponse($url->toString());
          }
        }
      }
    }

    throw new NotFoundHttpException();
  }

  /**
   * Redirects legacy URLs for files.
   *
   * The redirect is:
   * api/file/{directory}/{file.php}[/{branch}] ->
   * api/{project}/{directory}{separator}{file.php}/{branch}
   *
   * @param string $file_information
   *   All parametres after the 'file/' part of the URL.
   *
   * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpKernel\Exception\NotFoundHttpException
   *   Either redirect to the right place or throw a not found exception.
   */
  public function legacyFileRoute($file_information) {
    $arguments = explode(Formatter::FILEPATH_SEPARATOR_REPLACEMENT, $file_information);

    // Last argument can be:
    // * Branch slug.
    // * Filename and extension.
    // * Fixed string: 'source', 'documentation' (will be ignored)
    $last_argument = array_pop($arguments);
    if (in_array($last_argument, ['source', 'documentation'])) {
      $last_argument = array_pop($arguments);
    }

    if (!$last_argument) {
      throw new NotFoundHttpException();
    }

    // Check if $last_argument is a branch.
    $branches_ids = Branch::findBySlug($last_argument);
    if ($branches_ids) {
      // Next element should be the file basename then.
      $file_basename = array_pop($arguments);
      $branches = Branch::loadMultiple($branches_ids);
    }
    else {
      // No branch found, so it means it should be a file basename.
      // Also, assume default branch.
      $file_basename = $last_argument;
      $branches = [$this->utilities->getDefaultBranchProject()];
    }

    if (!$file_basename || !$branches) {
      throw new NotFoundHttpException();
    }

    // At this point, the rest of the arguments are the path to the file.
    $path = $arguments ? implode(Formatter::FILEPATH_SEPARATOR, $arguments) . '/' : '';

    // Try to find the file now on each of the branches. Settle for first match.
    foreach ($branches as $branch) {
      $file_id = DocBlock::findFileByFileName($path . $file_basename, $branch);
      if ($file_id) {
        $file = DocBlock::load($file_id);
        $url = Formatter::objectUrl($file, TRUE);
        return new RedirectResponse($url->toString());
      }
    }

    // Nothing found.
    throw new NotFoundHttpException();
  }

  /**
   * Process listing legacy route and redirect them to the right place.
   *
   * @param string $type
   *   Functions, constants, globals...
   * @param string $branch_slug
   *   Slug of the branch.
   *
   * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpKernel\Exception\NotFoundHttpException
   *   Either redirect to the right place or throw a not found exception.
   */
  protected function legacyListing($type, $branch_slug) {
    if (in_array($type, $this->legacyObjectTypes)) {
      // Most likely a legacy route WITHOUT the branch_slug, so get defaults
      // and redirect to the right place.
      $default_branch = $this->utilities->getDefaultBranchProject();
      if (!$default_branch) {
        throw new NotFoundHttpException();
      }
      return $this->legacyObject($type, $branch_slug, $default_branch->getSlug());
    }
    elseif (!in_array($type, $this->legacyListingTypes)) {
      throw new NotFoundHttpException();
    }

    $branches_ids = Branch::findBySlug($branch_slug);
    if ($branches_ids) {
      $branch_id = array_shift($branches_ids);
      $branch = Branch::load($branch_id);
      $url = Url::fromRoute('api.branch_explicit_route', [
        'project' => $branch->getProject()->getSlug(),
        'argument' => $type,
        'branch' => $branch->getSlug(),
      ]);
      return new RedirectResponse($url->toString());
    }

    throw new NotFoundHttpException();
  }

  /**
   * Entry point route for namespace paths.
   *
   * @param string $project
   *   Project slug.
   * @param string $namespace
   *   Namespace to see.
   * @param string $branch
   *   Branch slug.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   Request object.
   *
   * @return array|null
   *   Render array for the page, or NULL if the page doesn't work.
   */
  public function namespaceRoute($project, $namespace, $branch, Request $request) {
    $project = $this->getProject($project, TRUE);
    $branch = $this->getBranch($branch, $project, TRUE);
    $namespace = Formatter::getReplacementName($namespace, 'namespace', TRUE);

    $title = 'namespace ' . $namespace;
    return [
      '#title' => $title,
      '#theme' => 'api_namespace_page',
      '#branch' => $branch,
      '#name' => $namespace,
      '#listing' => views_embed_view(
        'api_namespaces',
        'block_items_namespace',
        $branch->id(),
        $namespace
      ),
    ];
  }

  /**
   * Entry point route when five arguments are given in the URL.
   *
   * Example: api/{project}/{filename}/{type}/{branch}
   *
   * @param string $project
   *   Project slug.
   * @param string $filename
   *   Name of the file including path (url encoded).
   * @param string $type
   *   Type of element to show.
   * @param string $branch
   *   Branch slug.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   Request object.
   *
   * @return array|null|\Symfony\Component\HttpFoundation\RedirectResponse
   *   Render array for the page, or NULL if the page doesn't work.
   */
  public function fileReferencesRoute($project, $filename, $type, $branch, Request $request) {
    $branch_argument = $branch;
    // Bring objects and validated parameters.
    $project = $this->getProject($project, TRUE);
    $branch = $this->getBranch($branch, $project);
    if (!$branch) {
      $guess_branch = $this->guessClosestBranch($branch_argument, $project);
      if (!empty($guess_branch)) {
        // Redirect to itself with the correct guess.
        return $this->redirect('api.file_references_route', [
          'project' => $project->getSlug(),
          'filename' => $filename,
          'branch' => $guess_branch,
          'type' => $type,
        ]);
      }

      // Maybe it's missing the branch, so take the project's default branch and
      // redirect to the best guess.
      return $this->redirect('api.detail_page_type_route', [
        'project' => $project->getSlug(),
        'filename' => $filename,
        'type' => $type,
        'item' => $branch_argument,
        'branch' => $project->getDefaultBranch(TRUE)->getSlug(),
      ]);
    }
    $file = $this->getFile($filename, $branch, TRUE);
    $types_allowed = [
      'theme_invokes',
      'theme_references',
      'yml_config',
      'yml_keys',
    ];
    if (!in_array($type, $types_allowed)) {
      throw new NotFoundHttpException();
    }

    return Formatter::prepareFunctionCallVariables($file, $type);
  }

  /**
   * Guess what's the closest branch to a given slug in a project.
   *
   * @param string $branch_name
   *   Slug of the branch to compare with.
   * @param \Drupal\api\Interfaces\ProjectInterface $project
   *   Project to get the branches from.
   *
   * @return string
   *   Slug of the closest branch.
   */
  protected function guessClosestBranch($branch_name, ProjectInterface $project) {
    $branches = $project->getBranches(TRUE) ?? [];
    $guesses = [];
    [$major_a] = explode('.', $branch_name);

    foreach ($branches as $branch) {
      $slug = $branch->getSlug();
      if ($slug === $branch_name) {
        return $slug;
      }
      [$major_b] = explode('.', $slug);
      if ($major_b === $major_a) {
        $guesses[] = $slug;
      }
    }

    sort($guesses, SORT_NATURAL);
    return !empty($guesses) ? array_pop($guesses) : '';
  }

  /**
   * Entry point route when six arguments are given in the URL.
   *
   * Example: api/{project}/{filename}/{type}/{item}/{branch}
   *
   * @param string $project
   *   Project slug.
   * @param string $filename
   *   Name of the file including path (url encoded).
   * @param string $type
   *   Type of element to show.
   * @param string $item
   *   Element to see.
   * @param string $branch
   *   Branch slug.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   Request object.
   *
   * @return array|null|\Symfony\Component\HttpFoundation\RedirectResponse
   *   Render array for the page, or NULL if the page doesn't work.
   */
  public function detailPageTypeRoute($project, $filename, $type, $item, $branch, Request $request) {
    $branch_argument = $branch;
    // Bring objects and validated parameters.
    $project = $this->getProject($project, TRUE);
    $branch = $this->getBranch($branch, $project);
    if (!$branch) {
      $guess_branch = $this->guessClosestBranch($branch_argument, $project);
      if (!empty($guess_branch)) {
        // Redirect to itself with the correct guess.
        return $this->redirect('api.detail_page_type_route', [
          'project' => $project->getSlug(),
          'filename' => $filename,
          'type' => $type,
          'item' => $item,
          'branch' => $guess_branch,
        ]);
      }

      // Maybe it's missing the branch, so take the project's default branch and
      // redirect to the best guess.
      return $this->redirect('api.detail_page_type_subtype_route', [
        'project' => $project->getSlug(),
        'filename' => $filename,
        'type' => $type,
        'subtype' => $item,
        'item' => $branch_argument,
        'branch' => $project->getDefaultBranch(TRUE)->getSlug(),
      ]);
    }
    $file = $this->getFile($filename, $branch, TRUE);
    $types_allowed = array_keys($this->elementsHierarchy);
    if (!in_array($type, $types_allowed)) {
      throw new NotFoundHttpException();
    }
    $item = $this->getItem($item, $file, $type, TRUE);

    // Call different output functions depending on $type.
    if (in_array($type, ['constant', 'global', 'property'])) {
      return Formatter::pageSimpleItem($item, $type);
    }
    elseif (in_array($type, ['class', 'interface', 'trait'])) {
      return Formatter::pageClass($item);
    }

    // 'function', 'service' and 'group` are left.
    $function_class = 'page' . ucfirst($type);
    return Formatter::$function_class($item);
  }

  /**
   * Entry point route when seven arguments are given in the URL.
   *
   * Example: api/{project}/{filename}/{type}/{item}/{branch}
   *
   * @param string $project
   *   Project slug.
   * @param string $filename
   *   Name of the file including path (url encoded).
   * @param string $type
   *   Type of element to show.
   * @param string $subtype
   *   Type with the type of element to show.
   * @param string $item
   *   Element to see.
   * @param string $branch
   *   Branch slug.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   Request object.
   *
   * @return array|null
   *   Render array for the page, or NULL if the page doesn't work.
   */
  public function detailPageTypeSubtypeRoute($project, $filename, $type, $subtype, $item, $branch, Request $request) {
    // Bring objects and validated parameters.
    $project = $this->getProject($project, TRUE);
    $branch = $this->getBranch($branch, $project, TRUE);
    $file = $this->getFile($filename, $branch, TRUE);
    $types_allowed = array_keys($this->elementsHierarchy);
    if (!in_array($type, $types_allowed)) {
      throw new NotFoundHttpException();
    }
    $subtypes_allowed = $this->elementsHierarchy[$type];
    if (!in_array($subtype, $subtypes_allowed)) {
      throw new NotFoundHttpException();
    }
    $item = $this->getItem($item, $file, $type, TRUE);

    // Call different output functions depending on $type and $subtype.
    if ($subtype == 'hierarchy') {
      return Formatter::pageClassHierarchy($item);
    }
    elseif ($type == 'interface' && $subtype == 'implements') {
      return Formatter::pageInterfaceImplements($item);
    }

    // All 19 remaining cases fall into this function call.
    return Formatter::pageFunctionCalls($item, $subtype);
  }

  /**
   * Page callback: Generates the default documentation page for a branch.
   *
   * @param string|\Drupal\api\Interfaces\BranchInterface $branch
   *   Branch giving the branch to display documentation for.
   * @param string|\Drupal\api\Interfaces\ProjectInterface $project
   *   Branch giving the branch to display documentation for.
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   Request object.
   *
   * @return array|null
   *   Render array for the page, or NULL if the page doesn't work.
   */
  public function branchView($branch, $project, Request $request) {
    $project = $this->getProject($project, TRUE);
    $branch = $this->getBranch($branch, $project, TRUE);

    return Formatter::prepareBranchVariables($branch);
  }

}

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

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