Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 167 |
|
0.00% |
0 / 16 |
CRAP | |
0.00% |
0 / 1 |
ConvertToText | |
0.00% |
0 / 161 |
|
0.00% |
0 / 16 |
2756 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
0.00% |
0 / 35 |
|
0.00% |
0 / 1 |
56 | |||
flowApi | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
processTopic | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
90 | |||
loadUser | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
processSummary | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
processPostCollection | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
42 | |||
getSignature | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
42 | |||
formatTimestamp | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
pageExists | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
getAllRevisions | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 | |||
processHeader | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
processMultiRevisions | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
90 | |||
getAllPostRevisions | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
processPost | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
processTopicTitle | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace Flow\Maintenance; |
4 | |
5 | use Flow\Import\LiquidThreadsApi\ApiBackend; |
6 | use Flow\Import\LiquidThreadsApi\LocalApiBackend; |
7 | use Flow\Import\LiquidThreadsApi\RemoteApiBackend; |
8 | use Flow\Model\AbstractRevision; |
9 | use Maintenance; |
10 | use MediaWiki\Parser\Parser; |
11 | use MediaWiki\Title\Title; |
12 | use MediaWiki\User\User; |
13 | use MediaWiki\Utils\MWTimestamp; |
14 | use ParserOptions; |
15 | |
16 | $IP = getenv( 'MW_INSTALL_PATH' ); |
17 | if ( $IP === false ) { |
18 | $IP = __DIR__ . '/../../..'; |
19 | } |
20 | |
21 | require_once "$IP/maintenance/Maintenance.php"; |
22 | |
23 | class ConvertToText extends Maintenance { |
24 | /** |
25 | * @var Title |
26 | */ |
27 | private $pageTitle; |
28 | |
29 | /** |
30 | * @var ApiBackend |
31 | */ |
32 | private $api; |
33 | |
34 | /** @inheritDoc */ |
35 | public function __construct() { |
36 | parent::__construct(); |
37 | $this->addDescription( "Converts a specific Flow page to text" ); |
38 | |
39 | $this->addOption( 'page', 'The page to convert', true, true ); |
40 | $this->addOption( 'remoteapi', 'URL to api.php (leave unset for local wiki)', false, true ); |
41 | |
42 | $this->requireExtension( 'Flow' ); |
43 | } |
44 | |
45 | /** @inheritDoc */ |
46 | public function execute() { |
47 | $pageName = $this->getOption( 'page' ); |
48 | $this->pageTitle = Title::newFromText( $pageName ); |
49 | |
50 | if ( !$this->pageTitle ) { |
51 | $this->fatalError( 'Invalid page title' ); |
52 | } |
53 | |
54 | if ( $this->getOption( 'remoteapi' ) ) { |
55 | $this->api = new RemoteApiBackend( $this->getOption( 'remoteapi' ) ); |
56 | } else { |
57 | $this->api = new LocalApiBackend(); |
58 | } |
59 | |
60 | $headerContent = $this->processHeader(); |
61 | |
62 | $continue = true; |
63 | $pagerParams = [ 'vtllimit' => 1 ]; |
64 | $topics = []; |
65 | while ( $continue ) { |
66 | $continue = false; |
67 | $flowData = $this->flowApi( |
68 | $this->pageTitle, |
69 | 'view-topiclist', |
70 | $pagerParams + [ 'vtlformat' => 'wikitext', 'vtlsortby' => 'newest' ] |
71 | ); |
72 | |
73 | $topicListBlock = $flowData['topiclist']; |
74 | |
75 | foreach ( $topicListBlock['roots'] as $rootPostId ) { |
76 | $revisionId = reset( $topicListBlock['posts'][$rootPostId] ); |
77 | $revision = $topicListBlock['revisions'][$revisionId]; |
78 | |
79 | $topics[] = $this->processTopic( $topicListBlock, $revision ); |
80 | } |
81 | |
82 | if ( isset( $topicListBlock['links']['pagination'] ) ) { |
83 | $paginationLinks = $topicListBlock['links']['pagination']; |
84 | if ( isset( $paginationLinks['fwd'] ) ) { |
85 | [ , $query ] = explode( '?', $paginationLinks['fwd']['url'] ); |
86 | $queryParams = wfCgiToArray( $query ); |
87 | |
88 | $pagerParams = [ |
89 | 'vtloffset-id' => $queryParams['topiclist_offset-id'], |
90 | 'vtloffset-dir' => 'fwd', |
91 | 'vtloffset-limit' => '1', |
92 | ]; |
93 | $continue = true; |
94 | } |
95 | } |
96 | } |
97 | |
98 | print $headerContent . "\n\n" . implode( "\n", array_reverse( $topics ) ); |
99 | } |
100 | |
101 | /** |
102 | * @param Title $title |
103 | * @param string $submodule |
104 | * @param array $request |
105 | * @return array |
106 | */ |
107 | private function flowApi( Title $title, $submodule, array $request ) { |
108 | $result = $this->api->apiCall( $request + [ |
109 | 'action' => 'flow', |
110 | 'submodule' => $submodule, |
111 | 'page' => $title->getPrefixedText(), |
112 | ] ); |
113 | |
114 | if ( isset( $result['error'] ) ) { |
115 | $this->fatalError( $result['error']['info'] ); |
116 | } |
117 | return $result['flow'][$submodule]['result']; |
118 | } |
119 | |
120 | /** |
121 | * @param array $context |
122 | * @param array $revision |
123 | * @return string |
124 | */ |
125 | private function processTopic( array $context, array $revision ) { |
126 | $topicOutput = $this->processTopicTitle( $revision ); |
127 | $summaryOutput = isset( $revision['summary'] ) ? $this->processSummary( $context, $revision['summary'] ) : ''; |
128 | $postsOutput = $this->processPostCollection( $context, $revision['replies'] ) . "\n\n"; |
129 | $resolved = isset( $revision['moderateState'] ) && $revision['moderateState'] === AbstractRevision::MODERATED_LOCKED; |
130 | |
131 | // check if "resolved" templates exist |
132 | $archiveTemplates = $this->pageExists( 'Template:Archive_top' ) && $this->pageExists( 'Template:Archive_bottom' ); |
133 | $hatnoteTemplate = $this->pageExists( 'Template:Hatnote' ); |
134 | |
135 | if ( $archiveTemplates && $resolved ) { |
136 | return '{{Archive top|result=' . $summaryOutput . "|status=resolved}}\n\n" . |
137 | $topicOutput . $postsOutput . "{{Archive bottom}}\n\n"; |
138 | } elseif ( $hatnoteTemplate && $summaryOutput ) { |
139 | return $topicOutput . '{{Hatnote|' . $summaryOutput . "}}\n\n" . $postsOutput; |
140 | } else { |
141 | // italicize summary, if there is any, to set it apart from posts |
142 | $summaryOutput = $summaryOutput ? "''" . $summaryOutput . "''\n\n" : ''; |
143 | return $topicOutput . $summaryOutput . $postsOutput; |
144 | } |
145 | } |
146 | |
147 | /** |
148 | * @param int $id |
149 | * @param string $name |
150 | * @return User |
151 | */ |
152 | private function loadUser( $id, $name ) { |
153 | return User::newFromRow( (object)[ 'user_name' => $name, 'user_id' => $id ] ); |
154 | } |
155 | |
156 | /** |
157 | * @param array $context |
158 | * @param array $summary |
159 | * @return string |
160 | */ |
161 | private function processSummary( array $context, array $summary ) { |
162 | $topicTitle = Title::newFromText( $summary['revision']['articleTitle'] ); |
163 | return $this->processMultiRevisions( |
164 | $this->getAllRevisions( $topicTitle, 'view-topic-summary', 'vts', 'topicsummary' ) |
165 | ); |
166 | } |
167 | |
168 | /** |
169 | * @param array $context |
170 | * @param int[] $collection |
171 | * @param int $indentLevel |
172 | * @return string |
173 | */ |
174 | private function processPostCollection( array $context, array $collection, $indentLevel = 0 ) { |
175 | $indent = str_repeat( ':', $indentLevel ); |
176 | $output = ''; |
177 | |
178 | foreach ( $collection as $postId ) { |
179 | $revisionId = reset( $context['posts'][$postId] ); |
180 | $revision = $context['revisions'][$revisionId]; |
181 | |
182 | // Skip moderated posts |
183 | if ( $revision['isModerated'] ) { |
184 | continue; |
185 | } |
186 | |
187 | $thisPost = $indent . $this->processPost( $revision ); |
188 | |
189 | if ( $indentLevel > 0 ) { |
190 | $thisPost = preg_replace( "/\n+/", "\n$indent", $thisPost ); |
191 | } |
192 | $output .= $thisPost . "\n"; |
193 | |
194 | if ( isset( $revision['replies'] ) ) { |
195 | $output .= $this->processPostCollection( $context, $revision['replies'], $indentLevel + 1 ); |
196 | } |
197 | |
198 | if ( $indentLevel == 0 ) { |
199 | $output .= "\n"; |
200 | } |
201 | } |
202 | |
203 | return $output; |
204 | } |
205 | |
206 | /** |
207 | * @param array $user |
208 | * @param string|false $timestamp |
209 | * @return string |
210 | */ |
211 | private function getSignature( array $user, $timestamp = false ) { |
212 | if ( !$user ) { |
213 | $signature = '[Unknown user]'; |
214 | if ( $timestamp ) { |
215 | $signature .= ' ' . $this->formatTimestamp( $timestamp ); |
216 | } |
217 | return $signature; |
218 | } |
219 | |
220 | // create a bogus user for whom username & id is known, so we |
221 | // can generate a correct signature |
222 | $user = $this->loadUser( $user['id'], $user['name'] ); |
223 | |
224 | // nickname & fancysig are user options: unless we're on local wiki, |
225 | // we don't know these & can't load them to generate the signature |
226 | $nickname = $this->getOption( 'remoteapi' ) ? null : false; |
227 | $fancysig = $this->getOption( 'remoteapi' ) ? false : null; |
228 | |
229 | $parser = $this->getServiceContainer()->getParserFactory()->create(); |
230 | // Parser::getUserSig can end calling `getCleanSignatures` on |
231 | // mOptions, which may not be set. Set a dummy options object so it |
232 | // doesn't fail (it'll initialise the requested value from a global |
233 | // anyway) |
234 | $options = ParserOptions::newFromAnon(); |
235 | $parser->startExternalParse( $this->pageTitle, $options, Parser::OT_WIKI ); |
236 | $signature = $parser->getUserSig( $user, $nickname, $fancysig ); |
237 | $signature = $parser->getStripState()->unstripBoth( $signature ); |
238 | if ( $timestamp ) { |
239 | $signature .= ' ' . $this->formatTimestamp( $timestamp ); |
240 | } |
241 | return $signature; |
242 | } |
243 | |
244 | /** |
245 | * @param string $timestamp |
246 | * @return string |
247 | */ |
248 | private function formatTimestamp( $timestamp ) { |
249 | $timestamp = MWTimestamp::getLocalInstance( $timestamp ); |
250 | $ts = $timestamp->format( 'YmdHis' ); |
251 | $tzMsg = $timestamp->format( 'T' ); # might vary on DST changeover! |
252 | |
253 | # Allow translation of timezones through wiki. format() can return |
254 | # whatever crap the system uses, localised or not, so we cannot |
255 | # ship premade translations. |
256 | $key = 'timezone-' . strtolower( trim( $tzMsg ) ); |
257 | $msg = wfMessage( $key )->inContentLanguage(); |
258 | if ( $msg->exists() ) { |
259 | $tzMsg = $msg->text(); |
260 | } |
261 | |
262 | return $this->getServiceContainer()->getContentLanguage() |
263 | ->timeanddate( $ts, false, false ) . " ($tzMsg)"; |
264 | } |
265 | |
266 | /** |
267 | * @param string $pageName |
268 | * @return bool |
269 | */ |
270 | private function pageExists( $pageName ) { |
271 | static $pages = []; |
272 | |
273 | if ( !isset( $pages[$pageName] ) ) { |
274 | $result = $this->api->apiCall( [ 'action' => 'query', 'titles' => $pageName ] ); |
275 | $pages[$pageName] = !isset( $result['query']['pages'][-1] ); |
276 | } |
277 | |
278 | return $pages[$pageName]; |
279 | } |
280 | |
281 | /** |
282 | * @param Title $pageTitle |
283 | * @param string $submodule |
284 | * @param string $prefix |
285 | * @param string $responseRoot |
286 | * @return array[] |
287 | */ |
288 | private function getAllRevisions( Title $pageTitle, $submodule, $prefix, $responseRoot ) { |
289 | $params = [ $prefix . 'format' => 'wikitext' ]; |
290 | $headerRevisions = []; |
291 | |
292 | do { |
293 | $headerData = $this->flowApi( $pageTitle, $submodule, $params ); |
294 | if ( !isset( $headerData[$responseRoot]['revision']['revisionId'] ) ) { |
295 | break; |
296 | } |
297 | |
298 | $headerRevision = $headerData[$responseRoot]['revision']; |
299 | $headerRevisions[] = $headerRevision; |
300 | |
301 | $revId = $headerRevision['previousRevisionId']; |
302 | $params[$prefix . 'revId'] = $revId; |
303 | } while ( $revId ); |
304 | |
305 | return $headerRevisions; |
306 | } |
307 | |
308 | /** |
309 | * @return string |
310 | */ |
311 | private function processHeader() { |
312 | return $this->processMultiRevisions( |
313 | $this->getAllRevisions( $this->pageTitle, 'view-header', 'vh', 'header' ), |
314 | false, |
315 | 'flow-edited-by-header' |
316 | ); |
317 | } |
318 | |
319 | /** |
320 | * @param array[] $allRevisions |
321 | * @param bool $sigForFirstAuthor |
322 | * @param string $msg |
323 | * @param string $glueAfterContent |
324 | * @param string $glueBeforeAuthors |
325 | * @return string |
326 | */ |
327 | private function processMultiRevisions( |
328 | array $allRevisions, |
329 | $sigForFirstAuthor = true, |
330 | $msg = 'flow-edited-by', |
331 | $glueAfterContent = '', |
332 | $glueBeforeAuthors = ' ' |
333 | ) { |
334 | if ( !$allRevisions ) { |
335 | return ''; |
336 | } |
337 | |
338 | $firstRevision = end( $allRevisions ); |
339 | $latestRevision = reset( $allRevisions ); |
340 | |
341 | // take the content from the first (most recent) revision |
342 | $content = $latestRevision['content']['content']; |
343 | $firstContributor = $firstRevision['author']; |
344 | |
345 | // deduplicate authors |
346 | $otherContributors = []; |
347 | foreach ( $allRevisions as $revision ) { |
348 | $name = $revision['author']['name']; |
349 | $otherContributors[$name] = $revision['author']; |
350 | } |
351 | |
352 | $formattedAuthors = ''; |
353 | if ( $sigForFirstAuthor ) { |
354 | $formattedAuthors .= $this->getSignature( $firstContributor, $firstRevision['timestamp'] ); |
355 | // remove first contributor from list of previous contributors |
356 | unset( $otherContributors[$firstContributor['name']] ); |
357 | } |
358 | |
359 | if ( $otherContributors && |
360 | ( count( $otherContributors ) > 1 || !isset( $otherContributors[$firstContributor['name']] ) ) |
361 | ) { |
362 | $signatures = array_map( [ $this, 'getSignature' ], $otherContributors ); |
363 | $formattedAuthors .= ( $sigForFirstAuthor ? ' ' : '' ) . '(' . |
364 | wfMessage( $msg )->inContentLanguage()->params( |
365 | $this->getServiceContainer()->getContentLanguage()->commaList( $signatures ) |
366 | )->text() . ')'; |
367 | } |
368 | |
369 | return $content . $glueAfterContent . ( $formattedAuthors === '' ? '' : $glueBeforeAuthors . $formattedAuthors ); |
370 | } |
371 | |
372 | /** |
373 | * @param array $revision |
374 | * @return array[] |
375 | */ |
376 | private function getAllPostRevisions( array $revision ) { |
377 | $topicTitle = Title::newFromText( $revision['articleTitle'] ); |
378 | $response = $this->flowApi( $topicTitle, 'view-post-history', [ 'vphpostId' => $revision['postId'], 'vphformat' => 'wikitext' ] ); |
379 | return $response['topic']['revisions']; |
380 | } |
381 | |
382 | /** |
383 | * @param array $revision |
384 | * @return string |
385 | */ |
386 | private function processPost( array $revision ) { |
387 | return $this->processMultiRevisions( $this->getAllPostRevisions( $revision ) ); |
388 | } |
389 | |
390 | /** |
391 | * @param array $revision |
392 | * @return string |
393 | */ |
394 | private function processTopicTitle( array $revision ) { |
395 | return '==' . $this->processMultiRevisions( |
396 | $this->getAllPostRevisions( $revision ), |
397 | false, |
398 | 'flow-edited-by-topic-title', |
399 | '==', |
400 | "\n\n" |
401 | ) . "\n\n"; |
402 | } |
403 | |
404 | } |
405 | |
406 | $maintClass = ConvertToText::class; |
407 | require_once RUN_MAINTENANCE_IF_MAIN; |