api-8.x-1.x-dev/src/Utilities.php

src/Utilities.php
<?php

namespace Drupal\api;

use Composer\Semver\Semver;
use CzProject\GitPhp\Git;
use CzProject\GitPhp\GitRepository;
use CzProject\GitPhp\Helpers;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\Logger\RfcLogLevel;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\StreamWrapper\PublicStream;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\api\Entity\Branch;
use Drupal\api\Entity\DocBlock;
use Drupal\api\Entity\DocBlock\DocFile;
use Drupal\api\Entity\Project;
use Drupal\api\Interfaces\BranchInterface;
use Drupal\api\Interfaces\ProjectInterface;
use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
use Symfony\Component\Filesystem\Filesystem;

/**
 * Utilities needed across the module.
 *
 * @package Drupal\api
 */
class Utilities {

  use StringTranslationTrait;

  /**
   * Variable representing one week.
   *
   * @const int
   */
  const ONE_WEEK = 604800;

  /**
   * Variable representing one month(ish).
   *
   * @const int
   */
  const ONE_MONTH = 2592000;

  /**
   * URL of the git repo containing the documentation.
   */
  const DOCUMENTATION_REPO_GIT_URL = 'https://git.drupalcode.org/project/documentation.git';

  /**
   * Base path to store git repositories.
   *
   * @var string
   */
  protected $gitBasePath;

  /**
   * FileSystemInterface definition.
   *
   * @var \Drupal\Core\File\FileSystemInterface
   */
  protected $fileSystem;

  /**
   * Logger instance for the api module.
   *
   * @var \Drupal\Core\Logger\LoggerChannelInterface
   */
  protected $logger;

  /**
   * Logger service.
   *
   * @var \Drupal\Core\Logger\LoggerChannelFactoryInterface
   */
  protected $loggerService;

  /**
   * ConfigFactoryInterface definition.
   *
   * @var \Drupal\Core\Config\ConfigFactoryInterface
   */
  protected $configFactory;

  /**
   * Config instance.
   *
   * @var \Drupal\Core\Config\ImmutableConfig
   */
  protected $apiConfig;

  /**
   * Current route match service.
   *
   * @var \Drupal\Core\Routing\RouteMatchInterface
   */
  protected $currentRouteMatch;

