Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 169
0.00% covered (danger)
0.00%
0 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
ConvertToText
0.00% covered (danger)
0.00%
0 / 163
0.00% covered (danger)
0.00%
0 / 16
2756
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
56
 flowApi
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 processTopic
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
90
 loadUser
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 processSummary
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 processPostCollection
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
42
 getSignature
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
42
 formatTimestamp
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 pageExists
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getAllRevisions
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 processHeader
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 processMultiRevisions
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
90
 getAllPostRevisions
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 processPost
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 processTopicTitle
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace Flow\Maintenance;
4
5use Flow\Import\LiquidThreadsApi\ApiBackend;
6use Flow\Import\LiquidThreadsApi\LocalApiBackend;
7use Flow\Import\LiquidThreadsApi\RemoteApiBackend;
8use Flow\Model\AbstractRevision;
9use MediaWiki\Maintenance\Maintenance;
10use MediaWiki\Parser\Parser;
11use MediaWiki\Parser\ParserOptions;
12use MediaWiki\Title\Title;
13use MediaWiki\User\User;
14use MediaWiki\Utils\MWTimestamp;
15
16$IP = getenv( 'MW_INSTALL_PATH' );
17if ( $IP === false ) {
18    $IP = __DIR__ . '/../../..';
19}
20
21require_once "$IP/maintenance/Maintenance.php";
22
23class 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( $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 $summary
158     * @return string
159     */
160    private function processSummary( array $summary ) {
161        $topicTitle = Title::newFromText( $summary['revision']['articleTitle'] );
162        return $this->processMultiRevisions(
163            $this->getAllRevisions( $topicTitle, 'view-topic-summary', 'vts', 'topicsummary' )
164        );
165    }
166
167    /**
168     * @param array $context
169     * @param int[] $collection
170     * @param int $indentLevel
171     * @return string
172     */
173    private function processPostCollection( array $context, array $collection, $indentLevel = 0 ) {
174        $indent = str_repeat( ':', $indentLevel );
175        $output = '';
176
177        foreach ( $collection as $postId ) {
178            $revisionId = reset( $context['posts'][$postId] );
179            $revision = $context['revisions'][$revisionId];
180
181            // Skip moderated posts
182            if ( $revision['isModerated'] ) {
183                continue;
184            }
185
186            $thisPost = $indent . $this->processPost( $revision );
187
188            if ( $indentLevel > 0 ) {
189                $thisPost = preg_replace( "/\n+/", "\n$indent", $thisPost );
190            }
191            $output .= $thisPost . "\n";
192
193            if ( isset( $revision['replies'] ) ) {
194                $output .= $this->processPostCollection( $context, $revision['replies'], $indentLevel + 1 );
195            }
196
197            if ( $indentLevel == 0 ) {
198                $output .= "\n";
199            }
200        }
201
202        return $output;
203    }
204
205    /**
206     * @param array $user
207     * @param string|false $timestamp
208     * @return string
209     */
210    private function getSignature( array $user, $timestamp = false ) {
211        if ( !$user ) {
212            $signature = '[Unknown user]';
213            if ( $timestamp ) {
214                $signature .= ' ' . $this->formatTimestamp( $timestamp );
215            }
216            return $signature;
217        }
218
219        // create a bogus user for whom username & id is known, so we
220        // can generate a correct signature
221        $user = $this->loadUser( $user['id'], $user['name'] );
222
223        // nickname & fancysig are user options: unless we're on local wiki,
224        // we don't know these & can't load them to generate the signature
225        $nickname = $this->getOption( 'remoteapi' ) ? null : false;
226        $fancysig = $this->getOption( 'remoteapi' ) ? false : null;
227
228        $parser = $this->getServiceContainer()->getParserFactory()->create();
229        // Parser::getUserSig can end calling `getCleanSignatures` on
230        // mOptions, which may not be set. Set a dummy options object so it
231        // doesn't fail (it'll initialise the requested value from a global
232        // anyway)
233        $options = ParserOptions::newFromAnon();
234        $parser->startExternalParse( $this->pageTitle, $options, Parser::OT_WIKI );
235        $signature = $parser->getUserSig( $user, $nickname, $fancysig );
236        $signature = $parser->getStripState()->unstripBoth( $signature );
237        if ( $timestamp ) {
238            $signature .= ' ' . $this->formatTimestamp( $timestamp );
239        }
240        return $signature;
241    }
242
243    /**
244     * @param string $timestamp
245     * @return string
246     */
247    private function formatTimestamp( $timestamp ) {
248        $timestamp = MWTimestamp::getLocalInstance( $timestamp );
249        $ts = $timestamp->format( 'YmdHis' );
250        $tzMsg = $timestamp->format( 'T' );  # might vary on DST changeover!
251
252        # Allow translation of timezones through wiki. format() can return
253        # whatever crap the system uses, localised or not, so we cannot
254        # ship premade translations.
255        $key = 'timezone-' . strtolower( trim( $tzMsg ) );
256        $msg = wfMessage( $key )->inContentLanguage();
257        if ( $msg->exists() ) {
258            $tzMsg = $msg->text();
259        }
260
261        return $this->getServiceContainer()->getContentLanguage()
262            ->timeanddate( $ts, false, false ) . " ($tzMsg)";
263    }
264
265    /**
266     * @param string $pageName
267     * @return bool
268     */
269    private function pageExists( $pageName ) {
270        static $pages = [];
271
272        if ( !isset( $pages[$pageName] ) ) {
273            $result = $this->api->apiCall( [ 'action' => 'query', 'titles' => $pageName ] );
274            $pages[$pageName] = !isset( $result['query']['pages'][-1] );
275        }
276
277        return $pages[$pageName];
278    }
279
280    /**
281     * @param Title $pageTitle
282     * @param string $submodule
283     * @param string $prefix
284     * @param string $responseRoot
285     * @return array[]
286     */
287    private function getAllRevisions( Title $pageTitle, $submodule, $prefix, $responseRoot ) {
288        $params = [ $prefix . 'format' => 'wikitext' ];
289        $headerRevisions = [];
290
291        do {
292            $headerData = $this->flowApi( $pageTitle, $submodule, $params );
293            if ( !isset( $headerData[$responseRoot]['revision']['revisionId'] ) ) {
294                break;
295            }
296
297            $headerRevision = $headerData[$responseRoot]['revision'];
298            $headerRevisions[] = $headerRevision;
299
300            $revId = $headerRevision['previousRevisionId'];
301            $params[$prefix . 'revId'] = $revId;
302        } while ( $revId );
303
304        return $headerRevisions;
305    }
306
307    /**
308     * @return string
309     */
310    private function processHeader() {
311        return $this->processMultiRevisions(
312            $this->getAllRevisions( $this->pageTitle, 'view-header', 'vh', 'header' ),
313            false,
314            'flow-edited-by-header'
315        );
316    }
317
318    /**
319     * @param array[] $allRevisions
320     * @param bool $sigForFirstAuthor
321     * @param string $msg
322     * @param string $glueAfterContent
323     * @param string $glueBeforeAuthors
324     * @return string
325     */
326    private function processMultiRevisions(
327        array $allRevisions,
328        $sigForFirstAuthor = true,
329        $msg = 'flow-edited-by',
330        $glueAfterContent = '',
331        $glueBeforeAuthors = ' '
332    ) {
333        if ( !$allRevisions ) {
334            return '';
335        }
336
337        $firstRevision = end( $allRevisions );
338        $latestRevision = reset( $allRevisions );
339
340        // take the content from the first (most recent) revision
341        $content = $latestRevision['content']['content'];
342        $firstContributor = $firstRevision['author'];
343
344        // deduplicate authors
345        $otherContributors = [];
346        foreach ( $allRevisions as $revision ) {
347            $name = $revision['author']['name'];
348            $otherContributors[$name] = $revision['author'];
349        }
350
351        $formattedAuthors = '';
352        if ( $sigForFirstAuthor ) {
353            $formattedAuthors .= $this->getSignature( $firstContributor, $firstRevision['timestamp'] );
354            // remove first contributor from list of previous contributors
355            unset( $otherContributors[$firstContributor['name']] );
356        }
357
358        if ( $otherContributors &&
359            ( count( $otherContributors ) > 1 || !isset( $otherContributors[$firstContributor['name']] ) )
360        ) {
361            $signatures = array_map( [ $this, 'getSignature' ], $otherContributors );
362            $formattedAuthors .= ( $sigForFirstAuthor ? ' ' : '' ) . '(' .
363                wfMessage( $msg )->inContentLanguage()->params(
364                    $this->getServiceContainer()->getContentLanguage()->commaList( $signatures )
365                )->text() . ')';
366        }
367
368        return $content . $glueAfterContent . ( $formattedAuthors === '' ? '' : $glueBeforeAuthors . $formattedAuthors );
369    }
370
371    /**
372     * @param array $revision
373     * @return array[]
374     */
375    private function getAllPostRevisions( array $revision ) {
376        $topicTitle = Title::newFromText( $revision['articleTitle'] );
377        $response = $this->flowApi( $topicTitle, 'view-post-history',
378            [ 'vphpostId' => $revision['postId'], 'vphformat' => 'wikitext' ]
379        );
380        return $response['topic']['revisions'];
381    }
382
383    /**
384     * @param array $revision
385     * @return string
386     */
387    private function processPost( array $revision ) {
388        return $this->processMultiRevisions( $this->getAllPostRevisions( $revision ) );
389    }
390
391    /**
392     * @param array $revision
393     * @return string
394     */
395    private function processTopicTitle( array $revision ) {
396        return '==' . $this->processMultiRevisions(
397            $this->getAllPostRevisions( $revision ),
398            false,
399            'flow-edited-by-topic-title',
400            '==',
401            "\n\n"
402        ) . "\n\n";
403    }
404
405}
406
407$maintClass = ConvertToText::class;
408require_once RUN_MAINTENANCE_IF_MAIN;