Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 167
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 / 161
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 / 3
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 Maintenance;
10use MediaWiki\Title\Title;
11use MediaWiki\User\User;
12use MediaWiki\Utils\MWTimestamp;
13use Parser;
14use ParserOptions;
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 /*required*/ );
40        $this->addOption( 'remoteapi', 'The api of the wiki to convert the page from (or nothing, for local wiki)', false /*required*/ );
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                    [ $junk, $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;
407require_once RUN_MAINTENANCE_IF_MAIN;