  /**
   * Utilities constructor.
   *
   * @param \Drupal\Core\File\FileSystemInterface $file_system
   *   File system service.
   * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_service
   *   Logger service.
   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
   *   Config factory.
   * @param \Drupal\Core\Routing\RouteMatchInterface $current_route_match
   *   Route match service.
   */
  public function __construct(FileSystemInterface $file_system, LoggerChannelFactoryInterface $logger_service, ConfigFactoryInterface $config_factory, RouteMatchInterface $current_route_match) {
    $this->fileSystem = $file_system;
    $this->loggerService = $logger_service;
    $this->configFactory = $config_factory;
    $this->currentRouteMatch = $current_route_match;

    $this->logger = $this->loggerService->get('api');
    $this->apiConfig = $this->configFactory->get('api.settings');
    $this->gitBasePath = $this->apiConfig->get('git_base_path') ?
      rtrim($this->apiConfig->get('git_base_path'), '/') :
      \DRUPAL_ROOT . '/' . PublicStream::basePath();
    $this->gitBasePath .= '/api_git_repositories';
    $this->fileSystem->prepareDirectory($this->gitBasePath, FileSystemInterface:: CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
  }

  /**
   * Gets the core compatibility of a project.
   *
   * @param string $project_folder
   *   Folder where the project is.
   * @param string $project_name
   *   Name of the project.
   * @param bool $lowest_available
   *   Return the lowest available match or the highest (default to highest).
   *
   * @return string
   *   Core compatibility of the project.
   */
  public function getCoreCompatibility($project_folder, $project_name, $lowest_available = FALSE) {
    $core_compatibility = '';
    $info_file = $project_folder . '/' . $project_name . '.info';

    if (file_exists($info_file)) {
      // D7, D6...
      $file = file_get_contents($info_file);
      $lines = explode(PHP_EOL, $file) ?? [];
      foreach ($lines as $line) {
        $line = str_replace(' ', '', $line);
        if (str_starts_with($line, 'core=')) {
          $core_compatibility = str_replace('core=', '', $line);
        }
      }
    }
    elseif (file_exists($info_file . '.yml')) {
      // D8, D9...
      $file = file_get_contents($info_file . '.yml');
      $lines = explode(PHP_EOL, $file) ?? [];
      foreach ($lines as $line) {
        $line = str_replace(' ', '', $line);
        if (str_starts_with($line, 'core_version_requirement:')) {
          // https://git.drupalcode.org/project/drupalorg/-/blob/207a3b24c8bd61fede8c8a8b9173189a46a1f029/drupalorg/drupalorg.module#L8389-8413
          $core_versions = $this->getRangeCoreVersions();
          $core_version_requirement = str_replace('core_version_requirement:', '', $line);
          // Trim spaces, single and double quotes.
          $core_version_requirement = trim($core_version_requirement, "\"' ");
          foreach ($core_versions as $core_version => $semver_version) {
            try {
              if (Semver::satisfies($semver_version, $core_version_requirement)) {
                $core_compatibility = $core_version . '.x';
                if ($lowest_available) {
                  return $core_compatibility;
                }
              }
            }
            catch (\Exception $e) {
              $this->logger->error('Malformed core_version_requirement. Error: %error', [
                '%error' => $e->getMessage(),
              ]);
            }
          }
        }
        elseif (str_starts_with($line, 'core:') && empty($core_compatibility)) {
          $core_compatibility = str_replace('core:', '', $line);
        }
      }
    }

    return $core_compatibility;
  }

  /**
   * Gets array of possible core versions.
   *
   * @return array
   *   Possible core versions with semver notation.
   */
  protected function getRangeCoreVersions() {
    $core_versions = [];
    // Review when Drupal is a teenager...
    foreach (range(8, 15) as $core_major) {
      $core_versions[$core_major] = $core_major . '.9999.9999';
    }

    return $core_versions;
  }

  /**
   * Determines whether the server running the project has `git` or not.
   *
   * @return bool
   *   Whether git command is available or not.
   */
  public function hasGit() {
    return (function_exists('exec') && exec('git'));
  }

  /**
   * Gets the project name of a git project.
   *
   * Valid formats are:
   * - https://git.drupalcode.org/project/project_name.git
   * - git@git.drupal.org:project/project_name.git
   * "https" format preferred.
   *
   * @param string $git_url
   *   URL of the git repository.
   *
   * @return string
   *   Name of the project.
   */
  public function gitProjectName($git_url) {
    return Helpers::extractRepositoryNameFromUrl($git_url);
  }

  /**
   * Cleans up the branch names coming from remote.
   *
   * @param array $branches
   *   Branch names to clean up.
   *
   * @return array
   *   Branch names cleaned up.
   */
  public function gitBranchNamesFromRemote(array $branches) {
    foreach ($branches as $i => $branch) {
      $branches[$i] = $branch = str_replace('origin/', '', $branch);
      if (str_starts_with($branch, 'HEAD')) {
        unset($branches[$i]);
      }
    }

    return $branches;
  }

  /**
   * Gets the remote sanitized branch names of a repo object.
   *
   * @param \CzProject\GitPhp\GitRepository $repo_object
   *   Repository object.
   *
   * @return array
   *   Array of branch names.
   */
  public function getRepoBranches(GitRepository $repo_object) {
    $branches = $repo_object->getRemoteBranches();
    return $branches ? $this->gitBranchNamesFromRemote($branches) : [];
  }

  /**
   * Pulls changes from branch.
   *
   * @param \Drupal\api\Interfaces\BranchInterface $branch
   *   Branch entity.
   *
   * @return bool
   *   Whether it could pull the latest version of the branch or not.
   */
  public function pullBranch(BranchInterface $branch) {
    if ($this->hasGit()) {
      $git = new Git();
      $branch_folder = NULL;
      $directories = $branch->getDirectories(TRUE);
      foreach ($directories as $directory) {
        if (str_starts_with($directory, $this->gitBasePath)) {
          $branch_folder = $directory;
        }
      }

      if (!is_null($branch_folder)) {
        try {
          $repo_object = $git->open($branch_folder);
          $repo_object->pull();
          $this->logger->info('Pulled branch %branch', [
            '%branch' => $branch->getSlug(),
          ]);
          return TRUE;
        }
        catch (\Throwable $e) {
          $this->logger->error('Could not pull branch. Error: %error', [
            '%error' => $e->getMessage(),
          ]);
          return FALSE;
        }
      }
    }

    return FALSE;
  }

  /**
   * Creates a branch folder within a repo folder.
   *
   * @param string $branch
   *   Branch to create.
   * @param string $folder
   *   Folder where the original repo is.
   * @param string $repo_url
   *   Url of the repo for the fallback.
   *
   * @return string
   *   Path of the branch folder.
   */
  public function createBranchFolder($branch, $folder, $repo_url = NULL) {
    $origin_folder = $folder . '/origin';
    $branch_folder = $folder . '/' . $branch;
    $git = new Git();

    try {
      $fileSystemHelper = new Filesystem();

      // Change branches and copy/replace files.
      $repo_object = $git->open($origin_folder);
      $repo_object->checkout($branch);
      $fileSystemHelper->mirror($origin_folder, $branch_folder, NULL, ['delete' => TRUE]);
    }
    catch (IOExceptionInterface $e) {
      if ($repo_url) {
        // Fallback to clone per branch.
        $branch_repo = $git->cloneRepository($repo_url, $branch_folder);
        $branch_repo->checkout($branch);
      }
      else {
        $this->logger->error('Could not create branch. Fallback Git URL not provided. Error: %error', [
          '%error' => $e->getMessage(),
        ]);
        $branch_folder = FALSE;
      }
    }
    catch (\Throwable $e) {
      $this->logger->error('Could not create branch. Error: %error', [
        '%error' => $e->getMessage(),
      ]);
      $branch_folder = FALSE;
    }

    return $branch_folder;
  }

  /**
   * Fetch new branches or updates existing ones.
   *
   * @param \Drupal\api\Interfaces\ProjectInterface $project
   *   Project to fetch branches from.
   * @param array $branches
   *   Branches to checkout or pull.
   * @param bool $create_branches
   *   Whether to create the branches in one operation or not.
   *
   * @return array|null
   *   Information about the created branches or NULL if it failed.
   */
  public function gitFetchBranches(ProjectInterface $project, array $branches = [], $create_branches = FALSE) {
    if (!$this->hasGit()) {
      return NULL;
    }

    // Try to determine the $base_folder value from the existing branches.
    $base_folder = FALSE;
    $projectBranches = $project->getBranches(TRUE);
    if (empty($projectBranches)) {
      // If we don't have any branches we can't guess anything.
      return NULL;
    }

    /** @var \Drupal\api\Interfaces\BranchInterface $anyBranch */
    $anyBranch = array_shift($projectBranches);
    $directories = $anyBranch->getDirectories(TRUE);
    foreach ($directories as $directory) {
      if (str_starts_with($directory, $this->gitBasePath)) {
        $base_folder = str_replace('/' . $anyBranch->getSlug(), '', $directory);
      }
    }

    if (!$base_folder) {
      // Maybe the paths were altered.
      return NULL;
    }

    // Create folders per branch.
    $folder_structure = [];
    foreach ($branches as $branch) {
      $branch_folder = $create_branches ?
        $this->createBranchFolder($branch, $base_folder) :
        $base_folder . '/' . $branch;
      if ($branch_folder) {
        $folder_structure[] = [
          'repo' => '',
          'branch' => $branch,
          'folder' => $branch_folder,
          'created' => $create_branches,
        ];
      }
    }

    return $folder_structure;
  }

  /**
   * Clones the repository into the folder for the given branches.
   *
   * @param string $repository
   *   Git URL of the repo.
   * @param array $branches
   *   Branches to clone.
   * @param bool $create_branches
   *   Whether to create the branches in one operation or not.
   *
   * @return array|null
   *   Information about the created branches or NULL if it failed.
   */
  public function gitClone($repository, array $branches = [], $create_branches = FALSE) {
    if (!$this->hasGit() || !UrlHelper::isValid($repository)) {
      return NULL;
    }

    $base_folder = $this->gitBasePath . '/' . uniqid();
    if (!$this->fileSystem->mkdir($base_folder)) {
      // Permissions?
      return NULL;
    }

    $folder_structure = [];
    $git = new Git();
    $origin_folder = $base_folder . '/origin';
    try {
      // Used to get the branches and to test the clone command.
      $origin_repo = $git->cloneRepository($repository, $origin_folder);
    }
    catch (\Throwable $e) {
      return NULL;
    }

    // Check branches if none given.
    if (empty($branches)) {
      $branches = $this->getRepoBranches($origin_repo);
      if (empty($branches)) {
        return NULL;
      }
    }

    // Create folders per branch.
    foreach ($branches as $branch) {
      $branch_folder = $create_branches ?
        $this->createBranchFolder($branch, $base_folder, $repository) :
        $base_folder . '/' . $branch;
      if ($branch_folder) {
        $folder_structure[] = [
          'repo' => $repository,
          'branch' => $branch,
          'folder' => $branch_folder,
          'created' => $create_branches,
        ];
      }
    }

    return $folder_structure;
  }

  /**
   * Gets a list of available update frequencies.
   *
   * @return array
   *   Values available for update frequencies.
   */
  public static function updateFrequencyValues() {
    return [
      1 => t('1 second'),
      3600 => t('1 hour'),
      10800 => t('3 hours'),
      21600 => t('6 hours'),
      43200 => t('12 hours'),
      86400 => t('1 day'),
      self::ONE_WEEK => t('1 week'),
      self::ONE_MONTH => t('30 days'),
    ];
  }

  /**
   * Writes a message to the logger based on the result given.
   *
   * @param array $result
   *   Results from parsing array.
   * @param string $type
   *   Type of the entity that was parsed.
   */
  public function logResultFromParsing(array $result, $type) {
    $watchdog = [
      'log' => FALSE,
      'message' => '',
      'type' => RfcLogLevel::INFO,
      'data' => [
        '@entity' => $result['label'],
        '@entitytype' => $type,
      ],
    ];

    if ($result['parsed']) {
      $watchdog['message'] = 'Parsed @count entries for @entity (type @entitytype).';
      $watchdog['data']['@count'] = $result['docblock_count'];
      $watchdog['log'] = TRUE;
    }
    elseif (!empty($result['limit_exceeded']) && $result['limit_exceeded']) {
      $watchdog['message'] = '@entity (type @entitytype) will be parsed at a later run, limit was exceeded.';
      $watchdog['type'] = RfcLogLevel::NOTICE;
      $watchdog['log'] = TRUE;
    }
    elseif ($result['needs_parsing']) {
      $watchdog['message'] = 'Could not parse any entries for @entity (type @entitytype).';
      $watchdog['type'] = RfcLogLevel::ERROR;
      $watchdog['log'] = TRUE;
    }

    if ($watchdog['log']) {
      $this->logger->log($watchdog['type'], $watchdog['message'], $watchdog['data']);
    }
  }

  /**
   * Adds external documentation to a folder.
   *
   * Older versions of Drupal (7 and below) do no include a "mainpage" tag in
   * the source code, so this external repo adds those additional pages to
   * provide more structured information and a nice welcoming page.
   *
   * All old core versions are reflected in the CORE.VERSION-1.x branch. So
   * 7.x would map to 7.x-1.x, 6.x to 6.x-1.x, etc.
   *
   * @param string $folder
   *   Path to the folder.
   * @param string $branch_name
   *   Name of the branch.
   * @param \Drupal\api\Interfaces\ProjectInterface $project
   *   Project where this folder is.
   */
  public function addExtraDocumentation($folder, $branch_name, ProjectInterface $project) {
    if ($project->isCore()) {
      $branch_name .= '-1.x';
      $folder .= '/external_documentation';
      $git = new Git();
      try {
        $documentation_repo = $git->cloneRepository(self::DOCUMENTATION_REPO_GIT_URL, $folder);
        $documentation_repo->checkout($branch_name);
      }
      catch (\Throwable $e) {
        // Maybe the branch does not exist.
      }
    }
  }

  /**
   * Returns the default branch for the whole site.
   *
   * @return \Drupal\api\Interfaces\BranchInterface|null
   *   Returns the default branch or null.
   */
  public function getDefaultBranchProject() {
    $default = $this->apiConfig->get('default_branch_project') ?? NULL;
    if ($default) {
      [, $id] = explode('|', $default);
      if ($id && ($branch = Branch::load($id))) {
        return $branch;
      }
    }

    return NULL;
  }

  /**
   * Types of pages available and their descriptions.
   *
   * @return array
   *   Associative array containing type => description.
   */
  public function getPageTypesAndDescriptions() {
    return [
      'branch' => $this->t('Branch landing pages'),
      'listing' => $this->t('Listing pages for functions, classes, topics, etc.'),
      'item' => $this->t('Single item pages for a function, class, topic, etc.'),
      'references' => $this->t('Pages listing references such as function calls'),
      'search' => $this->t('Search forms and search results'),
      'special' => $this->t('Miscellaneous API module pages'),
    ];
  }

  /**
   * Determines the type of page we are viewing based on the route.
   *
   * @return string
   *   Type of page we are in.
   */
  public function getPageTypeFromRoute() {
    $route_name = $this->currentRouteMatch->getRouteName();
    if (strpos($route_name, 'api.') !== 0 && $route_name !== 'entity.project.canonical') {
      return '';
    }

    // Some routes might actually cover two types of page, so we are returning
    // the default route type that is processed. If we need more granular
    // control we would need to read and process the parameters and then see
    // which type is correct, as done in OutputController, but that seems like
    // overkill for this case.
    $type = '';
    switch ($route_name) {
      case 'api.search.autocomplete':
      case 'api.full_list':
      case 'api.full_list_project':
      case 'api.function_dump':
      case 'api.projects':
        $type = 'special';
        break;

      case 'entity.project.canonical':
      case 'api.default':
      case 'api.branch_default_route':
        $type = 'branch';
        break;

      case 'api.search.project.branch':
      case 'api.search.project.branch.term':
      case 'api.search.global':
      case 'api.search.default':
      case 'api.search.branch':
      case 'api.opensearch':
      case 'api.opensearch.suggest':
        $type = 'search';
        break;

      case 'api.branch_explicit_route':
      case 'api.file_references_route':
        $type = 'listing';
        break;

      case 'api.namespace_route':
      case 'api.detail_page_type_route':
      case 'api.legacy_file_route':
        $type = 'item';
        break;

      case 'api.detail_page_type_subtype_route':
        $type = 'references';
        break;
    }

    return $type;
  }

  /**
   * Extract the project and branch from the current loaded route.
   *
   * @return array
   *   Project and branch found.
   */
  public function getProjectAndBranchFromRoute() {
    $results = [
      'project' => NULL,
      'branch' => NULL,
    ];

    $project = $this->currentRouteMatch->getParameter('project');

    // Project canonical route will load the whole object.
    $project = ($project instanceof ProjectInterface) ? $project : Project::getBySlug($project);
    if ($project) {
      $branch = $this->currentRouteMatch->getParameter('branch') ?? $this->currentRouteMatch->getParameter('argument');
      $branch = ($branch) ? Branch::getBySlug($branch, $project) : $project->getDefaultBranch(TRUE);

      $results['project'] = $project;
      $results['branch'] = $branch;
    }

    return $results;
  }

  /**
   * Extract the DocBlock item from the current loaded route.
   *
   * @param bool $fallback_to_default
   *   Try to fallback to defaults in case branch and project aren't found.
   *
   * @return array
   *   Best guesses for docblock and file.
   */
  public function getElementsFromRoute($fallback_to_default = FALSE) {
    [
      'project' => $project,
      'branch' => $branch,
    ] = $this->getProjectAndBranchFromRoute();
    if (!$branch && $fallback_to_default) {
      $branch = $this->getDefaultBranchProject();
      if ($branch) {
        $project = $branch->getProject();
      }
    }

    $elements = [
      'docblock' => NULL,
      'file' => NULL,
      'project' => $project,
      'branch' => $branch,
    ];

    if ($branch) {
      $item = $this->currentRouteMatch->getParameter('item') ?? FALSE;
      $type = $this->currentRouteMatch->getParameter('type') ?? FALSE;
      $filename = $this->currentRouteMatch->getParameter('filename') ?? FALSE;

      $file = FALSE;
      if ($filename) {
        $filename = urldecode($filename);
        $filename = Formatter::getReplacementName($filename, 'file', TRUE);
        $file_id = DocBlock::findFileByFileName($filename, $branch);
        if ($file_id) {
          $file = DocBlock::load($file_id);
        }
      }

      if ($file) {
        if (!$item || !$type) {
          // We can't check any further, so the item is the file.
          $elements += [
            'docblock' => $file,
            'file' => $file,
          ];
        }
        else {
          $item = urldecode($item);
          $docBlock_ids = DocBlock::findByNameAndType($item, $type, $branch);
          $docBlocks = DocBlock::loadMultiple($docBlock_ids);
          foreach ($docBlocks as $docBlock) {
            if ($docBlock->getFileName() == $file->getFileName()) {
              $elements += [
                'docblock' => $docBlock,
                'file' => $file,
              ];
              break;
            }
          }
        }
      }
    }

    return $elements;
  }

  /**
   * Remove DocBlocks that are no longer attached to a branch.
   *
   * Files and branches have timestamp fields which indicate when they were
   * processed. If the difference in days between these two timestamps is bigger
   * than the $remove_orphan_files value then it means that the files are
   * orphan, as the entries are always re-generated when the branch is parsed.
   */
  public function deleteOrphanDocBlocks() {
    $remove_orphan_files = (int) $this->apiConfig->get('remove_orphan_files');
    if ($remove_orphan_files > 0) {
      $branches = Branch::loadMultiple() ?? [];
      foreach ($branches as $branch) {
        $branch_was_parsed = $branch->getQueued();
        if ($branch_was_parsed) {
          // Get files from this branch where the timestamp is smaller than
          // the calculated date.
          $files_to_delete = DocFile::findCreatedBefore($this->getTimeToOrphan($branch_was_parsed, $remove_orphan_files));
          if ($files_to_delete) {
            foreach ($files_to_delete as $file_id) {
              $docFile = DocFile::load($file_id);
              if ($docFile) {
                // Deleting the DocBlock will delete all related entities.
                $this->logger->log(RfcLogLevel::NOTICE, 'DocBlock @docblock deleted.', [
                  '@docblock' => $docFile->getDocBlock()->getTitle(),
                ]);
                $docFile->delete();
              }
            }
          }
        }
      }
    }
  }

  /**
   * Files parsed before this Unix timestamp are orphaned.
   *
   * @param int $branch_was_parsed
   *   When the branch was parsed.
   * @param int $remove_orphan_files
   *   How many days should pass before files are considered orphaned.
   *
   * @return int
   *   Files parsed before this Unix timestamp are orphaned.
   */
  protected function getTimeToOrphan(int $branch_was_parsed, int $remove_orphan_files): int {
    return (new \DateTime())
      ->setTimestamp($branch_was_parsed)
      ->modify('-' . $remove_orphan_files . ' days')
      ->getTimestamp();
  }

}

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

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