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