diff --git a/.github/workflows/phan_dependencies b/.github/workflows/phan_dependencies new file mode 100644 index 0000000..f50ce4f --- /dev/null +++ b/.github/workflows/phan_dependencies @@ -0,0 +1,3 @@ +Scribunto: + branch: auto + repo: auto diff --git a/.phan/config.php b/.phan/config.php index a30d775..0d43c78 100644 --- a/.phan/config.php +++ b/.phan/config.php @@ -2,6 +2,18 @@ $cfg = require __DIR__ . '/../vendor/mediawiki/mediawiki-phan-config/src/config.php'; +$cfg['directory_list'] = array_merge( + $cfg['directory_list'], [ + '../../extensions/Scribunto', + ], +); + +$cfg['exclude_analysis_directory_list'] = array_merge( + $cfg['exclude_analysis_directory_list'], [ + '../../extensions/Scribunto', + ], +); + $cfg['suppress_issue_types'] = [ 'PhanAccessMethodInternal', 'SecurityCheck-LikelyFalsePositive', diff --git a/CHANGELOG.md b/CHANGELOG.md index a8aec50..9caadf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ ## ChangeLog for RottenLinks +### 2.1.0 (06-10-2024) +* Add parser function (`{{#rl_status: URL}}`) and Scribunto library + (`mw.ext.rottenLinks.getStatus(url)`) for getting the status code of a URL. + ### 2.0.1 (02-01-2024) * Add `requireExtension` to updateExternalLinks. * Modernize extension: diff --git a/RottenLinksMagic.php b/RottenLinksMagic.php new file mode 100644 index 0000000..dca94ec --- /dev/null +++ b/RottenLinksMagic.php @@ -0,0 +1,7 @@ + [ 0, 'rl_status' ], +]; diff --git a/extension.json b/extension.json index d1bbad7..b226dc0 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "RottenLinks", - "version": "2.0.1", + "version": "2.1.0", "author": [ "John Lewis", "Paladox", @@ -12,7 +12,10 @@ "license-name": "GPL-3.0-or-later", "type": "specialpage", "requires": { - "MediaWiki": ">= 1.40.0" + "MediaWiki": ">= 1.42.0", + "platform": { + "php": ">= 8.0" + } }, "MessagesDirs": { "RottenLinks": [ @@ -20,7 +23,8 @@ ] }, "ExtensionMessagesFiles": { - "RottenLinksAliases": "RottenLinksAliases.php" + "RottenLinksAliases": "RottenLinksAliases.php", + "RottenLinksMagic": "RottenLinksMagic.php" }, "AutoloadNamespaces": { "Miraheze\\RottenLinks\\": "includes/" @@ -43,6 +47,12 @@ }, "LoadExtensionSchemaUpdates": { "handler": "Installer" + }, + "ParserFirstCallInit": { + "handler": "Main" + }, + "ScribuntoExternalLibraries": { + "handler": "Scribunto" } }, "HookHandlers": { @@ -52,8 +62,12 @@ "Main": { "class": "Miraheze\\RottenLinks\\HookHandlers\\Main", "services": [ - "JobQueueGroup" + "JobQueueGroup", + "ConnectionProvider" ] + }, + "Scribunto": { + "class": "Miraheze\\RottenLinks\\HookHandlers\\Scribunto" } }, "config": { diff --git a/i18n/en.json b/i18n/en.json index bf29fdc..81af90a 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -11,6 +11,7 @@ "rottenlinks-extensionname": "RottenLinks", "rottenlinks-header": "Refine external link results display", "rottenlinks-header-info": "You may select what statistics you want displayed or how many results you want displayed per page.", + "rottenlinks-rlstatus-no-url": "No URL provided for {{#rl_status}}", "rottenlinks-table-external": "External Link", "rottenlinks-table-response": "HTTP Response", "rottenlinks-table-usage": "Page Usage", diff --git a/i18n/qqq.json b/i18n/qqq.json index b67da4e..8b97c62 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -5,6 +5,7 @@ "rottenlinks": "{{doc-special}}", "rottenlinks-desc": "{{desc|name=RottenLinks|url=https://github.com/miraheze/RottenLinks}}", "rottenlinks-extensionname": "{{name}}", + "rottenlinks-rlstatus-no-url": "This is the error message that is shown when the parser function {{#rl_status}} is called with no URL", "rottenlinks-table-external": "This is the label of a tabel header on Special:RottenLinks", "rottenlinks-table-response": "This is the label of a tabel header on Special:RottenLinks", "rottenlinks-table-usage": "This is the label of a tabel header on Special:RottenLinks", diff --git a/includes/HookHandlers/Main.php b/includes/HookHandlers/Main.php index 81a8bae..8baa93a 100644 --- a/includes/HookHandlers/Main.php +++ b/includes/HookHandlers/Main.php @@ -5,18 +5,24 @@ use JobQueueGroup; use MediaWiki\Deferred\LinksUpdate\LinksUpdate; use MediaWiki\Hook\LinksUpdateCompleteHook; +use MediaWiki\Hook\ParserFirstCallInitHook; +use MediaWiki\Parser\Parser; use Miraheze\RottenLinks\RottenLinksJob; +use Miraheze\RottenLinks\RottenLinksParserFunctions; +use Wikimedia\Rdbms\IConnectionProvider; -class Main implements LinksUpdateCompleteHook { +class Main implements LinksUpdateCompleteHook, ParserFirstCallInitHook { - /** @var JobQueueGroup */ - private $jobQueueGroup; + private JobQueueGroup $jobQueueGroup; + private RottenLinksParserFunctions $parserFunctions; /** * @param JobQueueGroup $jobQueueGroup + * @param IConnectionProvider $connectionProvider */ - public function __construct( JobQueueGroup $jobQueueGroup ) { + public function __construct( JobQueueGroup $jobQueueGroup, IConnectionProvider $connectionProvider ) { $this->jobQueueGroup = $jobQueueGroup; + $this->parserFunctions = new RottenLinksParserFunctions( $connectionProvider ); } /** @@ -38,4 +44,13 @@ public function onLinksUpdateComplete( $linksUpdate, $ticket ) { $this->jobQueueGroup->push( new RottenLinksJob( $params ) ); } } + + /** + * Handler for ParserFirstCallInit hook. + * @see https://www.mediawiki.org/wiki/Manual:Hooks/ParserFirstCallInit + * @param Parser $parser + */ + public function onParserFirstCallInit( $parser ) { + $parser->setFunctionHook( 'rl_status', [ $this->parserFunctions, 'onRLStatus' ], Parser::SFH_OBJECT_ARGS ); + } } diff --git a/includes/HookHandlers/Scribunto.php b/includes/HookHandlers/Scribunto.php new file mode 100644 index 0000000..07a6c18 --- /dev/null +++ b/includes/HookHandlers/Scribunto.php @@ -0,0 +1,23 @@ +newSelectQueryBuilder() + ->select( 'rl_respcode' ) + ->from( 'rottenlinks' ) + ->where( [ + 'rl_externallink' => $url, + ] ) + ->caller( __METHOD__ ) + ->fetchField(); + + return $statusCode !== false + ? $statusCode + : null; + } } diff --git a/includes/RottenLinksLuaLibrary.php b/includes/RottenLinksLuaLibrary.php new file mode 100644 index 0000000..b1d03bb --- /dev/null +++ b/includes/RottenLinksLuaLibrary.php @@ -0,0 +1,57 @@ +connectionProvider = MediaWikiServices::getInstance()->getConnectionProvider(); + } + + /** + * @param mixed $url + * @return array + * @internal + */ + public function onGetStatus( mixed $url = null ): array { + $name = 'mw.ext.rottenLinks.getStatus'; + $this->checkType( $name, 1, $url, 'string' ); + // $this->checkType() validates that $url is a string, therefore... + '@phan-var string $url'; + + // I think Lua errors are untranslated? LibraryBase::checkType() returns + // a plain ol' English string too. + if ( $url === '' ) { + throw new LuaError( "bad argument #1 to '{$name}' (url is empty)" ); + } + + $dbr = $this->connectionProvider->getReplicaDatabase(); + return [ RottenLinks::getResponseFromDatabase( $dbr, $url ) ]; + } + + /** + * @return array + */ + public function register(): array { + $functions = [ + 'getStatus' => [ $this, 'onGetStatus' ], + ]; + $arguments = []; + + return $this->getEngine()->registerInterface( __DIR__ . '/mw.ext.rottenLinks.lua', $functions, $arguments ); + } +} diff --git a/includes/RottenLinksParserFunctions.php b/includes/RottenLinksParserFunctions.php new file mode 100644 index 0000000..6b44361 --- /dev/null +++ b/includes/RottenLinksParserFunctions.php @@ -0,0 +1,41 @@ +connectionProvider = $connectionProvider; + } + + /** + * The function responsible for handling {{#rl_status}}. + * + * @param Parser $parser + * @param PPFrame $frame + * @param PPNode[] $args + * @return string + */ + public function onRLStatus( Parser $parser, PPFrame $frame, array $args ): string { + $url = trim( $frame->expand( $args[0] ?? '' ) ); + if ( $url === '' ) { + return Html::element( 'strong', [ + 'class' => 'error', + ], $parser->msg( 'rottenlinks-rlstatus-no-url' ) ); + } + + $dbr = $this->connectionProvider->getReplicaDatabase(); + return (string)RottenLinks::getResponseFromDatabase( $dbr, $url ); + } +} diff --git a/includes/mw.ext.rottenLinks.lua b/includes/mw.ext.rottenLinks.lua new file mode 100644 index 0000000..fa251ea --- /dev/null +++ b/includes/mw.ext.rottenLinks.lua @@ -0,0 +1,18 @@ +local p = {} + +function p.setupInterface(arguments) + p.setupInterface = nil + + local php = mw_interface + mw_interface = nil + + mw = mw or {} + mw.ext = mw.ext or {} + + p.getStatus = php.getStatus + + mw.ext.rottenLinks = p + package.loaded['mw.ext.rottenLinks'] = p +end + +return p diff --git a/tests/phpunit/integration/RottenLinksParserFunctionsTest.php b/tests/phpunit/integration/RottenLinksParserFunctionsTest.php new file mode 100644 index 0000000..c940bf8 --- /dev/null +++ b/tests/phpunit/integration/RottenLinksParserFunctionsTest.php @@ -0,0 +1,64 @@ +getDB()->newInsertQueryBuilder() + ->insertInto( 'rottenlinks' ) + ->rows( [ + [ 'rl_externallink' => 'https://ooo.eeeee.ooo/', 'rl_respcode' => 418 ], + [ 'rl_externallink' => 'https://witix777.neocities.org/', 'rl_respcode' => 0 ], + ] ) + ->execute(); + } + + /** + * Data provider to test RottenLinksParserFunctions::onRLStatus() + * + * @return array + */ + public static function provideOnRLStatus(): array { + return [ + [ '', Html::element( 'strong', [ 'class' => 'error' ], '(rottenlinks-rlstatus-no-url)' ) ], + [ 'https://rainverse.wiki/', '' ], + [ 'https://ooo.eeeee.ooo/', '418' ], + [ 'https://witix777.neocities.org/', '0' ], + ]; + } + + /** + * Test RottenLinksParserFunctions::onRLStatus() + * + * @dataProvider provideOnRLStatus + * @param string $url + * @param string $expected + * @covers ::onRLStatus + */ + public function testOnRLStatus( string $url, string $expected ): void { + // Set the target language to "qqx", mainly so that we can test no URL + // without having to either hardcode the English string, or having to do + // some file read shenanigans to get the string. + $services = $this->getServiceContainer(); + $options = ParserOptions::newFromAnon(); + $options->setTargetLanguage( $services->getLanguageFactory()->getLanguage( 'qqx' ) ); + + $parser = $services->getParserFactory()->create(); + $parser->startExternalParse( null, $options, Parser::OT_WIKI ); + $frame = $parser->getPreprocessor()->newFrame(); + + $output = $parser->callParserFunction( $frame, '#rl_status', [ $url ] ); + $this->assertTrue( $output['found'] ); + $this->assertSame( $expected, $output['text'] ); + } +}