Project.php

  1. 8.0.x lib/Drush/UpdateService/Project.php
  2. 7.x lib/Drush/UpdateService/Project.php
  3. master lib/Drush/UpdateService/Project.php

Namespace

Drush\UpdateService

Classes

Namesort descending Description
Project Representation of a project's release info from the update service.

File

lib/Drush/UpdateService/Project.php
View source
  1. <?php
  2. namespace Drush\UpdateService;
  3. use Drush\Log\LogLevel;
  4. /**
  5. * Representation of a project's release info from the update service.
  6. */
  7. class Project {
  8. private $parsed;
  9. /**
  10. * Constructor.
  11. *
  12. * @param string $project_name
  13. * Project name.
  14. *
  15. * @param \SimpleXMLElement $xml
  16. * XML data.
  17. */
  18. function __construct(\SimpleXMLElement $xml) {
  19. // Check if the xml contains an error on the project.
  20. if ($error = $xml->xpath('/error')) {
  21. $error = (string)$error[0];
  22. if (strpos($error, 'No release history available for') === 0) {
  23. $project_status = 'unsupported';
  24. }
  25. elseif (strpos($error, 'No release history was found for the requested project') === 0) {
  26. $project_status = 'unknown';
  27. }
  28. // Any other error we are not aware of.
  29. else {
  30. $project_status = 'unknown';
  31. }
  32. }
  33. // The xml has a project, but still it can have errors.
  34. else {
  35. $this->parsed = self::parseXml($xml);
  36. if (empty($this->parsed['releases'])) {
  37. $error = dt('No available releases found for the requested project (!name).', array('!name' => $this->parsed['short_name']));
  38. $project_status = 'unknown';
  39. }
  40. else {
  41. $error = FALSE;
  42. $project_status = $xml->xpath('/project/project_status');
  43. $project_status = (string)$project_status[0];
  44. }
  45. }
  46. $this->project_status = $project_status;
  47. $this->error = $error;
  48. if ($error) {
  49. drush_set_error('DRUSH_RELEASE_INFO_ERROR', $error);
  50. }
  51. }
  52. /**
  53. * Downloads release info xml from update service.
  54. *
  55. * @param array $request
  56. * A request array.
  57. * @param int $cache_duration
  58. * Cache lifetime.
  59. *
  60. * @return \Drush\UpdateService\Project
  61. */
  62. public static function getInstance(array $request, $cache_duration = ReleaseInfo::CACHE_LIFETIME) {
  63. $url = self::buildFetchUrl($request);
  64. drush_log(dt('Downloading release history from !url', array('!url' => $url)));
  65. $path = drush_download_file($url, drush_tempnam($request['name']), $cache_duration);
  66. $xml = simplexml_load_file($path);
  67. if (!$xml) {
  68. $error = dt('Failed to get available update data from !url', array('!url' => $url));
  69. return drush_set_error('DRUSH_RELEASE_INFO_ERROR', $error);
  70. }
  71. return new Project($xml);
  72. }
  73. /**
  74. * Returns URL to the updates service for the given request.
  75. *
  76. * @param array $request
  77. * A request array.
  78. *
  79. * @return string
  80. * URL to the updates service.
  81. *
  82. * @see \Drupal\update\UpdateFetcher::buildFetchUrl()
  83. */
  84. public static function buildFetchUrl(array $request) {
  85. $status_url = isset($request['status url']) ? $request['status url'] : ReleaseInfo::DEFAULT_URL;
  86. return $status_url . '/' . $request['name'] . '/' . $request['drupal_version'];
  87. }
  88. /**
  89. * Parses update service xml.
  90. *
  91. * @param \SimpleXMLElement $xml
  92. * XML element from the updates service.
  93. *
  94. * @return array
  95. * Project update information.
  96. */
  97. private static function parseXml(\SimpleXMLElement $xml) {
  98. $project_info = array();
  99. // Extract general project info.
  100. $items = array('title', 'short_name', 'dc:creator', 'type', 'api_version',
  101. 'recommended_major', 'supported_majors', 'default_major',
  102. 'project_status', 'link',
  103. );
  104. foreach ($items as $item) {
  105. if (array_key_exists($item, (array)$xml)) {
  106. $value = $xml->xpath($item);
  107. $project_info[$item] = (string)$value[0];
  108. }
  109. }
  110. // Parse project type.
  111. $project_types = array(
  112. 'core' => 'project_core',
  113. 'profile' => 'project_distribution',
  114. 'module' => 'project_module',
  115. 'theme' => 'project_theme',
  116. 'theme engine' => 'project_theme_engine',
  117. 'translation' => 'project_translation',
  118. 'utility' => 'project_drupalorg',
  119. );
  120. $type = $project_info['type'];
  121. // Probably unused but kept for possible legacy compat.
  122. $type = ($type == 'profile-legacy') ? 'profile' : $type;
  123. $project_info['project_type'] = array_search($type, $project_types);
  124. // Extract project terms.
  125. $project_info['terms'] = array();
  126. if ($xml->terms) {
  127. foreach ($xml->terms->children() as $term) {
  128. $term_name = (string) $term->name;
  129. $term_value = (string) $term->value;
  130. if (!isset($project_info[$term_name])) {
  131. $project_info['terms'][$term_name] = array();
  132. }
  133. $project_info['terms'][$term_name][] = $term_value;
  134. }
  135. }
  136. // Extract and parse releases info.
  137. // In addition to the info in the update service, here we calculate
  138. // release statuses as Recommended, Security, etc.
  139. $recommended_major = empty($project_info['recommended_major']) ? '' : $project_info['recommended_major'];
  140. $supported_majors = empty($project_info['supported_majors']) ? array() : array_flip(explode(',', $project_info['supported_majors']));
  141. $items = array(
  142. 'name', 'date', 'status', 'type',
  143. 'version', 'tag', 'version_major', 'version_patch', 'version_extra',
  144. 'release_link', 'download_link', 'mdhash', 'filesize',
  145. );
  146. $releases = array();
  147. $releases_xml = @$xml->xpath("/project/releases/release[status='published']");
  148. foreach ($releases_xml as $release) {
  149. $release_info = array();
  150. $statuses = array();
  151. // Extract general release info.
  152. foreach ($items as $item) {
  153. if (array_key_exists($item, $release)) {
  154. $value = $release->xpath($item);
  155. $release_info[$item] = (string)$value[0];
  156. }
  157. }
  158. // Extract release terms.
  159. $release_info['terms'] = array();
  160. if ($release->terms) {
  161. foreach ($release->terms->children() as $term) {
  162. $term_name = (string) $term->name;
  163. $term_value = (string) $term->value;
  164. if (!isset($release_info['terms'][$term_name])) {
  165. $release_info['terms'][$term_name] = array();
  166. }
  167. $release_info['terms'][$term_name][] = $term_value;
  168. // Add "Security" for security updates, and nothing
  169. // for the other kinds.
  170. if (strpos($term_value, "Security") !== FALSE) {
  171. $statuses[] = "Security";
  172. }
  173. }
  174. }
  175. // Extract files.
  176. $release_info['files'] = array();
  177. foreach ($release->files->children() as $file) {
  178. // Normalize keys to match the ones in the release info.
  179. $item = array(
  180. 'download_link' => (string) $file->url,
  181. 'date' => (string) $file->filedate,
  182. 'mdhash' => (string) $file->md5,
  183. 'filesize' => (string) $file->size,
  184. 'archive_type' => (string) $file->archive_type,
  185. );
  186. if (!empty($file->variant)) {
  187. $item['variant'] = (string) $file->variant;
  188. }
  189. $release_info['files'][] = $item;
  190. }
  191. // Calculate statuses.
  192. if (array_key_exists($release_info['version_major'], $supported_majors)) {
  193. $statuses[] = "Supported";
  194. unset($supported_majors[$release_info['version_major']]);
  195. }
  196. if ($release_info['version_major'] == $recommended_major) {
  197. if (!isset($latest_version)) {
  198. $latest_version = $release_info['version'];
  199. }
  200. // The first stable version (no 'version extra') in the recommended major
  201. // is the recommended release
  202. if (empty($release_info['version_extra']) && (!isset($recommended_version))) {
  203. $statuses[] = "Recommended";
  204. $recommended_version = $release_info['version'];
  205. }
  206. }
  207. if (!empty($release_info['version_extra']) && ($release_info['version_extra'] == "dev")) {
  208. $statuses[] = "Development";
  209. }
  210. $release_info['release_status'] = $statuses;
  211. $releases[$release_info['version']] = $release_info;
  212. }
  213. // If there's no "Recommended major version", we want to recommend
  214. // the most recent release.
  215. if (!$recommended_major) {
  216. $latest_version = key($releases);
  217. }
  218. // If there is no -stable- release in the recommended major,
  219. // then take the latest version in the recommended major to be
  220. // the recommended release.
  221. if (!isset($recommended_version) && isset($latest_version)) {
  222. $recommended_version = $latest_version;
  223. $releases[$recommended_version]['release_status'][] = "Recommended";
  224. }
  225. $project_info['releases'] = $releases;
  226. if (isset($recommended_version)) {
  227. $project_info['recommended'] = $recommended_version;
  228. }
  229. return $project_info;
  230. }
  231. /**
  232. * Gets the project type.
  233. *
  234. * @return string
  235. * Type of the project.
  236. */
  237. public function getType() {
  238. return $this->parsed['project_type'];
  239. }
  240. /**
  241. * Gets the project status in the update service.
  242. *
  243. * This is the project status in drupal.org: insecure, revoked, published etc.
  244. *
  245. * @return string
  246. */
  247. public function getStatus() {
  248. return $this->project_status;
  249. }
  250. /**
  251. * Whether this object represents a project in the update service or an error.
  252. */
  253. public function isValid() {
  254. return ($this->error === FALSE);
  255. }
  256. /**
  257. * Gets the parsed xml.
  258. *
  259. * @return array or FALSE if the xml has an error.
  260. */
  261. public function getInfo() {
  262. return (!$this->error) ? $this->parsed : FALSE;
  263. }
  264. /**
  265. * Helper to pick the best release in a list of candidates.
  266. *
  267. * The best one is the first stable release if there are stable
  268. * releases; otherwise, it will be the first of the candidates.
  269. *
  270. * @param array $releases
  271. * Array of release arrays.
  272. *
  273. * @return array|bool
  274. */
  275. public static function getBestRelease(array $releases) {
  276. if (empty($releases)) {
  277. return FALSE;
  278. }
  279. else {
  280. // If there are releases found, let's try first to fetch one with no
  281. // 'version_extra'. Otherwise, use all.
  282. $stable_releases = array();
  283. foreach ($releases as $one_release) {
  284. if (!array_key_exists('version_extra', $one_release)) {
  285. $stable_releases[] = $one_release;
  286. }
  287. }
  288. if (!empty($stable_releases)) {
  289. $releases = $stable_releases;
  290. }
  291. }
  292. // First published release is just the first value in $releases.
  293. return reset($releases);
  294. }
  295. private function searchReleases($key, $value) {
  296. $releases = array();
  297. foreach ($this->parsed['releases'] as $version => $release) {
  298. if ($release['status'] == 'published' && isset($release[$key]) && strcmp($release[$key], $value) == 0) {
  299. $releases[$version] = $release;
  300. }
  301. }
  302. return $releases;
  303. }
  304. /**
  305. * Returns the specific release that matches the request version.
  306. *
  307. * @param string $version
  308. * Version of the release to pick.
  309. * @return array|bool
  310. * The release or FALSE if no version specified or no release found.
  311. */
  312. public function getSpecificRelease($version = NULL) {
  313. if (!empty($version)) {
  314. $matches = array();
  315. // See if we only have a branch version.
  316. if (preg_match('/^\d+\.x-(\d+)$/', $version, $matches)) {
  317. $releases = $this->searchReleases('version_major', $matches[1]);
  318. }
  319. else {
  320. // In some cases, the request only says something like '7.x-3.x' but the
  321. // version strings include '-dev' on the end, so we need to append that
  322. // here for the xpath to match below.
  323. if (substr($version, -2) == '.x') {
  324. $version .= '-dev';
  325. }
  326. $releases = $this->searchReleases('version', $version);
  327. }
  328. if (empty($releases)) {
  329. return FALSE;
  330. }
  331. return self::getBestRelease($releases);
  332. }
  333. return array();
  334. }
  335. /**
  336. * Pick the first dev release from XML list.
  337. *
  338. * @return array|bool
  339. * The selected release xml object or FALSE.
  340. */
  341. public function getDevRelease() {
  342. $releases = $this->searchReleases('version_extra', 'dev');
  343. return self::getBestRelease($releases);
  344. }
  345. /**
  346. * Pick most appropriate release from XML list.
  347. *
  348. * @return array|bool
  349. * The selected release xml object or FALSE.
  350. */
  351. public function getRecommendedOrSupportedRelease() {
  352. $majors = array();
  353. $recommended_major = empty($this->parsed['recommended_major']) ? 0 : $this->parsed['recommended_major'];
  354. if ($recommended_major != 0) {
  355. $majors[] = $this->parsed['recommended_major'];
  356. }
  357. if (!empty($this->parsed['supported_majors'])) {
  358. $supported = explode(',', $this->parsed['supported_majors']);
  359. foreach ($supported as $v) {
  360. if ($v != $recommended_major) {
  361. $majors[] = $v;
  362. }
  363. }
  364. }
  365. $releases = array();
  366. foreach ($majors as $major) {
  367. $releases = $this->searchReleases('version_major', $major);
  368. if (!empty($releases)) {
  369. break;
  370. }
  371. }
  372. return self::getBestRelease($releases);
  373. }
  374. /**
  375. * Comparison routine to order releases by date.
  376. *
  377. * @param array $a
  378. * Release to compare.
  379. * @param array $b
  380. * Release to compare.
  381. *
  382. * @return int
  383. * -1, 0 or 1 whether $a is greater, equal or lower than $b.
  384. */
  385. private static function compareDates(array $a, array $b) {
  386. if ($a['date'] == $b['date']) {
  387. return ($a['version_major'] > $b['version_major']) ? -1 : 1;
  388. }
  389. if ($a['version_major'] == $b['version_major']) {
  390. return ($a['date'] > $b['date']) ? -1 : 1;
  391. }
  392. return ($a['version_major'] > $b['version_major']) ? -1 : 1;
  393. }
  394. /**
  395. * Comparison routine to order releases by version.
  396. *
  397. * @param array $a
  398. * Release to compare.
  399. * @param array $b
  400. * Release to compare.
  401. *
  402. * @return int
  403. * -1, 0 or 1 whether $a is greater, equal or lower than $b.
  404. */
  405. private static function compareVersions(array $a, array $b) {
  406. $defaults = array(
  407. 'version_patch' => '',
  408. 'version_extra' => '',
  409. 'date' => 0,
  410. );
  411. $a += $defaults;
  412. $b += $defaults;
  413. if ($a['version_major'] != $b['version_major']) {
  414. return ($a['version_major'] > $b['version_major']) ? -1 : 1;
  415. }
  416. else if ($a['version_patch'] != $b['version_patch']) {
  417. return ($a['version_patch'] > $b['version_patch']) ? -1 : 1;
  418. }
  419. else if ($a['version_extra'] != $b['version_extra']) {
  420. // Don't rely on version_extra alphabetical order.
  421. return ($a['date'] > $b['date']) ? -1 : 1;
  422. }
  423. return 0;
  424. }
  425. /**
  426. * Filter project releases by a criteria and returns a list.
  427. *
  428. * If no filter is provided, the first Recommended, Supported, Security
  429. * or Development release on each major version will be shown.
  430. *
  431. * @param string $filter
  432. * Valid values:
  433. * - 'all': Select all releases.
  434. * - 'dev': Select all development releases.
  435. * @param string $installed_version
  436. * Version string. If provided, Select all releases in the same
  437. * version_major branch until the provided one is found.
  438. * On any other branch, the default behaviour will be applied.
  439. *
  440. * @return array
  441. * List of releases matching the filter criteria.
  442. */
  443. function filterReleases($filter = '', $installed_version = NULL) {
  444. $releases = $this->parsed['releases'];
  445. usort($releases, array($this, 'compareDates'));
  446. $installed_version = pm_parse_version($installed_version);
  447. // Iterate through and filter out the releases we're interested in.
  448. $options = array();
  449. $limits_list = array();
  450. foreach ($releases as $release) {
  451. $eligible = FALSE;
  452. // Mark as eligible if the filter criteria matches.
  453. if ($filter == 'all') {
  454. $eligible = TRUE;
  455. }
  456. elseif ($filter == 'dev') {
  457. if (!empty($release['version_extra']) && ($release['version_extra'] == 'dev')) {
  458. $eligible = TRUE;
  459. }
  460. }
  461. // The Drupal core version scheme (ex: 7.31) is different to
  462. // other projects (ex 7.x-3.2). We need to manage this special case.
  463. elseif (($this->getType() != 'core') && ($installed_version['version_major'] == $release['version_major'])) {
  464. // In case there's no filter, select all releases until the installed one.
  465. // Always show the dev release.
  466. if (isset($release['version_extra']) && ($release['version_extra'] == 'dev')) {
  467. $eligible = TRUE;
  468. }
  469. else {
  470. if (self::compareVersions($release, $installed_version) < 1) {
  471. $eligible = TRUE;
  472. }
  473. }
  474. }
  475. // Otherwise, pick only the first release in each status.
  476. // For example after we pick out the first security release,
  477. // we won't pick any other. We do this on a per-major-version basis,
  478. // though, so if a project has three major versions, then we will
  479. // pick out the first security release from each.
  480. else {
  481. foreach ($release['release_status'] as $one_status) {
  482. $test_key = $release['version_major'] . $one_status;
  483. if (empty($limits_list[$test_key])) {
  484. $limits_list[$test_key] = TRUE;
  485. $eligible = TRUE;
  486. }
  487. }
  488. }
  489. if ($eligible) {
  490. $options[$release['version']] = $release;
  491. }
  492. }
  493. // Add Installed status.
  494. if (!is_null($installed_version) && isset($options[$installed_version['version']])) {
  495. $options[$installed_version['version']]['release_status'][] = 'Installed';
  496. }
  497. return $options;
  498. }
  499. /**
  500. * Prints release notes for given projects.
  501. *
  502. * @param string $version
  503. * Version of the release to get notes.
  504. * @param bool $print_status
  505. * Whether to print a informative note.
  506. * @param string $tmpfile
  507. * If provided, a file that contains contents to show before the
  508. * release notes.
  509. */
  510. function getReleaseNotes($version = NULL, $print_status = TRUE, $tmpfile = NULL) {
  511. $project_name = $this->parsed['short_name'];
  512. if (!isset($tmpfile)) {
  513. $tmpfile = drush_tempnam('rln-' . $project_name . '.');
  514. }
  515. // Select versions to show.
  516. $versions = array();
  517. if (!is_null($version)) {
  518. $versions[] = $version;
  519. }
  520. else {
  521. // If requested project is installed,
  522. // show release notes for the installed version and all newer versions.
  523. if (isset($this->parsed['recommended'], $this->parsed['installed'])) {
  524. $releases = array_reverse($this->parsed['releases']);
  525. foreach($releases as $version => $release) {
  526. if ($release['date'] >= $this->parsed['releases'][$this->parsed['installed']]['date']) {
  527. $release += array('version_extra' => '');
  528. $this->parsed['releases'][$this->parsed['installed']] += array('version_extra' => '');
  529. if ($release['version_extra'] == 'dev' && $this->parsed['releases'][$this->parsed['installed']]['version_extra'] != 'dev') {
  530. continue;
  531. }
  532. $versions[] = $version;
  533. }
  534. }
  535. }
  536. else {
  537. // Project is not installed and user did not specify a version,
  538. // so show the release notes for the recommended version.
  539. $versions[] = $this->parsed['recommended'];
  540. }
  541. }
  542. foreach ($versions as $version) {
  543. if (!isset($this->parsed['releases'][$version]['release_link'])) {
  544. drush_log(dt("Project !project does not have release notes for version !version.", array('!project' => $project_name, '!version' => $version)), LogLevel::WARNING);
  545. continue;
  546. }
  547. // Download the release node page and get the html as xml to explore it.
  548. $release_link = $this->parsed['releases'][$version]['release_link'];
  549. $filename = drush_download_file($release_link, drush_tempnam($project_name));
  550. @$dom = \DOMDocument::loadHTMLFile($filename);
  551. if ($dom) {
  552. drush_log(dt("Successfully parsed and loaded the HTML contained in the release notes' page for !project (!version) project.", array('!project' => $project_name, '!version' => $version)), LogLevel::INFO);
  553. }
  554. else {
  555. drush_log(dt("Error while requesting the release notes page for !project project.", array('!project' => $project_name)), LogLevel::ERROR);
  556. continue;
  557. }
  558. $xml = simplexml_import_dom($dom);
  559. // Extract last update time and the notes.
  560. $last_updated = $xml->xpath('//div[contains(@class,"views-field-changed")]');
  561. $last_updated = $last_updated[0]->asXML();
  562. $notes = $xml->xpath('//div[contains(@class,"field-name-body")]');
  563. $notes = (!empty($notes)) ? $notes[0]->asXML() : dt("There're no release notes.");
  564. // Build the notes header.
  565. $header = array();
  566. $header[] = '<hr>';
  567. $header[] = dt("> RELEASE NOTES FOR '!name' PROJECT, VERSION !version:", array('!name' => strtoupper($project_name), '!version' => $version));
  568. $header[] = dt("> !last_updated.", array('!last_updated' => trim(drush_html_to_text($last_updated))));
  569. if ($print_status) {
  570. $header[] = '> ' . implode(', ', $this->parsed['releases'][$version]['release_status']);
  571. }
  572. $header[] = '<hr>';
  573. // Finally add the release notes for the requested project to the tmpfile.
  574. $content = implode("\n", $header) . "\n" . $notes . "\n";
  575. #TODO# accept $html as a method argument
  576. if (!drush_get_option('html', FALSE)) {
  577. $content = drush_html_to_text($content, array('br', 'p', 'ul', 'ol', 'li', 'hr'));
  578. }
  579. file_put_contents($tmpfile, $content, FILE_APPEND);
  580. }
  581. #TODO# don't print! Just return the filename
  582. drush_print_file($tmpfile);
  583. }
  584. }