From f0207e8ca6e1e8fa41a768b825534572b59f08bc Mon Sep 17 00:00:00 2001 From: Brad Jorsch Date: Thu, 7 May 2015 13:11:09 -0400 Subject: [PATCH] Add Special:ApiSandbox Like Extension:ApiSandbox, but rewritten to use OOJS-UI and to add many long-requested features. Bug: T89386 Bug: T92893 Bug: T98457 Bug: T98083 Bug: T89229 Bug: T66008 Bug: T50607 Bug: T47811 Bug: T38875 Bug: T36962 Bug: T34740 Change-Id: Ic42a6c5ef54b811cd63cfef2132942b27a626fe5 Depends-On: I85c0eedcd31a0e419d8055eca0d9cb1ba872ae62 Depends-On: Ic85ff4abbbcd2076ebf5cdfaa0e95e98878e2308 --- RELEASE-NOTES-1.27 | 2 + autoload.php | 1 + includes/api/ApiFormatBase.php | 89 +- includes/api/ApiFormatJson.php | 4 +- includes/api/ApiFormatPhp.php | 2 +- includes/api/ApiFormatXml.php | 2 +- includes/api/ApiHelp.php | 5 +- includes/api/i18n/en.json | 4 +- includes/api/i18n/qqq.json | 2 + includes/specialpage/SpecialPageFactory.php | 1 + includes/specials/SpecialApiSandbox.php | 58 + languages/i18n/en.json | 35 + languages/i18n/qqq.json | 35 + languages/messages/MessagesEn.php | 1 + resources/Resources.php | 55 + .../mediawiki.special.apisandbox.css | 74 + .../mediawiki.special.apisandbox.js | 1659 +++++++++++++++++ .../mediawiki.special.apisandbox.top.css | 3 + .../src/mediawiki/mediawiki.apipretty.css | 2 +- 19 files changed, 2010 insertions(+), 24 deletions(-) create mode 100644 includes/specials/SpecialApiSandbox.php create mode 100644 resources/src/mediawiki.special/mediawiki.special.apisandbox.css create mode 100644 resources/src/mediawiki.special/mediawiki.special.apisandbox.js create mode 100644 resources/src/mediawiki.special/mediawiki.special.apisandbox.top.css diff --git a/RELEASE-NOTES-1.27 b/RELEASE-NOTES-1.27 index f4e4815c29..fbbacab155 100644 --- a/RELEASE-NOTES-1.27 +++ b/RELEASE-NOTES-1.27 @@ -65,6 +65,8 @@ production. * $wgAllowAsyncCopyUploads and $CopyUploadAsyncTimeout were removed. This was an experimental feature that has never worked. * $wgEnotifUseJobQ was removed and the job queue is always used. +* The functionality of the ApiSandbox extension has been merged into core. The + extension should no longer be used. === New features in 1.27 === * $wgDataCenterUpdateStickTTL was also added. This decides how long a user diff --git a/autoload.php b/autoload.php index cd12fc1320..b055574aea 100644 --- a/autoload.php +++ b/autoload.php @@ -1153,6 +1153,7 @@ $wgAutoloadLocalClasses = array( 'SpecialAllMyUploads' => __DIR__ . '/includes/specials/SpecialMyRedirectPages.php', 'SpecialAllPages' => __DIR__ . '/includes/specials/SpecialAllPages.php', 'SpecialApiHelp' => __DIR__ . '/includes/specials/SpecialApiHelp.php', + 'SpecialApiSandbox' => __DIR__ . '/includes/specials/SpecialApiSandbox.php', 'SpecialBlankpage' => __DIR__ . '/includes/specials/SpecialBlankpage.php', 'SpecialBlock' => __DIR__ . '/includes/specials/SpecialBlock.php', 'SpecialBlockList' => __DIR__ . '/includes/specials/SpecialBlockList.php', diff --git a/includes/api/ApiFormatBase.php b/includes/api/ApiFormatBase.php index be683105ff..69cedd76b2 100644 --- a/includes/api/ApiFormatBase.php +++ b/includes/api/ApiFormatBase.php @@ -32,6 +32,7 @@ abstract class ApiFormatBase extends ApiBase { private $mIsHtml, $mFormat, $mUnescapeAmps, $mHelp; private $mBuffer, $mDisabled = false; + private $mIsWrappedHtml = false; protected $mForceDefaultParams = false; /** @@ -45,6 +46,7 @@ abstract class ApiFormatBase extends ApiBase { $this->mIsHtml = ( substr( $format, -2, 2 ) === 'fm' ); // ends with 'fm' if ( $this->mIsHtml ) { $this->mFormat = substr( $format, 0, -2 ); // remove ending 'fm' + $this->mIsWrappedHtml = $this->getMain()->getCheck( 'wrappedhtml' ); } else { $this->mFormat = $format; } @@ -79,6 +81,15 @@ abstract class ApiFormatBase extends ApiBase { return $this->mIsHtml; } + /** + * Returns true when the special wrapped mode is enabled. + * @since 1.27 + * @return bool + */ + protected function getIsWrappedHtml() { + return $this->mIsWrappedHtml; + } + /** * Disable the formatter. * @@ -145,7 +156,9 @@ abstract class ApiFormatBase extends ApiBase { return; } - $mime = $this->getIsHtml() ? 'text/html' : $this->getMimeType(); + $mime = $this->getIsWrappedHtml() + ? 'text/mediawiki-api-prettyprint-wrapped' + : ( $this->getIsHtml() ? 'text/html' : $this->getMimeType() ); // Some printers (ex. Feed) do their own header settings, // in which case $mime will be set to null @@ -185,19 +198,21 @@ abstract class ApiFormatBase extends ApiBase { $out->addModuleStyles( 'mediawiki.apipretty' ); $out->setPageTitle( $context->msg( 'api-format-title' ) ); - // When the format without suffix 'fm' is defined, there is a non-html version - if ( $this->getMain()->getModuleManager()->isDefined( $lcformat, 'format' ) ) { - $msg = $context->msg( 'api-format-prettyprint-header' )->params( $format, $lcformat ); - } else { - $msg = $context->msg( 'api-format-prettyprint-header-only-html' )->params( $format ); - } + if ( !$this->getIsWrappedHtml() ) { + // When the format without suffix 'fm' is defined, there is a non-html version + if ( $this->getMain()->getModuleManager()->isDefined( $lcformat, 'format' ) ) { + $msg = $context->msg( 'api-format-prettyprint-header' )->params( $format, $lcformat ); + } else { + $msg = $context->msg( 'api-format-prettyprint-header-only-html' )->params( $format ); + } - $header = $msg->parseAsBlock(); - $out->addHTML( - Html::rawElement( 'div', array( 'class' => 'api-pretty-header' ), - ApiHelp::fixHelpLinks( $header ) - ) - ); + $header = $msg->parseAsBlock(); + $out->addHTML( + Html::rawElement( 'div', array( 'class' => 'api-pretty-header' ), + ApiHelp::fixHelpLinks( $header ) + ) + ); + } if ( Hooks::run( 'ApiFormatHighlight', array( $context, $result, $mime, $format ) ) ) { $out->addHTML( @@ -205,10 +220,38 @@ abstract class ApiFormatBase extends ApiBase { ); } - // API handles its own clickjacking protection. - // Note, that $wgBreakFrames will still override $wgApiFrameOptions for format mode. - $out->allowClickjacking(); - $out->output(); + if ( $this->getIsWrappedHtml() ) { + // This is a special output mode mainly intended for ApiSandbox use + $time = microtime( true ) - $this->getConfig()->get( 'RequestTime' ); + $json = FormatJson::encode( + array( + 'html' => $out->getHTML(), + 'modules' => array_values( array_unique( array_merge( + $out->getModules(), + $out->getModuleScripts(), + $out->getModuleStyles() + ) ) ), + 'time' => round( $time * 1000 ), + ), + false, FormatJson::ALL_OK + ); + + // Bug 66776: wfMangleFlashPolicy() is needed to avoid a nasty bug in + // Flash, but what it does isn't friendly for the API, so we need to + // work around it. + if ( preg_match( '/\<\s*cross-domain-policy\s*\>/i', $json ) ) { + $json = preg_replace( + '/\<(\s*cross-domain-policy\s*)\>/i', '\\u003C$1\\u003E', $json + ); + } + + echo $json; + } else { + // API handles its own clickjacking protection. + // Note, that $wgBreakFrames will still override $wgApiFrameOptions for format mode. + $out->allowClickjacking(); + $out->output(); + } } else { // For non-HTML output, clear all errors that might have been // displayed if display_errors=On @@ -234,6 +277,18 @@ abstract class ApiFormatBase extends ApiBase { return $this->mBuffer; } + public function getAllowedParams() { + $ret = array(); + if ( $this->getIsHtml() ) { + $ret['wrappedhtml'] = array( + ApiBase::PARAM_DFLT => false, + ApiBase::PARAM_HELP_MSG => 'apihelp-format-param-wrappedhtml', + + ); + } + return $ret; + } + protected function getExamplesMessages() { return array( 'action=query&meta=siteinfo&siprop=namespaces&format=' . $this->getModuleName() diff --git a/includes/api/ApiFormatJson.php b/includes/api/ApiFormatJson.php index a319be3943..1566a0ff49 100644 --- a/includes/api/ApiFormatJson.php +++ b/includes/api/ApiFormatJson.php @@ -121,10 +121,10 @@ class ApiFormatJson extends ApiFormatBase { public function getAllowedParams() { if ( $this->isRaw ) { - return array(); + return parent::getAllowedParams(); } - $ret = array( + $ret = parent::getAllowedParams() + array( 'callback' => array( ApiBase::PARAM_HELP_MSG => 'apihelp-json-param-callback', ), diff --git a/includes/api/ApiFormatPhp.php b/includes/api/ApiFormatPhp.php index df9d581f06..f5f2504ed8 100644 --- a/includes/api/ApiFormatPhp.php +++ b/includes/api/ApiFormatPhp.php @@ -78,7 +78,7 @@ class ApiFormatPhp extends ApiFormatBase { } public function getAllowedParams() { - $ret = array( + $ret = parent::getAllowedParams() + array( 'formatversion' => array( ApiBase::PARAM_TYPE => array( 1, 2, 'latest' ), ApiBase::PARAM_DFLT => 1, diff --git a/includes/api/ApiFormatXml.php b/includes/api/ApiFormatXml.php index e8ad387bc3..b4a478c240 100644 --- a/includes/api/ApiFormatXml.php +++ b/includes/api/ApiFormatXml.php @@ -288,7 +288,7 @@ class ApiFormatXml extends ApiFormatBase { } public function getAllowedParams() { - return array( + return parent::getAllowedParams() + array( 'xslt' => array( ApiBase::PARAM_HELP_MSG => 'apihelp-xml-param-xslt', ), diff --git a/includes/api/ApiHelp.php b/includes/api/ApiHelp.php index bbea20b524..ecd6eb6a55 100644 --- a/includes/api/ApiHelp.php +++ b/includes/api/ApiHelp.php @@ -690,9 +690,12 @@ class ApiHelp extends ApiBase { ) ); $link = wfAppendQuery( wfScript( 'api' ), $qs ); + $sandbox = SpecialPage::getTitleFor( 'ApiSandbox' )->getLocalURL() . '#' . $qs; $help['examples'] .= Html::rawElement( 'dt', null, $msg->parse() ); $help['examples'] .= Html::rawElement( 'dd', null, - Html::element( 'a', array( 'href' => $link ), "api.php?$qs" ) + Html::element( 'a', array( 'href' => $link ), "api.php?$qs" ) . ' ' . + Html::rawElement( 'a', array( 'href' => $sandbox ), + $context->msg( 'api-help-open-in-apisandbox' )->parse() ) ); } $help['examples'] .= Html::closeElement( 'dl' ); diff --git a/includes/api/i18n/en.json b/includes/api/i18n/en.json index 1af53fa7d6..a1b303ffc2 100644 --- a/includes/api/i18n/en.json +++ b/includes/api/i18n/en.json @@ -6,7 +6,7 @@ ] }, - "apihelp-main-description": "
\n* [[mw:API:Main_page|Documentation]]\n* [[mw:API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Mailing list]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API Announcements]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bugs & requests]\n
\nStatus: All features shown on this page should be working, but the API is still in active development, and may change at any time. Subscribe to [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ the mediawiki-api-announce mailing list] for notice of updates.\n\nErroneous requests: When erroneous requests are sent to the API, an HTTP header will be sent with the key \"MediaWiki-API-Error\" and then both the value of the header and the error code sent back will be set to the same value. For more information see [[mw:API:Errors_and_warnings|API: Errors and warnings]].", + "apihelp-main-description": "
\n* [[mw:API:Main_page|Documentation]]\n* [[mw:API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Mailing list]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce API Announcements]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bugs & requests]\n
\nStatus: All features shown on this page should be working, but the API is still in active development, and may change at any time. Subscribe to [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ the mediawiki-api-announce mailing list] for notice of updates.\n\nErroneous requests: When erroneous requests are sent to the API, an HTTP header will be sent with the key \"MediaWiki-API-Error\" and then both the value of the header and the error code sent back will be set to the same value. For more information see [[mw:API:Errors_and_warnings|API: Errors and warnings]].\n\nTesting: For ease of testing API requests, see [[Special:ApiSandbox]].", "apihelp-main-param-action": "Which action to perform.", "apihelp-main-param-format": "The format of the output.", "apihelp-main-param-maxlag": "Maximum lag can be used when MediaWiki is installed on a database replicated cluster. To save actions causing any more site replication lag, this parameter can make the client wait until the replication lag is less than the specified value. In case of excessive lag, error code maxlag is returned with a message like Waiting for $host: $lag seconds lagged.
See [[mw:Manual:Maxlag_parameter|Manual: Maxlag parameter]] for more information.", @@ -1370,6 +1370,7 @@ "apihelp-watch-example-generator": "Watch the first few pages in the main namespace.", "apihelp-format-example-generic": "Return the query result in the $1 format.", + "apihelp-format-param-wrappedhtml": "Return the pretty-printed HTML and associated ResourceLoader modules as a JSON object.", "apihelp-json-description": "Output data in JSON format.", "apihelp-json-param-callback": "If specified, wraps the output into a given function call. For safety, all user-specific data will be restricted.", "apihelp-json-param-utf8": "If specified, encodes most (but not all) non-ASCII characters as UTF-8 instead of replacing them with hexadecimal escape sequences. Default when formatversion is not 1.", @@ -1451,6 +1452,7 @@ "api-help-permissions": "{{PLURAL:$1|Permission|Permissions}}:", "api-help-permissions-granted-to": "{{PLURAL:$1|Granted to}}: $2", "api-help-right-apihighlimits": "Use higher limits in API queries (slow queries: $1; fast queries: $2). The limits for slow queries also apply to multivalue parameters.", + "api-help-open-in-apisandbox": "[open in sandbox]", "api-credits-header": "Credits", "api-credits": "API developers:\n* Yuri Astrakhan (creator, lead developer Sep 2006–Sep 2007)\n* Roan Kattouw (lead developer Sep 2007–2009)\n* Victor Vasiliev\n* Bryan Tong Minh\n* Sam Reed\n* Brad Jorsch (lead developer 2013–present)\n\nPlease send your comments, suggestions and questions to mediawiki-api@lists.wikimedia.org\nor file a bug report at https://phabricator.wikimedia.org/." diff --git a/includes/api/i18n/qqq.json b/includes/api/i18n/qqq.json index 4d4614c3f4..e3354aadec 100644 --- a/includes/api/i18n/qqq.json +++ b/includes/api/i18n/qqq.json @@ -1275,6 +1275,7 @@ "apihelp-watch-example-unwatch": "{{doc-apihelp-example|watch}}", "apihelp-watch-example-generator": "{{doc-apihelp-example|watch}}", "apihelp-format-example-generic": "{{doc-apihelp-example|format|params=* $1 - Format name|paramstart=2|noseealso=1}}", + "apihelp-format-param-wrappedhtml": "{{doc-apihelp-param|format|wrappedhtml|description=the \"wrappedhtml\" parameter in pretty-printing format modules}}", "apihelp-json-description": "{{doc-apihelp-description|json|seealso=* {{msg-mw|apihelp-jsonfm-description}}}}", "apihelp-json-param-callback": "{{doc-apihelp-param|json|callback}}", "apihelp-json-param-utf8": "{{doc-apihelp-param|json|utf8}}", @@ -1353,6 +1354,7 @@ "api-help-permissions": "Label for the \"permissions\" section in the main module's help output.\n\nParameters:\n* $1 - Number of permissions displayed\n{{Identical|Permission}}", "api-help-permissions-granted-to": "Used to introduce the list of groups each permission is assigned to.\n\nParameters:\n* $1 - Number of groups\n* $2 - List of group names, comma-separated", "api-help-right-apihighlimits": "{{technical}}{{doc-right|apihighlimits|prefix=api-help}}\nThis message is used instead of {{msg-mw|right-apihighlimits}} in the API help to display the actual limits.\n\nParameters:\n* $1 - Limit for slow queries\n* $2 - Limit for fast queries", + "api-help-open-in-apisandbox": "Text for the link to open an API example in [[Special:ApiSandbox]].", "api-credits-header": "Header for the API credits section in the API help output\n{{Identical|Credit}}", "api-credits": "API credits text, displayed in the API help output" } diff --git a/includes/specialpage/SpecialPageFactory.php b/includes/specialpage/SpecialPageFactory.php index 3babafdbb1..8f2194e79c 100644 --- a/includes/specialpage/SpecialPageFactory.php +++ b/includes/specialpage/SpecialPageFactory.php @@ -123,6 +123,7 @@ class SpecialPageFactory { 'ListDuplicatedFiles' => 'ListDuplicatedFilesPage', // Data and tools + 'ApiSandbox' => 'SpecialApiSandbox', 'Statistics' => 'SpecialStatistics', 'Allmessages' => 'SpecialAllMessages', 'Version' => 'SpecialVersion', diff --git a/includes/specials/SpecialApiSandbox.php b/includes/specials/SpecialApiSandbox.php new file mode 100644 index 0000000000..42101baa5d --- /dev/null +++ b/includes/specials/SpecialApiSandbox.php @@ -0,0 +1,58 @@ +setHeaders(); + $out = $this->getOutput(); + + if ( !$this->getConfig()->get( 'EnableAPI' ) ) { + $out->showErrorPage( 'error', 'apisandbox-api-disabled' ); + } + + $out->addJsConfigVars( 'apihighlimits', $this->getUser()->isAllowed( 'apihighlimits' ) ); + $out->addModuleStyles( array( + 'mediawiki.special.apisandbox.styles', + ) ); + $out->addModules( array( + 'mediawiki.special.apisandbox', + 'mediawiki.apipretty', + ) ); + $out->wrapWikiMsg( + "
\n$1\n
", + 'apisandbox-jsonly' + ); + } + + protected function getGroupName() { + return 'wiki'; + } +} diff --git a/languages/i18n/en.json b/languages/i18n/en.json index cb9f7c4a45..99ef3c3f98 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -1822,6 +1822,41 @@ "apihelp-summary": "", "apihelp-no-such-module": "Module \"$1\" not found.", "apihelp-link": "[[Special:ApiHelp/$1|$2]]", + "apisandbox": "API sandbox", + "apisandbox-summary": "", + "apisandbox-jsonly": "JavaScript is required to use the API sandbox.", + "apisandbox-api-disabled": "The API is disabled on this site.", + "apisandbox-intro": "Use this page to experiment with the MediaWiki web service API.\nRefer to [[mw:API:Main page|the API documentation]] for further details of API usage. Example: [//www.mediawiki.org/wiki/API#A_simple_example get the content of a Main Page]. Select an action to see more examples.\n\nNote that, although this is a sandbox, actions you carry out on this page may modify the wiki.", + "apisandbox-fullscreen": "Expand panel", + "apisandbox-fullscreen-tooltip": "Expand the sandbox panel to fill the browser window.", + "apisandbox-unfullscreen": "Show page", + "apisandbox-unfullscreen-tooltip": "Reduce the sandbox panel, so MediaWiki navigation links are available.", + "apisandbox-submit": "Make request", + "apisandbox-reset": "Clear", + "apisandbox-retry": "Retry", + "apisandbox-loading": "Loading information for API module \"$1\"...", + "apisandbox-load-error": "An error occurred while loading information for API module \"$1\": $2", + "apisandbox-no-parameters": "This API module has no parameters.", + "apisandbox-helpurls": "Help links", + "apisandbox-examples": "Examples", + "apisandbox-dynamic-parameters": "Additional parameters", + "apisandbox-dynamic-parameters-add-label": "Add parameter:", + "apisandbox-dynamic-parameters-add-placeholder": "Parameter name", + "apisandbox-dynamic-error-exists": "A parameter named \"$1\" already exists.", + "apisandbox-deprecated-parameters": "Deprecated parameters", + "apisandbox-fetch-token": "Auto-fill the token", + "apisandbox-submit-invalid-fields-title": "Some fields are invalid", + "apisandbox-submit-invalid-fields-message": "Please correct the marked fields and try again.", + "apisandbox-results": "Results", + "apisandbox-sending-request": "Sending API request...", + "apisandbox-loading-results": "Receiving API results...", + "apisandbox-results-error": "An error occurred while loading the API query response: $1.", + "apisandbox-request-url-label": "Request URL:", + "apisandbox-request-time": "Request time: {{PLURAL:$1|$1 ms}}", + "apisandbox-results-fixtoken": "Correct token and resubmit", + "apisandbox-results-fixtoken-fail": "Failed to fetch \"$1\" token.", + "apisandbox-alert-page": "Fields on this page are not valid.", + "apisandbox-alert-field": "The value of this field is not valid.", "booksources": "Book sources", "booksources-summary": "", "booksources-search-legend": "Search for book sources", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index 388ec67316..fb05f50dfd 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -1997,6 +1997,41 @@ "apihelp-summary": "{{doc-specialpagesummary|ApiHelp}}", "apihelp-no-such-module": "Used as an error message if the requested API module is not found.\n\nParameters:\n* $1 - Requested module name", "apihelp-link": "{{notranslate}} Used to construct a link to [[Special:ApiHelp]]\n\nParameters:\n* $1 - module to link\n* $2 - link text", + "apisandbox": "{{doc-special|ApiSandbox}}", + "apisandbox-summary": "{{doc-specialpagesummary|ApiSandbox}}", + "apisandbox-jsonly": "Displayed as an error message if the browser does not have JavaScript enabled.", + "apisandbox-api-disabled": "Displayed as an error message if the API is disabled on this site.", + "apisandbox-intro": "Displayed (from JavaScript) as a header on [[Special:ApiSandbox]].", + "apisandbox-fullscreen": "JavaScript button label for enabling full-page mode.", + "apisandbox-fullscreen-tooltip": "Tooltip for the {{msg-mw|apisandbox-fullscreen}} button.", + "apisandbox-unfullscreen": "JavaScript button label for disabling full-page mode.", + "apisandbox-unfullscreen-tooltip": "Tooltip for the {{msg-mw|apisandbox-unfullscreen}} button.", + "apisandbox-submit": "JavaScript button label for submitting the request.", + "apisandbox-reset": "JavaScript button label for clearing the form.", + "apisandbox-retry": "JavaScript button label for retrying the submission.", + "apisandbox-loading": "JavaScript message displayed while data is loading.\n\nParameters:\n* $1 - Module being loaded", + "apisandbox-load-error": "Displayed as an error message from JavaScript when data failed to load.\n\nParameters:\n* $1 - Module being loaded\n* $2 - Error message from the API", + "apisandbox-no-parameters": "Displayed (from JavaScript) when the loaded API module has no parameters.", + "apisandbox-helpurls": "JavaScript button label for showing help URLs.", + "apisandbox-examples": "JavaScript button label for showing example queries.", + "apisandbox-dynamic-parameters": "JavaScript fieldset legend for the section containing the widgets to add arbitrary parameters to a module that can accept dynamic parameters.", + "apisandbox-dynamic-parameters-add-label": "JavaScript label for the widget to add a new arbitrary parameter.", + "apisandbox-dynamic-parameters-add-placeholder": "JavaScript text field placeholder for the widget to add a new arbitrary parameter.", + "apisandbox-dynamic-error-exists": "Displayed as an error message from JavaScript when trying to add a new arbitrary parameter with a name that already exists. Parameters:\n* $1 - Parameter name that failed.", + "apisandbox-deprecated-parameters": "JavaScript button label and fieldset legend for separating deprecated parameters in the UI.", + "apisandbox-fetch-token": "Tooltop for the button that fetches a CSRF token.", + "apisandbox-submit-invalid-fields-title": "Title for a JavaScript error message when fields are invalid.", + "apisandbox-submit-invalid-fields-message": "Content for a JavaScript error message when fields are invalid.", + "apisandbox-results": "JavaScript tab label for the tab displaying the API query results.", + "apisandbox-sending-request": "JavaScript message displayed while the request is being sent.", + "apisandbox-loading-results": "JavaScript message displayed while the response is being read.", + "apisandbox-results-error": "Displayed as an error message from JavaScript when the request failed.\n\nParameters:\n* $1 - Error message", + "apisandbox-request-url-label": "Label for the text field displaying the URL used to make this request.", + "apisandbox-request-time": "Label and value for displaying the time taken by the request.\n\nParameters:\n* $1 - Time taken in milliseconds", + "apisandbox-results-fixtoken": "JavaScript button label", + "apisandbox-results-fixtoken-fail": "Displayed as an error message from JavaScript when a CSRF token could not be fetched.\n\nParameters:\n* $1 - Token type", + "apisandbox-alert-page": "Tooltip for the alert icon on a module's page tab when the page contains fields with issues.", + "apisandbox-alert-field": "Tooltip for the alert icon on a field when the field has issues.", "booksources": "{{doc-special|BookSources}}\n\n'''This message shouldn't be changed unless it has serious mistakes.'''\n\nIt's used as the page name of the configuration page of [[Special:BookSources]]. Changing it breaks existing sites using the default version of this message.\n\nSee also:\n* {{msg-mw|Booksources|title}}\n* {{msg-mw|Booksources-text|text}}", "booksources-summary": "{{doc-specialpagesummary|booksources}}", "booksources-search-legend": "Box heading on [[Special:BookSources|book sources]] special page. The box is for searching for places where a particular book can be bought or viewed.", diff --git a/languages/messages/MessagesEn.php b/languages/messages/MessagesEn.php index 8fa13c65bf..f366057bb5 100644 --- a/languages/messages/MessagesEn.php +++ b/languages/messages/MessagesEn.php @@ -390,6 +390,7 @@ $specialPageAliases = array( 'AllMyUploads' => array( 'AllMyUploads', 'AllMyFiles' ), 'Allpages' => array( 'AllPages' ), 'ApiHelp' => array( 'ApiHelp' ), + 'ApiSandbox' => array( 'ApiSandbox' ), 'Ancientpages' => array( 'AncientPages' ), 'Badtitle' => array( 'Badtitle' ), 'Blankpage' => array( 'BlankPage' ), diff --git a/resources/Resources.php b/resources/Resources.php index 458d5f1a0c..9b1b16667f 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -1695,6 +1695,61 @@ return array( 'scripts' => 'resources/src/mediawiki.special/mediawiki.special.js', 'styles' => 'resources/src/mediawiki.special/mediawiki.special.css', ), + 'mediawiki.special.apisandbox.styles' => array( + 'styles' => 'resources/src/mediawiki.special/mediawiki.special.apisandbox.top.css', + ), + 'mediawiki.special.apisandbox' => array( + 'styles' => 'resources/src/mediawiki.special/mediawiki.special.apisandbox.css', + 'scripts' => 'resources/src/mediawiki.special/mediawiki.special.apisandbox.js', + 'dependencies' => array( + 'mediawiki.special', + 'mediawiki.api', + 'mediawiki.jqueryMsg', + 'oojs-ui', + 'mediawiki.widgets.datetime', + ), + 'messages' => array( + 'apisandbox-intro', + 'apisandbox-submit', + 'apisandbox-reset', + 'apisandbox-fullscreen', + 'apisandbox-fullscreen-tooltip', + 'apisandbox-unfullscreen', + 'apisandbox-unfullscreen-tooltip', + 'apisandbox-retry', + 'apisandbox-loading', + 'apisandbox-load-error', + 'apisandbox-fetch-token', + 'apisandbox-helpurls', + 'apisandbox-examples', + 'apisandbox-dynamic-parameters', + 'apisandbox-dynamic-parameters-add-label', + 'apisandbox-dynamic-parameters-add-placeholder', + 'apisandbox-dynamic-error-exists', + 'apisandbox-deprecated-parameters', + 'apisandbox-no-parameters', + 'api-help-param-limit', + 'api-help-param-limit2', + 'api-help-param-integer-min', + 'api-help-param-integer-max', + 'api-help-param-integer-minmax', + 'api-help-param-multi-separate', + 'api-help-param-multi-max', + 'apisandbox-submit-invalid-fields-title', + 'apisandbox-submit-invalid-fields-message', + 'apisandbox-results', + 'apisandbox-sending-request', + 'apisandbox-loading-results', + 'apisandbox-results-error', + 'apisandbox-request-url-label', + 'apisandbox-request-time', + 'apisandbox-results-fixtoken', + 'apisandbox-results-fixtoken-fail', + 'apisandbox-alert-page', + 'apisandbox-alert-field', + 'blanknamespace', + ), + ), 'mediawiki.special.block' => array( 'scripts' => 'resources/src/mediawiki.special/mediawiki.special.block.js', 'styles' => 'resources/src/mediawiki.special/mediawiki.special.block.css', diff --git a/resources/src/mediawiki.special/mediawiki.special.apisandbox.css b/resources/src/mediawiki.special/mediawiki.special.apisandbox.css new file mode 100644 index 0000000000..e52955f25f --- /dev/null +++ b/resources/src/mediawiki.special/mediawiki.special.apisandbox.css @@ -0,0 +1,74 @@ +.mw-apisandbox-fullscreen { + overflow: hidden; +} + +.mw-apisandbox-toolbar { + text-align: right; + padding: 0.5em; +} + +.mw-apisandbox-popup .oo-ui-popupWidget-body > .oo-ui-widget { + vertical-align: middle; +} + +/* So DateTimeInputWidget's calendar popup works... */ +.mw-apisandbox-popup .oo-ui-popupWidget-popup, +.mw-apisandbox-popup .oo-ui-popupWidget-body { + overflow: visible; +} + +.mw-apisandbox-fullscreen #mw-apisandbox-ui { + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + background: #fff; + z-index: 100; +} + +.mw-apisandbox-spacer { + display: inline-block; + height: 1px; + width: 5em; +} + +.mw-apisandbox-optionalWidget { + width: 100%; +} + +.mw-apisandbox-optionalWidget.oo-ui-widget-disabled { + position: relative; + z-index: 0; /* New stacking context to prevent the overlay from leaking out */ +} + +.mw-apisandbox-optionalWidget-overlay { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + z-index: 2; + cursor: pointer; +} + +.mw-apisandbox-optionalWidget-fields { + display: table; + width: 100%; +} + +.mw-apisandbox-optionalWidget-widget, +.mw-apisandbox-optionalWidget-checkbox { + display: table-cell; + vertical-align: middle; +} + +.mw-apisandbox-optionalWidget-checkbox { + width: 1%; /* Will be expanded by content */ + white-space: nowrap; + padding-left: 0.5em; +} + +.oo-ui-textInputWidget.oo-ui-widget-enabled > .oo-ui-indicatorElement-indicator.mw-apisandbox-clickable-indicator { + cursor: pointer; +} diff --git a/resources/src/mediawiki.special/mediawiki.special.apisandbox.js b/resources/src/mediawiki.special/mediawiki.special.apisandbox.js new file mode 100644 index 0000000000..32ccdcd717 --- /dev/null +++ b/resources/src/mediawiki.special/mediawiki.special.apisandbox.js @@ -0,0 +1,1659 @@ +/*global OO */ +( function ( $, mw, OO ) { + 'use strict'; + var ApiSandbox, Util, WidgetMethods, Validators, + $content, panel, booklet, oldhash, windowManager, fullscreenButton, + api = new mw.Api(), + bookletPages = [], + availableFormats = {}, + resultPage = null, + suppressErrors = true, + updatingBooklet = false, + pages = {}, + moduleInfoCache = {}; + + WidgetMethods = { + textInputWidget: { + getApiValue: function () { + return this.getValue(); + }, + setApiValue: function ( v ) { + if ( v === undefined ) { + v = this.paramInfo[ 'default' ]; + } + this.setValue( v ); + }, + apiCheckValid: function () { + var that = this; + return this.isValid().done( function ( ok ) { + ok = ok || suppressErrors; + that.setIcon( ok ? null : 'alert' ); + that.setIconTitle( ok ? '' : mw.message( 'apisandbox-alert-field' ).plain() ); + } ); + } + }, + + dateTimeInputWidget: { + isValid: function () { + var ok = !Util.apiBool( this.paramInfo.required ) || this.getApiValue() !== ''; + return $.Deferred().resolve( ok ).promise(); + } + }, + + tokenWidget: { + alertTokenError: function ( code, error ) { + windowManager.openWindow( 'errorAlert', { + title: mw.message( + 'apisandbox-results-fixtoken-fail', this.paramInfo.tokentype + ).parse(), + message: error, + actions: [ + { + action: 'accept', + label: OO.ui.msg( 'ooui-dialog-process-dismiss' ), + flags: 'primary' + } + ] + } ); + }, + fetchToken: function () { + this.pushPending(); + return api.getToken( this.paramInfo.tokentype ) + .done( this.setApiValue.bind( this ) ) + .fail( this.alertTokenError.bind( this ) ) + .always( this.popPending.bind( this ) ); + }, + setApiValue: function ( v ) { + WidgetMethods.textInputWidget.setApiValue.call( this, v ); + if ( v === '123ABC' ) { + this.fetchToken(); + } + } + }, + + passwordWidget: { + getApiValueForDisplay: function () { + return ''; + } + }, + + toggleSwitchWidget: { + getApiValue: function () { + return this.getValue() ? 1 : undefined; + }, + setApiValue: function ( v ) { + this.setValue( Util.apiBool( v ) ); + }, + apiCheckValid: function () { + return $.Deferred().resolve( true ).promise(); + } + }, + + dropdownWidget: { + getApiValue: function () { + var item = this.getMenu().getSelectedItem(); + return item === null ? undefined : item.getData(); + }, + setApiValue: function ( v ) { + var menu = this.getMenu(); + + if ( v === undefined ) { + v = this.paramInfo[ 'default' ]; + } + if ( v === undefined ) { + menu.selectItem(); + } else { + menu.selectItemByData( String( v ) ); + } + }, + apiCheckValid: function () { + var ok = this.getApiValue() !== undefined || suppressErrors; + this.setIcon( ok ? null : 'alert' ); + this.setIconTitle( ok ? '' : mw.message( 'apisandbox-alert-field' ).plain() ); + return $.Deferred().resolve( ok ).promise(); + } + }, + + capsuleWidget: { + getApiValue: function () { + return this.getItemsData().join( '|' ); + }, + setApiValue: function ( v ) { + this.setItemsFromData( v === undefined || v === '' ? [] : String( v ).split( '|' ) ); + }, + apiCheckValid: function () { + var ok = this.getApiValue() !== undefined || suppressErrors; + this.setIcon( ok ? null : 'alert' ); + this.setIconTitle( ok ? '' : mw.message( 'apisandbox-alert-field' ).plain() ); + return $.Deferred().resolve( ok ).promise(); + } + }, + + optionalWidget: { + getApiValue: function () { + return this.isDisabled() ? undefined : this.widget.getApiValue(); + }, + setApiValue: function ( v ) { + this.setDisabled( v === undefined ); + this.widget.setApiValue( v ); + }, + apiCheckValid: function () { + if ( this.isDisabled() ) { + return $.Deferred().resolve( true ).promise(); + } else { + return this.widget.apiCheckValid(); + } + } + }, + + submoduleWidget: { + single: function () { + var v = this.isDisabled() ? this.paramInfo[ 'default' ] : this.getApiValue(); + return v === undefined ? [] : [ { value: v, path: this.paramInfo.submodules[ v ] } ]; + }, + multi: function () { + var map = this.paramInfo.submodules, + v = this.isDisabled() ? this.paramInfo[ 'default' ] : this.getApiValue(); + return v === undefined || v === '' ? [] : $.map( String( v ).split( '|' ), function ( v ) { + return { value: v, path: map[ v ] }; + } ); + } + }, + + uploadWidget: { + getApiValueForDisplay: function () { + return '...'; + }, + getApiValue: function () { + return this.getValue(); + }, + setApiValue: function () { + // Can't, sorry. + }, + apiCheckValid: function () { + var ok = this.getValue() !== null || suppressErrors; + this.setIcon( ok ? null : 'alert' ); + this.setIconTitle( ok ? '' : mw.message( 'apisandbox-alert-field' ).plain() ); + return $.Deferred().resolve( ok ).promise(); + } + } + }; + + Validators = { + generic: function () { + return !Util.apiBool( this.paramInfo.required ) || this.getApiValue() !== ''; + } + }; + + /** + * @class mw.special.ApiSandbox.Utils + * @private + */ + Util = { + /** + * Fetch API module info + * + * @param {string} module Module to fetch data for + * @return {jQuery.Promise} + */ + fetchModuleInfo: function ( module ) { + var apiPromise, + deferred = $.Deferred(); + + if ( moduleInfoCache.hasOwnProperty( module ) ) { + return deferred + .resolve( moduleInfoCache[ module ] ) + .promise( { abort: function () {} } ); + } else { + apiPromise = api.post( { + action: 'paraminfo', + modules: module, + helpformat: 'html', + uselang: mw.config.get( 'wgUserLanguage' ) + } ).done( function ( data ) { + var info; + + if ( data.warnings && data.warnings.paraminfo ) { + deferred.reject( '???', data.warnings.paraminfo[ '*' ] ); + return; + } + + info = data.paraminfo.modules; + if ( !info || info.length !== 1 || info[ 0 ].path !== module ) { + deferred.reject( '???', 'No module data returned' ); + return; + } + + moduleInfoCache[ module ] = info[ 0 ]; + deferred.resolve( info[ 0 ] ); + } ).fail( function ( code, details ) { + if ( code === 'http' ) { + details = 'HTTP error: ' + details.exception; + } else if ( details.error ) { + details = details.error.info; + } + deferred.reject( code, details ); + } ); + return deferred + .promise( { abort: apiPromise.abort } ); + } + }, + + /** + * Mark all currently-in-use tokens as bad + */ + markTokensBad: function () { + var page, subpages, i, + checkPages = [ pages.main ]; + + while ( checkPages.length ) { + page = checkPages.shift(); + + if ( page.tokenWidget ) { + api.badToken( page.tokenWidget.paramInfo.tokentype ); + } + + subpages = page.getSubpages(); + for ( i = 0; i < subpages.length; i++ ) { + if ( pages.hasOwnProperty( subpages[ i ].key ) ) { + checkPages.push( pages[ subpages[ i ].key ] ); + } + } + } + }, + + /** + * Test an API boolean + * + * @param {Mixed} value + * @return {boolean} + */ + apiBool: function ( value ) { + return value !== undefined && value !== false; + }, + + /** + * Create a widget for a parameter. + * + * @param {Object} pi Parameter info from API + * @param {Object} opts Additional options + * @return {OO.ui.Widget} + */ + createWidgetForParameter: function ( pi, opts ) { + var widget, innerWidget, finalWidget, items, $button, $content, func, + multiMode = 'none'; + + opts = opts || {}; + + switch ( pi.type ) { + case 'boolean': + widget = new OO.ui.ToggleSwitchWidget(); + widget.paramInfo = pi; + $.extend( widget, WidgetMethods.toggleSwitchWidget ); + pi.required = true; // Avoid wrapping in the non-required widget + break; + + case 'string': + case 'user': + if ( pi.tokentype ) { + widget = new TextInputWithIndicatorWidget( { + input: { + indicator: 'previous', + indicatorTitle: mw.message( 'apisandbox-fetch-token' ).text(), + required: Util.apiBool( pi.required ) + } + } ); + } else if ( Util.apiBool( pi.multi ) ) { + widget = new OO.ui.CapsuleMultiSelectWidget( { + allowArbitrary: true + } ); + widget.paramInfo = pi; + $.extend( widget, WidgetMethods.capsuleWidget ); + } else { + widget = new OO.ui.TextInputWidget( { + required: Util.apiBool( pi.required ) + } ); + } + if ( !Util.apiBool( pi.multi ) ) { + widget.paramInfo = pi; + $.extend( widget, WidgetMethods.textInputWidget ); + widget.setValidation( Validators.generic ); + } + if ( pi.tokentype ) { + $.extend( widget, WidgetMethods.tokenWidget ); + widget.input.paramInfo = pi; + $.extend( widget.input, WidgetMethods.textInputWidget ); + $.extend( widget.input, WidgetMethods.tokenWidget ); + widget.on( 'indicator', widget.fetchToken, [], widget ); + } + break; + + case 'text': + widget = new OO.ui.TextInputWidget( { + multiline: true, + required: Util.apiBool( pi.required ) + } ); + widget.paramInfo = pi; + $.extend( widget, WidgetMethods.textInputWidget ); + widget.setValidation( Validators.generic ); + break; + + case 'password': + widget = new OO.ui.TextInputWidget( { + type: 'password', + required: Util.apiBool( pi.required ) + } ); + widget.paramInfo = pi; + $.extend( widget, WidgetMethods.textInputWidget ); + $.extend( widget, WidgetMethods.passwordWidget ); + widget.setValidation( Validators.generic ); + multiMode = 'enter'; + break; + + case 'integer': + widget = new OO.ui.NumberInputWidget( { + required: Util.apiBool( pi.required ), + isInteger: true + } ); + widget.setIcon = widget.input.setIcon.bind( widget.input ); + widget.setIconTitle = widget.input.setIconTitle.bind( widget.input ); + widget.isValid = widget.input.isValid.bind( widget.input ); + widget.paramInfo = pi; + $.extend( widget, WidgetMethods.textInputWidget ); + if ( Util.apiBool( pi.enforcerange ) ) { + widget.setRange( pi.min || -Infinity, pi.max || Infinity ); + } + multiMode = 'enter'; + break; + + case 'limit': + widget = new OO.ui.NumberInputWidget( { + required: Util.apiBool( pi.required ), + isInteger: true + } ); + widget.setIcon = widget.input.setIcon.bind( widget.input ); + widget.setIconTitle = widget.input.setIconTitle.bind( widget.input ); + widget.isValid = widget.input.isValid.bind( widget.input ); + widget.input.setValidation( function ( value ) { + return value === 'max' || widget.validateNumber( value ); + } ); + widget.paramInfo = pi; + $.extend( widget, WidgetMethods.textInputWidget ); + widget.setRange( pi.min || 0, mw.config.get( 'apihighlimits' ) ? pi.highmax : pi.max ); + multiMode = 'enter'; + break; + + case 'timestamp': + widget = new mw.widgets.datetime.DateTimeInputWidget( { + formatter: { + format: '${year|0}-${month|0}-${day|0}T${hour|0}:${minute|0}:${second|0}${zone|short}' + }, + required: Util.apiBool( pi.required ), + clearable: false + } ); + widget.paramInfo = pi; + $.extend( widget, WidgetMethods.textInputWidget ); + $.extend( widget, WidgetMethods.dateTimeInputWidget ); + multiMode = 'indicator'; + break; + + case 'upload': + widget = new OO.ui.SelectFileWidget(); + widget.paramInfo = pi; + $.extend( widget, WidgetMethods.uploadWidget ); + break; + + case 'namespace': + items = $.map( mw.config.get( 'wgFormattedNamespaces' ), function ( name, ns ) { + if ( ns === '0' ) { + name = mw.message( 'blanknamespace' ).text(); + } + return new OO.ui.MenuOptionWidget( { data: ns, label: name } ); + } ).sort( function ( a, b ) { + return a.data - b.data; + } ); + if ( Util.apiBool( pi.multi ) ) { + widget = new OO.ui.CapsuleMultiSelectWidget( { + menu: { items: items } + } ); + widget.paramInfo = pi; + $.extend( widget, WidgetMethods.capsuleWidget ); + } else { + widget = new OO.ui.DropdownWidget( { + menu: { items: items } + } ); + widget.paramInfo = pi; + $.extend( widget, WidgetMethods.dropdownWidget ); + } + break; + + default: + if ( !$.isArray( pi.type ) ) { + throw new Error( 'Unknown parameter type ' + pi.type ); + } + + items = $.map( pi.type, function ( v ) { + return new OO.ui.MenuOptionWidget( { data: String( v ), label: String( v ) } ); + } ); + if ( Util.apiBool( pi.multi ) ) { + widget = new OO.ui.CapsuleMultiSelectWidget( { + menu: { items: items } + } ); + widget.paramInfo = pi; + $.extend( widget, WidgetMethods.capsuleWidget ); + if ( Util.apiBool( pi.submodules ) ) { + widget.getSubmodules = WidgetMethods.submoduleWidget.multi; + widget.on( 'change', ApiSandbox.updateUI ); + } + } else { + widget = new OO.ui.DropdownWidget( { + menu: { items: items } + } ); + widget.paramInfo = pi; + $.extend( widget, WidgetMethods.dropdownWidget ); + if ( Util.apiBool( pi.submodules ) ) { + widget.getSubmodules = WidgetMethods.submoduleWidget.single; + widget.getMenu().on( 'choose', ApiSandbox.updateUI ); + } + } + + break; + } + + if ( Util.apiBool( pi.multi ) && multiMode !== 'none' ) { + innerWidget = widget; + switch ( multiMode ) { + case 'enter': + $content = innerWidget.$element; + break; + + case 'indicator': + $button = innerWidget.$indicator; + $button.css( 'cursor', 'pointer' ); + $button.attr( 'tabindex', 0 ); + $button.parent().append( $button ); + innerWidget.setIndicator( 'next' ); + $content = innerWidget.$element; + break; + + default: + throw new Error( 'Unknown multiMode "' + multiMode + '"' ); + } + + widget = new OO.ui.CapsuleMultiSelectWidget( { + allowArbitrary: true, + popup: { + classes: [ 'mw-apisandbox-popup' ], + $content: $content + } + } ); + widget.paramInfo = pi; + $.extend( widget, WidgetMethods.capsuleWidget ); + + func = function () { + if ( !innerWidget.isDisabled() ) { + innerWidget.apiCheckValid().done( function ( ok ) { + if ( ok ) { + widget.addItemsFromData( [ innerWidget.getApiValue() ] ); + innerWidget.setApiValue( undefined ); + } + } ); + return false; + } + }; + switch ( multiMode ) { + case 'enter': + innerWidget.connect( null, { enter: func } ); + break; + + case 'indicator': + $button.on( { + click: func, + keypress: function ( e ) { + if ( e.which === OO.ui.Keys.SPACE || e.which === OO.ui.Keys.ENTER ) { + func(); + } + } + } ); + break; + } + } + + if ( Util.apiBool( pi.required ) || opts.nooptional ) { + finalWidget = widget; + } else { + finalWidget = new OptionalWidget( widget ); + finalWidget.paramInfo = pi; + $.extend( finalWidget, WidgetMethods.optionalWidget ); + if ( widget.getSubmodules ) { + finalWidget.getSubmodules = widget.getSubmodules.bind( widget ); + finalWidget.on( 'disable', function () { setTimeout( ApiSandbox.updateUI ); } ); + } + finalWidget.setDisabled( true ); + } + + widget.setApiValue( pi[ 'default' ] ); + + return finalWidget; + }, + + /** + * Parse an HTML string, adding target="_blank" to any links + * + * @param {string} html HTML to parse + * @return {jQuery} + */ + parseHTML: function ( html ) { + var $ret = $( $.parseHTML( html ) ); + $ret.filter( 'a' ).add( $ret.find( 'a' ) ) + .filter( '[href]:not([target])' ) + .attr( 'target', '_blank' ); + return $ret; + } + }; + + /** + * Interface to ApiSandbox UI + * + * @class mw.special.ApiSandbox + */ + mw.special.ApiSandbox = ApiSandbox = { + /** + * Initialize the UI + * + * Automatically called on $.ready() + */ + init: function () { + var $toolbar; + + $content = $( '#mw-apisandbox' ); + + windowManager = new OO.ui.WindowManager(); + $( 'body' ).append( windowManager.$element ); + windowManager.addWindows( { + errorAlert: new OO.ui.MessageDialog() + } ); + + fullscreenButton = new OO.ui.ButtonWidget( { + label: mw.message( 'apisandbox-fullscreen' ).text(), + title: mw.message( 'apisandbox-fullscreen-tooltip' ).text() + } ).on( 'click', ApiSandbox.toggleFullscreen ); + + $toolbar = $( '
' ) + .addClass( 'mw-apisandbox-toolbar' ) + .append( + fullscreenButton.$element, + new OO.ui.ButtonWidget( { + label: mw.message( 'apisandbox-submit' ).text(), + flags: [ 'primary', 'constructive' ] + } ).on( 'click', ApiSandbox.sendRequest ).$element, + new OO.ui.ButtonWidget( { + label: mw.message( 'apisandbox-reset' ).text(), + flags: 'destructive' + } ).on( 'click', ApiSandbox.resetUI ).$element + ); + + booklet = new OO.ui.BookletLayout( { + outlined: true, + autoFocus: false + } ); + + panel = new OO.ui.PanelLayout( { + classes: [ 'mw-apisandbox-container' ], + content: [ booklet ], + expanded: false, + framed: true + } ); + + pages.main = new ApiSandbox.PageLayout( { key: 'main', path: 'main' } ); + + // Parse the current hash string + if ( !ApiSandbox.loadFromHash() ) { + ApiSandbox.updateUI(); + } + + // If the hashchange event exists, use it. Otherwise, fake it. + // And, of course, IE has to be dumb. + if ( 'onhashchange' in window && + ( document.documentMode === undefined || document.documentMode >= 8 ) + ) { + $( window ).on( 'hashchange', ApiSandbox.loadFromHash ); + } else { + setInterval( function () { + if ( oldhash !== location.hash ) { + ApiSandbox.loadFromHash(); + } + }, 1000 ); + } + + $content + .empty() + .append( $( '

' ).append( mw.message( 'apisandbox-intro' ).parse() ) ) + .append( + $( '

', { id: 'mw-apisandbox-ui' } ) + .append( $toolbar ) + .append( panel.$element ) + ); + + $( window ).on( 'resize', ApiSandbox.resizePanel ); + + ApiSandbox.resizePanel(); + }, + + /** + * Toggle "fullscreen" mode + */ + toggleFullscreen: function () { + var $body = $( document.body ); + + $body.toggleClass( 'mw-apisandbox-fullscreen' ); + if ( $body.hasClass( 'mw-apisandbox-fullscreen' ) ) { + fullscreenButton.setLabel( mw.message( 'apisandbox-unfullscreen' ).text() ); + fullscreenButton.setTitle( mw.message( 'apisandbox-unfullscreen-tooltip' ).text() ); + $body.append( $( '#mw-apisandbox-ui' ) ); + } else { + fullscreenButton.setLabel( mw.message( 'apisandbox-fullscreen' ).text() ); + fullscreenButton.setTitle( mw.message( 'apisandbox-fullscreen-tooltip' ).text() ); + $content.append( $( '#mw-apisandbox-ui' ) ); + } + ApiSandbox.resizePanel(); + }, + + /** + * Set the height of the panel based on the current viewport. + */ + resizePanel: function () { + var height = $( window ).height(), + contentTop = $content.offset().top; + + if ( $( document.body ).hasClass( 'mw-apisandbox-fullscreen' ) ) { + height -= panel.$element.offset().top - $( '#mw-apisandbox-ui' ).offset().top; + panel.$element.height( height - 1 ); + } else { + // Subtract the height of the intro text + height -= panel.$element.offset().top - contentTop; + + panel.$element.height( height - 10 ); + $( window ).scrollTop( contentTop - 5 ); + } + }, + + /** + * Update the current query when the page hash changes + */ + loadFromHash: function () { + var params, m, re, + hash = location.hash; + + if ( oldhash === hash ) { + return false; + } + oldhash = hash; + if ( hash === '' ) { + return false; + } + + // I'm surprised this doesn't seem to exist in jQuery or mw.util. + params = {}; + hash = hash.replace( '+', '%20' ); + re = /([^&=#]+)=?([^&#]*)/g; + while ( ( m = re.exec( hash ) ) ) { + params[ decodeURIComponent( m[ 1 ] ) ] = decodeURIComponent( m[ 2 ] ); + } + + ApiSandbox.updateUI( params ); + return true; + }, + + /** + * Update the pages in the booklet + * + * @param {Object} [params] Optional query parameters to load + */ + updateUI: function ( params ) { + var i, page, subpages, j, removePages, + addPages = []; + + if ( !$.isPlainObject( params ) ) { + params = undefined; + } + + if ( updatingBooklet ) { + return; + } + updatingBooklet = true; + try { + if ( params !== undefined ) { + pages.main.loadQueryParams( params ); + } + addPages.push( pages.main ); + if ( resultPage !== null ) { + addPages.push( resultPage ); + } + pages.main.apiCheckValid(); + + i = 0; + while ( addPages.length ) { + page = addPages.shift(); + if ( bookletPages[ i ] !== page ) { + for ( j = i; j < bookletPages.length; j++ ) { + if ( bookletPages[ j ].getName() === page.getName() ) { + bookletPages.splice( j, 1 ); + } + } + bookletPages.splice( i, 0, page ); + booklet.addPages( [ page ], i ); + } + i++; + + if ( page.getSubpages ) { + subpages = page.getSubpages(); + for ( j = 0; j < subpages.length; j++ ) { + if ( !pages.hasOwnProperty( subpages[ j ].key ) ) { + subpages[ j ].indentLevel = page.indentLevel + 1; + pages[ subpages[ j ].key ] = new ApiSandbox.PageLayout( subpages[ j ] ); + } + if ( params !== undefined ) { + pages[ subpages[ j ].key ].loadQueryParams( params ); + } + addPages.splice( j, 0, pages[ subpages[ j ].key ] ); + pages[ subpages[ j ].key ].apiCheckValid(); + } + } + } + + if ( bookletPages.length > i ) { + removePages = bookletPages.splice( i, bookletPages.length - i ); + booklet.removePages( removePages ); + } + + if ( !booklet.getCurrentPageName() ) { + booklet.selectFirstSelectablePage(); + } + } finally { + updatingBooklet = false; + } + }, + + /** + * Reset button handler + */ + resetUI: function () { + suppressErrors = true; + pages = { + main: new ApiSandbox.PageLayout( { key: 'main', path: 'main' } ) + }; + resultPage = null; + ApiSandbox.updateUI(); + }, + + /** + * Submit button handler + */ + sendRequest: function () { + var page, subpages, i, query, $result, + progress, $progressText, progressLoading, + deferreds = [], + params = {}, + displayParams = {}, + checkPages = [ pages.main ]; + + suppressErrors = false; + + while ( checkPages.length ) { + page = checkPages.shift(); + deferreds.push( page.apiCheckValid() ); + page.getQueryParams( params, displayParams ); + subpages = page.getSubpages(); + for ( i = 0; i < subpages.length; i++ ) { + if ( pages.hasOwnProperty( subpages[ i ].key ) ) { + checkPages.push( pages[ subpages[ i ].key ] ); + } + } + } + + $.when.apply( $, deferreds ).done( function () { + if ( $.inArray( false, arguments ) !== -1 ) { + windowManager.openWindow( 'errorAlert', { + title: mw.message( 'apisandbox-submit-invalid-fields-title' ).parse(), + message: mw.message( 'apisandbox-submit-invalid-fields-message' ).parse(), + actions: [ + { + action: 'accept', + label: OO.ui.msg( 'ooui-dialog-process-dismiss' ), + flags: 'primary' + } + ] + } ); + return; + } + + query = $.param( displayParams ); + + // Force a 'fm' format with wrappedhtml=1, if available + if ( params.format !== undefined ) { + if ( availableFormats.hasOwnProperty( params.format + 'fm' ) ) { + params.format = params.format + 'fm'; + } + if ( params.format.substr( -2 ) === 'fm' ) { + params.wrappedhtml = 1; + } + } + + progressLoading = false; + $progressText = $( '' ).text( mw.message( 'apisandbox-sending-request' ).text() ); + progress = new OO.ui.ProgressBarWidget( { + progress: false, + $content: $progressText + } ); + + $result = $( '
' ) + .append( progress.$element ); + + resultPage = page = new OO.ui.PageLayout( '|results|' ); + page.setupOutlineItem = function () { + this.outlineItem.setLabel( mw.message( 'apisandbox-results' ).text() ); + }; + page.$element.empty() + .append( + new OO.ui.FieldLayout( + new OO.ui.TextInputWidget( { + readOnly: true, + value: mw.util.wikiScript( 'api' ) + '?' + query + } ), { + label: mw.message( 'apisandbox-request-url-label' ).parse() + } + ).$element, + $result + ); + ApiSandbox.updateUI(); + booklet.setPage( '|results|' ); + + location.href = oldhash = '#' + query; + + api.post( params, { + contentType: 'multipart/form-data', + dataType: 'text', + xhr: function () { + var xhr = new window.XMLHttpRequest(); + xhr.upload.addEventListener( 'progress', function ( e ) { + if ( !progressLoading ) { + if ( e.lengthComputable ) { + progress.setProgress( e.loaded * 100 / e.total ); + } else { + progress.setProgress( false ); + } + } + } ); + xhr.addEventListener( 'progress', function ( e ) { + if ( !progressLoading ) { + progressLoading = true; + $progressText.text( mw.message( 'apisandbox-loading-results' ).text() ); + } + if ( e.lengthComputable ) { + progress.setProgress( e.loaded * 100 / e.total ); + } else { + progress.setProgress( false ); + } + } ); + return xhr; + } + } ) + .fail( function ( code, data ) { + var details = 'HTTP error: ' + data.exception; + $result.empty() + .append( + new OO.ui.LabelWidget( { + label: mw.message( 'apisandbox-results-error', details ).text(), + classes: [ 'error' ] + } ).$element + ); + } ) + .done( function ( data, jqXHR ) { + var m, loadTime, button, + ct = jqXHR.getResponseHeader( 'Content-Type' ); + + $result.empty(); + if ( /^text\/mediawiki-api-prettyprint-wrapped(?:;|$)/.test( ct ) ) { + data = $.parseJSON( data ); + if ( data.modules.length ) { + mw.loader.load( data.modules ); + } + $result.append( Util.parseHTML( data.html ) ); + loadTime = data.time; + } else if ( ( m = data.match( /][\s\S]*<\/pre>/ ) ) ) { + $result.append( Util.parseHTML( m[ 0 ] ) ); + if ( ( m = data.match( /"wgBackendResponseTime":\s*(\d+)/ ) ) ) { + loadTime = parseInt( m[ 1 ], 10 ); + } + } else { + $( '
' )
+								.addClass( 'api-pretty-content' )
+								.text( data )
+								.appendTo( $result );
+						}
+						if ( typeof loadTime === 'number' ) {
+							$result.append(
+								$( '
' ).append( + new OO.ui.LabelWidget( { + label: mw.message( 'apisandbox-request-time', loadTime ).text() + } ).$element + ) + ); + } + + if ( jqXHR.getResponseHeader( 'MediaWiki-API-Error' ) === 'badtoken' ) { + // Flush all saved tokens in case one of them is the bad one. + Util.markTokensBad(); + button = new OO.ui.ButtonWidget( { + label: mw.message( 'apisandbox-results-fixtoken' ).text() + } ); + button.on( 'click', ApiSandbox.fixTokenAndResend ) + .on( 'click', button.setDisabled, [ true ], button ) + .$element.appendTo( $result ); + } + } ); + } ); + }, + + /** + * Handler for the "Correct token and resubmit" button + * + * Used on a 'badtoken' error, it re-fetches token parameters for all + * pages and then re-submits the query. + */ + fixTokenAndResend: function () { + var page, subpages, i, k, + ok = true, + tokenWait = { dummy: true }, + checkPages = [ pages.main ], + success = function ( k ) { + delete tokenWait[ k ]; + if ( ok && $.isEmptyObject( tokenWait ) ) { + ApiSandbox.sendRequest(); + } + }, + failure = function ( k ) { + delete tokenWait[ k ]; + ok = false; + }; + + while ( checkPages.length ) { + page = checkPages.shift(); + + if ( page.tokenWidget ) { + k = page.apiModule + page.tokenWidget.paramInfo.name; + tokenWait[ k ] = page.tokenWidget.fetchToken() + .done( success.bind( page.tokenWidget, k ) ) + .fail( failure.bind( page.tokenWidget, k ) ); + } + + subpages = page.getSubpages(); + for ( i = 0; i < subpages.length; i++ ) { + if ( pages.hasOwnProperty( subpages[ i ].key ) ) { + checkPages.push( pages[ subpages[ i ].key ] ); + } + } + } + + success( 'dummy', '' ); + }, + + /** + * Reset validity indicators for all widgets + */ + updateValidityIndicators: function () { + var page, subpages, i, + checkPages = [ pages.main ]; + + while ( checkPages.length ) { + page = checkPages.shift(); + page.apiCheckValid(); + subpages = page.getSubpages(); + for ( i = 0; i < subpages.length; i++ ) { + if ( pages.hasOwnProperty( subpages[ i ].key ) ) { + checkPages.push( pages[ subpages[ i ].key ] ); + } + } + } + } + }; + + /** + * PageLayout for API modules + * + * @class + * @private + * @extends OO.ui.PageLayout + * @constructor + * @param {Object} [config] Configuration options + */ + ApiSandbox.PageLayout = function ( config ) { + config = $.extend( { prefix: '' }, config ); + this.displayText = config.key; + this.apiModule = config.path; + this.prefix = config.prefix; + this.paramInfo = null; + this.apiIsValid = true; + this.loadFromQueryParams = null; + this.widgets = {}; + this.tokenWidget = null; + this.indentLevel = config.indentLevel ? config.indentLevel : 0; + ApiSandbox.PageLayout[ 'super' ].call( this, config.key, config ); + this.loadParamInfo(); + }; + OO.inheritClass( ApiSandbox.PageLayout, OO.ui.PageLayout ); + ApiSandbox.PageLayout.prototype.setupOutlineItem = function () { + this.outlineItem.setLevel( this.indentLevel ); + this.outlineItem.setLabel( this.displayText ); + this.outlineItem.setIcon( this.apiIsValid || suppressErrors ? null : 'alert' ); + this.outlineItem.setIconTitle( + this.apiIsValid || suppressErrors ? '' : mw.message( 'apisandbox-alert-page' ).plain() + ); + }; + + /** + * Fetch module information for this page's module, then create UI + */ + ApiSandbox.PageLayout.prototype.loadParamInfo = function () { + var dynamicFieldset, dynamicParamNameWidget, + that = this, + addDynamicParamWidget = function () { + var name, layout, widget, button; + + // Check name is filled in + name = dynamicParamNameWidget.getValue().trim(); + if ( name === '' ) { + dynamicParamNameWidget.focus(); + return; + } + + if ( that.widgets[ name ] !== undefined ) { + windowManager.openWindow( 'errorAlert', { + title: mw.message( + 'apisandbox-dynamic-error-exists', name + ).parse(), + actions: [ + { + action: 'accept', + label: OO.ui.msg( 'ooui-dialog-process-dismiss' ), + flags: 'primary' + } + ] + } ); + return; + } + + widget = Util.createWidgetForParameter( { + name: name, + type: 'string', + 'default': '' + }, { + nooptional: true + } ); + button = new OO.ui.ButtonWidget( { + icon: 'remove', + flags: 'destructive' + } ); + layout = new OO.ui.ActionFieldLayout( + widget, + button, + { + label: name, + align: 'left' + } + ); + button.on( 'click', removeDynamicParamWidget, [ name, layout ] ); + that.widgets[ name ] = widget; + dynamicFieldset.addItems( [ layout ], dynamicFieldset.getItems().length - 1 ); + widget.focus(); + + dynamicParamNameWidget.setValue( '' ); + }, + removeDynamicParamWidget = function ( name, layout ) { + dynamicFieldset.removeItems( [ layout ] ); + delete that.widgets[ name ]; + }; + + this.$element.empty() + .append( new OO.ui.ProgressBarWidget( { + progress: false, + text: mw.message( 'apisandbox-loading', this.displayText ).text() + } ).$element ); + + Util.fetchModuleInfo( this.apiModule ) + .done( function ( pi ) { + var prefix, i, j, dl, widget, $widgetLabel, widgetField, helpField, tmp, flag, count, + items = [], + deprecatedItems = [], + buttons = [], + filterFmModules = function ( v ) { + return v.substr( -2 ) !== 'fm' || + !availableFormats.hasOwnProperty( v.substr( 0, v.length - 2 ) ); + }, + widgetLabelOnClick = function () { + var f = this.getField(); + if ( $.isFunction( f.setDisabled ) ) { + f.setDisabled( false ); + } + if ( $.isFunction( f.focus ) ) { + f.focus(); + } + }, + doNothing = function () {}; + + // This is something of a hack. We always want the 'format' and + // 'action' parameters from the main module to be specified, + // and for 'format' we also want to simplify the dropdown since + // we always send the 'fm' variant. + if ( that.apiModule === 'main' ) { + for ( i = 0; i < pi.parameters.length; i++ ) { + if ( pi.parameters[ i ].name === 'action' ) { + pi.parameters[ i ].required = true; + delete pi.parameters[ i ][ 'default' ]; + } + if ( pi.parameters[ i ].name === 'format' ) { + tmp = pi.parameters[ i ].type; + for ( j = 0; j < tmp.length; j++ ) { + availableFormats[ tmp[ j ] ] = true; + } + pi.parameters[ i ].type = $.grep( tmp, filterFmModules ); + pi.parameters[ i ][ 'default' ] = 'json'; + pi.parameters[ i ].required = true; + } + } + } + + // Hide the 'wrappedhtml' parameter on format modules + if ( pi.group === 'format' ) { + pi.parameters = $.grep( pi.parameters, function ( p ) { + return p.name !== 'wrappedhtml'; + } ); + } + + that.paramInfo = pi; + + items.push( new OO.ui.FieldLayout( + new OO.ui.Widget( {} ).toggle( false ), { + align: 'top', + label: Util.parseHTML( pi.description ) + } + ) ); + + if ( pi.helpurls.length ) { + buttons.push( new OO.ui.PopupButtonWidget( { + label: mw.message( 'apisandbox-helpurls' ).text(), + icon: 'help', + popup: { + $content: $( '