Brian Wolff has uploaded a new change for review. https://gerrit.wikimedia.org/r/292830
Change subject: Introduce {{#transcludelist:<page name>|options...}} ...................................................................... Introduce {{#transcludelist:<page name>|options...}} This also changes normal transclusion to transclude the entire list. Syntax is {{#transcludelist:page name of list |includeDesc=true |maxItems=<number> |offset=<number> |defaultSort=[natural|random] |tags=tag1+tag2 |tags=tag3+tag4 }} (Where each tags argument is AND-ed together, and the + acts as an OR) Bug: T135088 Change-Id: Ib4acc39a76dd34aa4701daa2a4b17915cc4d2246 --- A CollaborationKit.i18n.magic.php M extension.json M i18n/en.json M i18n/qqq.json M includes/CollaborationKit.php M includes/content/CollaborationListContent.php 6 files changed, 240 insertions(+), 22 deletions(-) git pull ssh://gerrit.wikimedia.org:29418/mediawiki/extensions/CollaborationKit refs/changes/30/292830/1 diff --git a/CollaborationKit.i18n.magic.php b/CollaborationKit.i18n.magic.php new file mode 100644 index 0000000..0019658 --- /dev/null +++ b/CollaborationKit.i18n.magic.php @@ -0,0 +1,7 @@ +<?php + +$magicWords = []; + +$magicWords['en'] = [ + 'transcludelist' => [ 0, 'transcludelist' ], +]; diff --git a/extension.json b/extension.json index d5b354e..dfd776e 100644 --- a/extension.json +++ b/extension.json @@ -12,7 +12,8 @@ ] }, "ExtensionMessagesFiles": { - "CollaborationKitAlias": "CollaborationKit.alias.php" + "CollaborationKitAlias": "CollaborationKit.alias.php", + "CollaborationKitMagic": "CollaborationKit.i18n.magic.php" }, "AutoloadClasses": { "ApiEditCollaborationHub": "includes/ApiEditCollaborationHub.php", @@ -39,6 +40,9 @@ ], "UnitTestsList": [ "CollaborationKitHooks::onUnitTestsList" + ], + "ParserFirstCallInit": [ + "CollaborationKitHooks::onParserFirstCallInit" ] }, "ResourceModules": { diff --git a/i18n/en.json b/i18n/en.json index 4ce1daf..2a17571 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -38,5 +38,7 @@ "collaborationhub-edit-apierror": "Editing the Collaboration Hub through the API failed with error code $1.", "collaborationhub-edit-tojsonerror": "Error creating json out of provided junk", "collaborationkit-taglist": "'''{{PLURAL:$2|Tagged:|Tags:}}''' $1 ", - "collaborationkit-listempty": "This list has no items in it." + "collaborationkit-listempty": "This list has no items in it.", + "collaborationkit-listcontent-toomanytags": "You are not allowed to specify more than {{PLURAL:$1|one tag|$1 tags}}", + "collaborationkit-listcontent-notlist": "[[$1]] is not a CollaborationKit list page" } diff --git a/i18n/qqq.json b/i18n/qqq.json index 72a07bd..5dd3ddb 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -38,5 +38,7 @@ "collaborationhub-edit-apierror": "Error message shown when a request to the edit API to update a collaboration hub failed.\n\nParameters:\n* $1 - error code string", "collaborationhub-edit-tojsonerror": "Error message shown when the input could not be successfully encoded back into the collaboration hub json", "collaborationkit-taglist": "Box for showing tags a specific item has on a list. $1 = comma separated list of tags. $2 = number of tags", - "collaborationkit-listempty": "Shown on lists that are empty immediately after the description" + "collaborationkit-listempty": "Shown on lists that are empty immediately after the description", + "collaborationkit-listcontent-toomanytags": "Error if <nowiki>{{#transcludelist:...}}</nowiki> is given more than the allowed number of tag arguments. $1 - number of tags allowed. $2 - number of tags given.", + "collaborationkit-listcontent-notlist": "Error when <nowiki>{{#transcludelist:page}}</nowiki> is given a page that is not a CollaborationKit list page. $1 Name of page given." } diff --git a/includes/CollaborationKit.php b/includes/CollaborationKit.php index 09dd39a..ce1f4ad 100644 --- a/includes/CollaborationKit.php +++ b/includes/CollaborationKit.php @@ -76,4 +76,8 @@ $files = array_merge( $files, $ourFiles ); } + + public static function onParserFirstCallInit( $parser ) { + $parser->setFunctionHook( 'transcludelist', 'CollaborationListContent::transcludeHook' ); + } } diff --git a/includes/content/CollaborationListContent.php b/includes/content/CollaborationListContent.php index 4447bb4..db2b47e 100644 --- a/includes/content/CollaborationListContent.php +++ b/includes/content/CollaborationListContent.php @@ -1,6 +1,8 @@ <?php /** + * Important design assumption: This class assumes lists are small + * (e.g. Average case < 500 items, outliers < 2000) * * Json structure is as follows: * { @@ -16,7 +18,7 @@ * ... * ], * "options": { - * "sortcriteria": { + * "sortcriteria": { //unimplemented * // FIXME, not sure if this actually meets with usecase * "criteria name": { * "order": "numeric", // or potentially collation?? @@ -33,6 +35,8 @@ class CollaborationListContent extends JsonContent { const MAX_LIST_SIZE = 2000; // Maybe should be a config option. + const RANDOM_CACHE_EXPIRY = 28800; // 8 hours + const MAX_TAGS = 50; /** @var $decoded boolean Have we decoded the data yet */ private $decoded = false; @@ -197,6 +201,26 @@ return true; } + private static function validateOption( $name, &$value ) { + switch ( $name ) { + case 'defaultSort': + if ( in_array( $value, [ 'natural', 'random' ] ) ) { + return true; + } + case 'maxItems': + case 'offset': + if ( is_numeric( $value ) ) { + $value = (int)$value; + return true; + } + case 'includeDesc': + $value = (bool)$value; + return true; + default: + return false; + } + } + /** * Decode the JSON contents and populate protected variables. */ @@ -242,34 +266,57 @@ if ( !$lang ) { $lang = $title->getPageLanguage(); } - $text = $this->convertToWikitext( $lang, true ); + $text = $this->convertToWikitext( $lang, $this->getFullRenderListOptions() ); $output = $wgParser->parse( $text, $title, $options, true, true, $revId ); + } + + private function getFullRenderListOptions() { + return $listOptions = [ + 'includeDesc' => true, + 'maxItems' => false, + 'defaultSort' => 'natural' + ]; } /** * Convert the JSON to wikitext. * * @param $lang Language The (content) language to render the page in. - * @param $includeDesc boolean Include the description + * @param $options Array Options to override the default transclude options * @return string The wikitext */ - private function convertToWikitext( Language $lang, $includeDesc = false, $maxItems = false ) { + private function convertToWikitext( Language $lang, $options = [] ) { $this->decode(); + $options = $options + $this->getDefaultOptions(); + $maxItems = $options['maxItems']; + $includeDesc = $options['includeDesc']; $text = "__NOEDITSECTION__\n__NOTOC__"; - if ( includeDesc ) { + if ( $includeDesc ) { $text .= $this->getDescription() . "\n"; } if ( count( $this->items ) === 0 ) { - $text .= "<hr>\n" . wfMessage( 'collaborationkit-listempty' ) - ->inLanguage( $lang ) - ->plain() . "\n"; + $text .= "<hr>\n{{mediawiki:collaborationkit-listempty}}\n"; } $curItem = 0; - foreach ( $this->items as $item ) { + $offset = $options['defaultSort'] === 'random' ? 0 : $options['offset']; + + $sortedItems = $this->items; + $this->sortList( $sortedItems, $options['defaultSort'] ); + + foreach ( $sortedItems as $item ) { + if ( $offset !== 0 ) { + $offset--; + continue; + } $curItem++; if ( $maxItems !== false && $maxItems < $curItem ) { break; + } + + $itemTags = $item->tags ? $item->tags : []; + if ( !$this->matchesTag( $options['tags'], $itemTags ) ) { + continue; } $titleForItem = null; @@ -314,7 +361,7 @@ array_map( 'wfEscapeWikiText', $item->tags ) ) )->numParams( count( $item->tags ) ) - ->plain() . + ->text() . "</div>\n"; } if ( $image ) { @@ -330,7 +377,8 @@ // using wgContLang is kind of icky. Maybe we should transclude // from mediawiki namespace, or give up on not splitting the // parser cache and just use {{int:... (?) - $text = $this->convertToWikitext( $wgContLang, true ); + $renderOpts = $this->getFullRenderListOptions(); + $text = $this->convertToWikitext( $wgContLang, $renderOpts ); return ContentHandler::makeContent( $text, null, $toModel ); } elseif ( $toModel === CONTENT_MODEL_JSON ) { return ContentHandler::makeContent( $this->getNativeData(), null, $toModel ); @@ -338,13 +386,164 @@ return parent::convert( $toModel, $lossy ); } - public function getWikitextForTransclusion() { - global $wgContLang; - // FIXME Unclear if we should really do this as a transclusion, or - // introduce a parser function. Too bad we don't have access to - // transclusion parameters from this interface w/o doing something - // insane:( - $text = $this->convertToWikitext( $wgContLang, false, 5 ); - return $text; + public function getDefaultOptions() { + // FIXME implement + return [ + 'includeDesc' => false, + 'maxItems' => 5, + 'defaultSort' => 'random', + 'offset' => 0, + 'tags' => [], + ]; + } + + + /** + * Sort a list + * + * @param &$items Array List to sort (sorted in-place) + * @param $mode String sort method + * @return Array sorted list + * @throws UnexpectedValueException on unrecognized mode + */ + private function sortList( &$items, $mode ) { + switch( $mode ) { + case 'random': + return $this->sortRandomly( $items ); + case 'natural': + return $items; + default: + throw new UnexpectedValueException( "invalid sort mode" ); + } + } + + /** + * Sort an array pseudo-randomly using an affine transform + * + * @param Array $items Stuff to sort (sorted in-place) + * @return Array + */ + private function sortRandomly( &$items ) { + $totItems = count( $items ); + $rand1 = mt_rand( 1, $totItems - 1 ); + $rand2 = mt_rand( 0, $totItems - 1 ); + + while( $rand1 < $totItems - 1 && $rand1 % $totItems === 0 ) { + // Make rand1 relatively prime to $totItems. + $rand1++; + } + uksort( $items, function ( $a, $b ) use( $rand1, $rand2, $totItems ) { + $a2 = ( $a * $rand1 + $rand2 ) % $totItems; + $b2 = ( $b * $rand1 + $rand2 ) % $totItems; + if ( $a2 === $b2 ) { + // Really should not happen + return 0; + } + return $a2 > $b2 ? 1 : -1; + } ); + return $items; + } + + + /** + * Determine if an item matches a set of tags + * + * $tagSpecifier is a 2D array describing an AND of OR conditions + * e.g. $tagSpecifier = [ [ 'a', 'b' ], ['b', 'd'] ] + * means that any item must have the tags (a&&b) || (b&&d). + * + * @param $tagSpecifier Array What tags to check (aka $options['tags']) + * @param $itemTags Array What tags is this item tagged with + * @return boolean If the item matches + */ + private function matchesTag( Array $tagSpecifier, Array $itemTags ) { + if ( !$tagSpecifier ) { + return true; + } + $matchesAllGroups = true; + foreach( $tagSpecifier as $tagGroups ) { + foreach( $tagGroups as $tagAlt ) { + $matchesOneAlternative = false; + $itemTags; + if ( in_array( $tagAlt, $itemTags ) ) { + $matchesOneAlternative = true; + break; + } + } + if ( !$matchesOneAlternative ) { + $matchesAllGroups = false; + break; + } + } + return $matchesAllGroups; + } + + /** + * Function to handle {{#trancludelist:Page name|options...}} calls + */ + public static function transcludeHook( $parser, $pageName = '' ) { + $args = array_splice( func_get_args(), 2 ); + $options = []; + $title = Title::newFromText( $pageName ); + $lang = $parser->getFunctionLang(); + + if ( !$title || $title->getContentModel() !== __CLASS__ ) { + // This is interpreted as wikitext, so is safe. + return Html::rawElement( 'div', [ 'class' => 'error' ], + wfMessage( 'collaborationkit-listcontent-notlist' ) + ->params( $title->getPrefixedText() ) + ->inLanguage( $lang ) + ->text() + ); + } + $tagCount = 0; + foreach( $args as $argument ) { + if ( strpos( $argument, '=' ) === false ) { + continue; + } + // If we need everything i18n-ized, this could be + // replaced with magic words. + list( $name, $value ) = explode( '=', $argument, 2 ); + if ( $name === 'tags' ) { + $tagList = explode( '+', $value ); + if ( !isset( $options['tags'] ) ) { + $options['tags'] = []; + } + $options['tags'][] = $tagList; + $tagCount += count( $tagList ); + } elseif ( self::validateOption( $name, $value ) ) { + $options[$name] = $value; + } + } + + if ( $tagCount > self::MAX_TAGS ) { + return Html::rawElement( 'div', [ 'class' => 'error' ], + wfMessage( 'collaborationkit-listcontent-toomanytags' ) + ->numParams( self::MAX_TAGS, $tagCount ) + ->inLanguage( $lang ) + ->text() + ); + } + + $wikipage = WikiPage::Factory( $title ); + $content = $wikipage->getContent(); + if ( !$content instanceof CollaborationListContent ) { + // We already checked this, so this should not happen... + return Html::rawElement( 'div', [ 'class' => 'error' ], + wfMessage( 'collaborationkit-listcontent-notlist' ) + ->params( $title->getPrefixedText() ) + ->inLanguage( $lang ) + ->text() + ); + } + + if ( ( isset( $options['defaultSort'] ) + && $options['defaultSort'] === 'random' ) + || $content->getDefaultOptions()['defaultSort'] === 'random' + ) { + $parser->getOutput()->updateCacheExpiry( self::RANDOM_CACHE_EXPIRY ); + } + $parser->getOutput()->addTemplate( $title, $wikipage->getId(), $wikipage->getLatest() ); + return $content->convertToWikitext( $lang, $options ); } } -- To view, visit https://gerrit.wikimedia.org/r/292830 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: newchange Gerrit-Change-Id: Ib4acc39a76dd34aa4701daa2a4b17915cc4d2246 Gerrit-PatchSet: 1 Gerrit-Project: mediawiki/extensions/CollaborationKit Gerrit-Branch: master Gerrit-Owner: Brian Wolff <bawolff...@gmail.com> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits