3ef114e30e887e6c15c2c7dc65002d80c365c2b3
[Packages/TYPO3.CMS.git] / typo3 / sysext / install / Classes / UpgradeAnalysis / DocumentationFile.php
1 <?php
2 declare(strict_types = 1);
3
4 namespace TYPO3\CMS\Install\UpgradeAnalysis;
5
6 /*
7 * This file is part of the TYPO3 CMS project.
8 *
9 * It is free software; you can redistribute it and/or modify it under
10 * the terms of the GNU General Public License, either version 2
11 * of the License, or any later version.
12 *
13 * For the full copyright and license information, please read the
14 * LICENSE.txt file that was distributed with this source code.
15 *
16 * The TYPO3 project - inspiring people to share!
17 */
18
19 use TYPO3\CMS\Core\Registry;
20 use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
21 use TYPO3\CMS\Core\Utility\GeneralUtility;
22 use TYPO3\CMS\Core\Utility\PathUtility;
23
24 /**
25 * Provide information about documentation files
26 */
27 class DocumentationFile
28 {
29 /**
30 * @var Registry
31 */
32 protected $registry;
33
34 /**
35 * @var array Unified array of used tags
36 */
37 protected $tagsTotal = [];
38
39 /**
40 * all files handled in this Class need to reside inside the changelog dir
41 * this is a security measure to protect system files
42 *
43 * @var string
44 */
45 protected $changelogPath = '';
46
47 /**
48 * DocumentationFile constructor.
49 * @param Registry|null $registry
50 */
51 public function __construct(Registry $registry = null, $changelogDir = '')
52 {
53 $this->registry = $registry;
54 if ($this->registry === null) {
55 $this->registry = new Registry();
56 }
57 $this->changelogPath = $changelogDir !== '' ? $changelogDir : realpath(ExtensionManagementUtility::extPath('core') . 'Documentation/Changelog');
58 $this->changelogPath = strtr($this->changelogPath, '\\', '/');
59 }
60
61 /**
62 * Traverse given directory, select files
63 *
64 * @param string $path
65 * @return array file details of affected documentation files
66 * @throws \InvalidArgumentException
67 */
68 public function findDocumentationFiles(string $path): array
69 {
70 if (strcasecmp($path, $this->changelogPath) < 0 || strpos($path, $this->changelogPath) === false) {
71 throw new \InvalidArgumentException('the given path does not belong to the changelog dir. Aborting', 1485425530);
72 }
73
74 $documentationFiles = [];
75 $versionDirectories = scandir($path);
76
77 $fileInfo = pathinfo($path);
78 $absolutePath = strtr($fileInfo['dirname'], '\\', '/') . '/' . $fileInfo['basename'];
79 foreach ($versionDirectories as $version) {
80 $directory = $absolutePath . '/' . $version;
81 $documentationFiles += $this->getDocumentationFilesForVersion($directory, $version);
82 }
83 $this->tagsTotal = $this->collectTagTotal($documentationFiles);
84
85 return $documentationFiles;
86 }
87
88 /**
89 * Get main information from a .rst file
90 *
91 * @param string $file Absolute path to documentation file
92 * @return array
93 */
94 public function getListEntry(string $file): array
95 {
96 if (strcasecmp($file, $this->changelogPath) < 0 || strpos($file, $this->changelogPath) === false) {
97 throw new \InvalidArgumentException('the given file does not belong to the changelog dir. Aborting', 1485425531);
98 }
99 $lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
100 $headline = $this->extractHeadline($lines);
101 $entry['headline'] = $headline;
102 $entry['filepath'] = $file;
103 $entry['tags'] = $this->extractTags($lines);
104 $entry['class'] = 'default';
105 foreach ($entry['tags'] as $tag) {
106 if (strpos($tag, 'cat:') !== false) {
107 $entry['class'] = strtolower(substr($tag, 4));
108 }
109 }
110 $entry['tagList'] = implode(',', $entry['tags']);
111 $entry['content'] = file_get_contents($file);
112 $entry['parsedContent'] = $this->parseContent($entry['content']);
113 $entry['file_hash'] = md5($entry['content']);
114 $issueNumber = $this->extractIssueNumber($headline);
115
116 return [$issueNumber => $entry];
117 }
118
119 /**
120 * True if file should be considered
121 *
122 * @param array $fileInfo
123 * @return bool
124 */
125 protected function isRelevantFile(array $fileInfo): bool
126 {
127 $isRelevantFile = $fileInfo['extension'] === 'rst' && $fileInfo['filename'] !== 'Index';
128 // file might be ignored by users choice
129 if ($isRelevantFile && $this->isFileIgnoredByUsersChoice($fileInfo['basename'])) {
130 $isRelevantFile = false;
131 }
132
133 return $isRelevantFile;
134 }
135
136 /**
137 * Add tags from file
138 *
139 * @param array $file file content, each line is an array item
140 * @return array
141 */
142 protected function extractTags(array $file): array
143 {
144 $tags = $this->extractTagsFromFile($file);
145 // Headline starting with the category like Breaking, Important or Feature
146 $tags[] = $this->extractCategoryFromHeadline($file);
147 natcasesort($tags);
148
149 return $tags;
150 }
151
152 /**
153 * Files must contain an index entry, detailing any number of manual tags
154 * each of these tags is extracted and added to the general tag structure for the file
155 *
156 * @param array $file file content, each line is an array item
157 * @return array extracted tags
158 */
159 protected function extractTagsFromFile(array $file): array
160 {
161 foreach ($file as $line) {
162 if (strpos($line, '.. index::') === 0) {
163 $tagString = substr($line, strlen('.. index:: '));
164 return GeneralUtility::trimExplode(',', $tagString, true);
165 }
166 }
167
168 return [];
169 }
170
171 /**
172 * Files contain a headline (provided as input parameter,
173 * it starts with the category string.
174 * This will used as a tag
175 *
176 * @param array $lines
177 * @return string
178 */
179 protected function extractCategoryFromHeadline(array $lines): string
180 {
181 $headline = $this->extractHeadline($lines);
182 if (strpos($headline, ':') !== false) {
183 return 'cat:' . substr($headline, 0, strpos($headline, ':'));
184 }
185
186 return '';
187 }
188
189 /**
190 * Skip include line and markers, use the first line actually containing text
191 *
192 * @param array $lines
193 * @return string
194 */
195 protected function extractHeadline(array $lines): string
196 {
197 $index = 0;
198 while (strpos($lines[$index], '..') === 0 || strpos($lines[$index], '==') === 0) {
199 $index++;
200 }
201 return trim($lines[$index]);
202 }
203
204 /**
205 * Get issue number from headline
206 *
207 * @param string $headline
208 * @return int
209 */
210 protected function extractIssueNumber(string $headline): int
211 {
212 return (int)substr($headline, strpos($headline, '#') + 1, 5);
213 }
214
215 /**
216 * True for real directories and a valid version
217 *
218 * @param string $versionDirectory
219 * @param string $version
220 * @return bool
221 */
222 protected function isRelevantDirectory(string $versionDirectory, string $version): bool
223 {
224 return is_dir($versionDirectory) && $version !== '.' && $version !== '..';
225 }
226
227 /**
228 * Handle a single directory
229 *
230 * @param string $docDirectory
231 * @param string $version
232 * @return array
233 */
234 protected function getDocumentationFilesForVersion(
235 string $docDirectory,
236 string $version
237 ): array {
238 $documentationFiles = [];
239 if ($this->isRelevantDirectory($docDirectory, $version)) {
240 $documentationFiles[$version] = [];
241 $absolutePath = strtr(PathUtility::dirname($docDirectory), '\\', '/') . '/' . $version;
242 $rstFiles = scandir($docDirectory);
243 foreach ($rstFiles as $file) {
244 $fileInfo = pathinfo($file);
245 if ($this->isRelevantFile($fileInfo)) {
246 $filePath = $absolutePath . '/' . $fileInfo['basename'];
247 $documentationFiles[$version] += $this->getListEntry($filePath);
248 }
249 }
250 }
251
252 return $documentationFiles;
253 }
254
255 /**
256 * Merge tag list
257 *
258 * @param $documentationFiles
259 * @return array
260 */
261 protected function collectTagTotal($documentationFiles): array
262 {
263 $tags = [];
264 foreach ($documentationFiles as $versionArray) {
265 foreach ($versionArray as $fileArray) {
266 $tags = array_merge(array_unique($tags), $fileArray['tags']);
267 }
268 }
269
270 return array_unique($tags);
271 }
272
273 /**
274 * Return full tag list
275 *
276 * @return array
277 */
278 public function getTagsTotal(): array
279 {
280 return $this->tagsTotal;
281 }
282
283 /**
284 * whether that file has been removed from users view
285 *
286 * @param string $filename
287 * @return bool
288 */
289 protected function isFileIgnoredByUsersChoice(string $filename): bool
290 {
291 $isFileIgnoredByUsersChoice = false;
292
293 $ignoredFiles = $this->registry->get('upgradeAnalysisIgnoreFilter', 'ignoredDocumentationFiles');
294 if (is_array($ignoredFiles)) {
295 foreach ($ignoredFiles as $filePath) {
296 if ($filePath !== null && strlen($filePath) > 0) {
297 if (strpos($filePath, $filename) !== false) {
298 $isFileIgnoredByUsersChoice = true;
299 break;
300 }
301 }
302 }
303 }
304 return $isFileIgnoredByUsersChoice;
305 }
306
307 /**
308 * @param string $rstContent
309 *
310 * @return string
311 * @throws \InvalidArgumentException
312 */
313 protected function parseContent(string $rstContent): string
314 {
315 $content = htmlspecialchars($rstContent);
316 $content = preg_replace('/:issue:`([\d]*)`/', '<a href="https://forge.typo3.org/issues/\\1" target="_blank">\\1</a>', $content);
317 $content = preg_replace('/#([\d]*)/', '#<a href="https://forge.typo3.org/issues/\\1" target="_blank">\\1</a>', $content);
318 $content = preg_replace('/(\n([=]*)\n(.*)\n([=]*)\n)/', '', $content, 1);
319 $content = preg_replace('/.. index::(.*)/', '', $content);
320 $content = preg_replace('/.. include::(.*)/', '', $content);
321 return trim($content);
322 }
323 }