From ba5c1a18ac9edf5334b8bee6a52cfef85fca0ef3 Mon Sep 17 00:00:00 2001 From: MusikAnimal Date: Tue, 30 Dec 2025 15:46:22 -0500 Subject: [PATCH] Switch to MediaWiki coding style, use OpenAPI attributes The convention now for methods with attributes is to have first the OpenAPI attributes (if applicable), the routing attributes, followed by the PHP doc block. Use more of PHP 8+ features like str_starts_with and first class callable syntax. --- .editorconfig | 13 +- assets/js/adminstats.js | 50 +- assets/js/authorship.js | 22 +- assets/js/autoedits.js | 126 +- assets/js/blame.js | 70 +- assets/js/categoryedits.js | 186 +- assets/js/common/application.js | 1104 ++++----- assets/js/common/contributions-lists.js | 256 +- assets/js/editcounter.js | 971 ++++---- assets/js/globalcontribs.js | 14 +- assets/js/pageinfo.js | 74 +- assets/js/pages.js | 122 +- assets/js/topedits.js | 18 +- composer.json | 9 +- composer.lock | 2177 +++++++++------- config/bundles.php | 24 +- config/packages/nelmio_api_doc.yaml | 2 +- config/preload.php | 6 +- phpcs.xml | 111 +- public/build/app.9cc563c1.js | 2 + ...ICENSE.txt => app.9cc563c1.js.LICENSE.txt} | 0 public/build/app.a7ec0e72.js | 2 - public/build/entrypoints.json | 2 +- public/build/manifest.json | 2 +- src/Controller/AdminScoreController.php | 94 +- src/Controller/AdminStatsController.php | 669 +++-- src/Controller/AuthorshipController.php | 188 +- src/Controller/AutomatedEditsController.php | 903 +++---- src/Controller/BlameController.php | 167 +- src/Controller/CategoryEditsController.php | 464 ++-- src/Controller/DefaultController.php | 592 ++--- src/Controller/EditCounterController.php | 1490 +++++------ src/Controller/EditSummaryController.php | 295 +-- src/Controller/GlobalContribsController.php | 372 +-- src/Controller/LargestPagesController.php | 264 +- src/Controller/MetaController.php | 473 ++-- src/Controller/PageInfoController.php | 1297 +++++----- src/Controller/PagesController.php | 794 +++--- src/Controller/QuoteController.php | 449 ++-- .../SimpleEditCounterController.php | 268 +- src/Controller/TopEditsController.php | 567 ++--- src/Controller/XtoolsController.php | 1970 ++++++++------- .../DisabledToolSubscriber.php | 66 +- src/EventSubscriber/ExceptionListener.php | 227 +- src/EventSubscriber/RateLimitSubscriber.php | 465 ++-- src/Exception/BadGatewayException.php | 35 +- src/Exception/XtoolsHttpException.php | 92 +- src/Helper/AutomatedEditsHelper.php | 528 ++-- src/Helper/I18nHelper.php | 573 +++-- src/Kernel.php | 7 +- src/Model/AdminScore.php | 205 +- src/Model/AdminStats.php | 502 ++-- src/Model/Authorship.php | 597 +++-- src/Model/AutoEdits.php | 501 ++-- src/Model/Blame.php | 353 ++- src/Model/CategoryEdits.php | 366 ++- src/Model/Edit.php | 999 ++++---- src/Model/EditCounter.php | 2106 ++++++++-------- src/Model/EditSummary.php | 539 ++-- src/Model/GlobalContribs.php | 316 ++- src/Model/LargestPages.php | 98 +- src/Model/Model.php | 317 ++- src/Model/Page.php | 903 ++++--- src/Model/PageAssessments.php | 551 ++--- src/Model/PageInfo.php | 2185 ++++++++--------- src/Model/PageInfoApi.php | 919 ++++--- src/Model/Pages.php | 940 ++++--- src/Model/Project.php | 729 +++--- src/Model/SimpleEditCounter.php | 350 ++- src/Model/TopEdits.php | 691 +++--- src/Model/User.php | 849 ++++--- src/Model/UserRights.php | 908 ++++--- src/Monolog/WebProcessorMonolog.php | 61 +- src/Repository/AdminScoreRepository.php | 44 +- src/Repository/AdminStatsRepository.php | 362 ++- src/Repository/AuthorshipRepository.php | 96 +- src/Repository/AutoEditsRepository.php | 1244 +++++----- src/Repository/BlameRepository.php | 72 +- src/Repository/CategoryEditsRepository.php | 409 ++- src/Repository/EditCounterRepository.php | 865 ++++--- src/Repository/EditRepository.php | 147 +- src/Repository/EditSummaryRepository.php | 134 +- src/Repository/GlobalContribsRepository.php | 573 +++-- src/Repository/LargestPagesRepository.php | 168 +- src/Repository/PageAssessmentsRepository.php | 85 +- src/Repository/PageInfoRepository.php | 649 +++-- src/Repository/PageRepository.php | 840 ++++--- src/Repository/PagesRepository.php | 708 +++--- src/Repository/ProjectRepository.php | 755 +++--- src/Repository/Repository.php | 836 ++++--- .../SimpleEditCounterRepository.php | 203 +- src/Repository/TopEditsRepository.php | 633 +++-- src/Repository/UserRepository.php | 656 +++-- src/Repository/UserRightsRepository.php | 503 ++-- src/Twig/AppExtension.php | 1319 +++++----- src/Twig/TopNavExtension.php | 285 ++- tests/Controller/AdminStatsControllerTest.php | 59 +- tests/Controller/AuthorshipControllerTest.php | 32 +- .../AutomatedEditsControllerTest.php | 230 +- .../CategoryEditsControllerTest.php | 36 +- tests/Controller/ControllerTestAdapter.php | 97 +- tests/Controller/DefaultControllerTest.php | 304 ++- .../Controller/EditCounterControllerTest.php | 270 +- .../Controller/EditSummaryControllerTest.php | 57 +- .../GlobalContribsControllerTest.php | 32 +- tests/Controller/MetaControllerTest.php | 32 +- .../OverridableXtoolsController.php | 207 +- tests/Controller/PageInfoControllerTest.php | 218 +- tests/Controller/PagesControllerTest.php | 86 +- .../SimpleEditCounterControllerTest.php | 38 +- tests/Controller/TopEditsControllerTest.php | 118 +- tests/Controller/XtoolsControllerTest.php | 942 ++++--- tests/Exception/BadGatewayExceptionTest.php | 19 +- tests/Helper/AutomatedEditsTest.php | 305 ++- tests/Helper/I18nHelperTest.php | 100 +- tests/Model/AdminStatsTest.php | 320 ++- tests/Model/AuthorshipTest.php | 130 +- tests/Model/AutoEditsTest.php | 462 ++-- tests/Model/BlameTest.php | 134 +- tests/Model/CategoryEditsTest.php | 291 ++- tests/Model/EditCounterTest.php | 1083 ++++---- tests/Model/EditSummaryTest.php | 249 +- tests/Model/EditTest.php | 475 ++-- tests/Model/GlobalContribsTest.php | 188 +- tests/Model/LargestPagesTest.php | 32 +- tests/Model/ModelTest.php | 67 +- tests/Model/PageAssessmentsTest.php | 209 +- tests/Model/PageInfoTest.php | 1028 ++++---- tests/Model/PageTest.php | 610 +++-- tests/Model/PagesTest.php | 477 ++-- tests/Model/ProjectTest.php | 454 ++-- tests/Model/TopEditsTest.php | 526 ++-- tests/Model/UserRightsTest.php | 529 ++-- tests/Model/UserTest.php | 604 +++-- tests/Repository/RepositoryTest.php | 149 +- tests/SessionHelper.php | 83 +- tests/TestAdapter.php | 112 +- tests/Twig/AppExtensionTest.php | 444 ++-- tests/Twig/TopNavExtensionTest.php | 152 +- webpack.config.js | 135 +- 140 files changed, 28960 insertions(+), 29408 deletions(-) create mode 100644 public/build/app.9cc563c1.js rename public/build/{app.a7ec0e72.js.LICENSE.txt => app.9cc563c1.js.LICENSE.txt} (100%) delete mode 100644 public/build/app.a7ec0e72.js diff --git a/.editorconfig b/.editorconfig index 1a8ee72f2..719ada4f4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,11 +2,16 @@ # Unix-style newlines with a newline ending every file [*] +indent_style = tab +indent_size = tab +tab_width = 4 end_of_line = lf -insert_final_newline = true +charset = utf-8 trim_trailing_whitespace = true +insert_final_newline = true -# 4 space indentation -[*.{php,js,twig}] +# Tabs may not be valid YAML +# @see https://yaml.org/spec/1.2/spec.html#id2777534 +[*.{yml,yaml}] indent_style = space -indent_size = 4 +indent_size = 2 diff --git a/assets/js/adminstats.js b/assets/js/adminstats.js index 92a02dec0..265553367 100644 --- a/assets/js/adminstats.js +++ b/assets/js/adminstats.js @@ -1,35 +1,35 @@ xtools.adminstats = {}; $(function () { - var $projectInput = $('#project_input'), - lastProject = $projectInput.val(); + var $projectInput = $('#project_input'), + lastProject = $projectInput.val(); - // Don't do anything if this isn't an Admin Stats page. - if ($('body.adminstats, body.patrollerstats, body.stewardstats').length === 0) { - return; - } + // Don't do anything if this isn't an Admin Stats page. + if ($('body.adminstats, body.patrollerstats, body.stewardstats').length === 0) { + return; + } - xtools.application.setupMultiSelectListeners(); + xtools.application.setupMultiSelectListeners(); - $('.group-selector').on('change', function () { - $('.action-selector').addClass('hidden'); - $('.action-selector--' + $(this).val()).removeClass('hidden'); + $('.group-selector').on('change', function () { + $('.action-selector').addClass('hidden'); + $('.action-selector--' + $(this).val()).removeClass('hidden'); - // Update title of form. - $('.xt-page-title--title').text($.i18n('tool-' + $(this).val() + 'stats')); - $('.xt-page-title--desc').text($.i18n('tool-' + $(this).val() + 'stats-desc')); - var title = $.i18n('tool-' + $(this).val() + 'stats') + ' - ' + $.i18n('xtools-title'); - document.title = title; - history.replaceState({}, title, '/' + $(this).val() + 'stats'); + // Update title of form. + $('.xt-page-title--title').text($.i18n('tool-' + $(this).val() + 'stats')); + $('.xt-page-title--desc').text($.i18n('tool-' + $(this).val() + 'stats-desc')); + var title = $.i18n('tool-' + $(this).val() + 'stats') + ' - ' + $.i18n('xtools-title'); + document.title = title; + history.replaceState({}, title, '/' + $(this).val() + 'stats'); - // Change project to Meta if it's Steward Stats. - if ('steward' === $(this).val()) { - lastProject = $projectInput.val(); - $projectInput.val('meta.wikimedia.org'); - } else { - $projectInput.val(lastProject); - } + // Change project to Meta if it's Steward Stats. + if ('steward' === $(this).val()) { + lastProject = $projectInput.val(); + $projectInput.val('meta.wikimedia.org'); + } else { + $projectInput.val(lastProject); + } - xtools.application.setupMultiSelectListeners(); - }); + xtools.application.setupMultiSelectListeners(); + }); }); diff --git a/assets/js/authorship.js b/assets/js/authorship.js index b0b29304c..6ac2d93cb 100644 --- a/assets/js/authorship.js +++ b/assets/js/authorship.js @@ -1,16 +1,16 @@ $(function () { - if (!$('body.authorship').length) { - return; - } + if (!$('body.authorship').length) { + return; + } - const $showSelector = $('#show_selector'); + const $showSelector = $('#show_selector'); - $showSelector.on('change', e => { - $('.show-option').addClass('hidden') - .find('input').prop('disabled', true); - $(`.show-option--${e.target.value}`).removeClass('hidden') - .find('input').prop('disabled', false); - }); + $showSelector.on('change', e => { + $('.show-option').addClass('hidden') + .find('input').prop('disabled', true); + $(`.show - option--${e.target.value}`).removeClass('hidden') + .find('input').prop('disabled', false); + }); - window.onload = () => $showSelector.trigger('change'); + window.onload = () => $showSelector.trigger('change'); }); diff --git a/assets/js/autoedits.js b/assets/js/autoedits.js index 24e25fa4a..65a16a653 100644 --- a/assets/js/autoedits.js +++ b/assets/js/autoedits.js @@ -1,78 +1,78 @@ xtools.autoedits = {}; $(function () { - if (!$('body.autoedits').length) { - return; - } + if (!$('body.autoedits').length) { + return; + } - var $contributionsContainer = $('.contributions-container'), - $toolSelector = $('#tool_selector'); + var $contributionsContainer = $('.contributions-container'), + $toolSelector = $('#tool_selector'); - // For the form page. - if ($toolSelector.length) { - xtools.autoedits.fetchTools = function (project) { - $toolSelector.prop('disabled', true); - $.get('/api/project/automated_tools/' + project).done(function (tools) { - if (tools.error) { - $toolSelector.prop('disabled', false); - return; // Abort, project was invalid. - } + // For the form page. + if ($toolSelector.length) { + xtools.autoedits.fetchTools = function (project) { + $toolSelector.prop('disabled', true); + $.get('/api/project/automated_tools/' + project).done(function (tools) { + if (tools.error) { + $toolSelector.prop('disabled', false); + return; // Abort, project was invalid. + } - // These aren't tools, just metadata in the API response. - delete tools.project; - delete tools.elapsed_time; + // These aren't tools, just metadata in the API response. + delete tools.project; + delete tools.elapsed_time; - $toolSelector.html( - '' + - '' - ); - Object.keys(tools).forEach(function (tool) { - $toolSelector.append( - '' - ); - }); + $toolSelector.html( + '' + + '' + ); + Object.keys(tools).forEach(function (tool) { + $toolSelector.append( + '' + ); + }); - $toolSelector.prop('disabled', false); - }); - }; + $toolSelector.prop('disabled', false); + }); + }; - $(document).ready(function () { - $('#project_input').on('change.autoedits', function () { - xtools.autoedits.fetchTools($('#project_input').val()); - }); - }); + $(document).ready(function () { + $('#project_input').on('change.autoedits', function () { + xtools.autoedits.fetchTools($('#project_input').val()); + }); + }); - xtools.autoedits.fetchTools($('#project_input').val()); + xtools.autoedits.fetchTools($('#project_input').val()); - // All the other code below only applies to result pages. - return; - } + // All the other code below only applies to result pages. + return; + } - // For result pages only... + // For result pages only... - xtools.application.setupToggleTable(window.countsByTool, window.toolsChart, 'count', function (newData) { - var total = 0; - Object.keys(newData).forEach(function (tool) { - total += parseInt(newData[tool].count, 10); - }); - var toolsCount = Object.keys(newData).length; - /** global: i18nLang */ - $('.tools--tools').text( - toolsCount.toLocaleString(i18nLang) + " " + - $.i18n('num-tools', toolsCount) - ); - $('.tools--count').text(total.toLocaleString(i18nLang)); - }); + xtools.application.setupToggleTable(window.countsByTool, window.toolsChart, 'count', function (newData) { + var total = 0; + Object.keys(newData).forEach(function (tool) { + total += parseInt(newData[tool].count, 10); + }); + var toolsCount = Object.keys(newData).length; + /** global: i18nLang */ + $('.tools--tools').text( + toolsCount.toLocaleString(i18nLang) + " " + + $.i18n('num-tools', toolsCount) + ); + $('.tools--count').text(total.toLocaleString(i18nLang)); + }); - if ($contributionsContainer.length) { - // Load the contributions browser, or set up the listeners if it is already present. - var initFunc = $('.contributions-table').length ? 'setupContributionsNavListeners' : 'loadContributions'; - xtools.application[initFunc]( - function (params) { - return `${params.target}-contributions/${params.project}/${params.username}` + - `/${params.namespace}/${params.start}/${params.end}`; - }, - $contributionsContainer.data('target') - ); - } + if ($contributionsContainer.length) { + // Load the contributions browser, or set up the listeners if it is already present. + var initFunc = $('.contributions-table').length ? 'setupContributionsNavListeners' : 'loadContributions'; + xtools.application[initFunc]( + function (params) { + return `${params.target} - contributions / ${params.project} / ${params.username}` + + ` / ${params.namespace} / ${params.start} / ${params.end}`; + }, + $contributionsContainer.data('target') + ); + } }); diff --git a/assets/js/blame.js b/assets/js/blame.js index 079715c5b..b36c3129f 100644 --- a/assets/js/blame.js +++ b/assets/js/blame.js @@ -1,44 +1,44 @@ xtools.blame = {}; $(function () { - if (!$('body.blame').length) { - return; - } + if (!$('body.blame').length) { + return; + } - if ($('.diff-empty').length === $('.diff tr').length - 1) { - $('.diff-empty').eq(0) - .text(`(${$.i18n('diff-empty').toLowerCase()})`) - .addClass('text-muted text-center') - .prop('width', '20%'); - } + if ($('.diff-empty').length === $('.diff tr').length - 1) { + $('.diff-empty').eq(0) + .text(`(${$.i18n('diff-empty').toLowerCase()})`) + .addClass('text-muted text-center') + .prop('width', '20%'); + } - $('.diff-addedline').each(function () { - // Escape query to make regex-safe. - const escapedQuery = xtools.blame.query.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); + $('.diff-addedline').each(function () { + // Escape query to make regex-safe. + const escapedQuery = xtools.blame.query.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); - const highlightMatch = selector => { - const regex = new RegExp(`(${escapedQuery})`, 'gi'); - $(selector).html( - $(selector).html().replace(regex, `$1`) - ); - }; + const highlightMatch = selector => { + const regex = new RegExp(`(${escapedQuery})`, 'gi'); + $(selector).html( + $(selector).html().replace(regex, ` < strong > $1 < / strong > `) + ); + }; - if ($(this).find('.diffchange-inline').length) { - $('.diffchange-inline').each(function () { - highlightMatch(this); - }); - } else { - highlightMatch(this); - } - }); + if ($(this).find('.diffchange-inline').length) { + $('.diffchange-inline').each(function () { + highlightMatch(this); + }); + } else { + highlightMatch(this); + } + }); - // Handles the "Show" dropdown, show/hiding the associated input field accordingly. - const $showSelector = $('#show_selector'); - $showSelector.on('change', e => { - $('.show-option').addClass('hidden') - .find('input').prop('disabled', true); - $(`.show-option--${e.target.value}`).removeClass('hidden') - .find('input').prop('disabled', false); - }); - window.onload = () => $showSelector.trigger('change'); + // Handles the "Show" dropdown, show/hiding the associated input field accordingly. + const $showSelector = $('#show_selector'); + $showSelector.on('change', e => { + $('.show-option').addClass('hidden') + .find('input').prop('disabled', true); + $(`.show - option--${e.target.value}`).removeClass('hidden') + .find('input').prop('disabled', false); + }); + window.onload = () => $showSelector.trigger('change'); }); diff --git a/assets/js/categoryedits.js b/assets/js/categoryedits.js index 09c4dcd79..6a4b959dc 100644 --- a/assets/js/categoryedits.js +++ b/assets/js/categoryedits.js @@ -1,52 +1,52 @@ xtools.categoryedits = {}; $(function () { - if (!$('body.categoryedits').length) { - return; - } + if (!$('body.categoryedits').length) { + return; + } - $(document).ready(function () { - xtools.categoryedits.$select2Input = $('#category_selector'); + $(document).ready(function () { + xtools.categoryedits.$select2Input = $('#category_selector'); - setupCategoryInput(); + setupCategoryInput(); - $('#project_input').on('xtools.projectLoaded', function (_e, data) { - /** global: xtBaseUrl */ - $.get(xtBaseUrl + 'api/project/namespaces/' + data.project).done(function (data) { - setupCategoryInput(data.api, data.namespaces[14]); - }); - }); + $('#project_input').on('xtools.projectLoaded', function (_e, data) { + /** global: xtBaseUrl */ + $.get(xtBaseUrl + 'api/project/namespaces/' + data.project).done(function (data) { + setupCategoryInput(data.api, data.namespaces[14]); + }); + }); - $('form').on('submit', function () { - $('#category_input').val( // Hidden input field - xtools.categoryedits.$select2Input.val().join('|') - ); - }); + $('form').on('submit', function () { + $('#category_input').val( // Hidden input field + xtools.categoryedits.$select2Input.val().join('|') + ); + }); - xtools.application.setupToggleTable(window.countsByCategory, window.categoryChart, 'editCount', function (newData) { - var totalEdits = 0, - totalPages = 0; - Object.keys(newData).forEach(function (category) { - totalEdits += parseInt(newData[category].editCount, 10); - totalPages += parseInt(newData[category].pageCount, 10); - }); - var categoriesCount = Object.keys(newData).length; - /** global: i18nLang */ - $('.category--category').text( - categoriesCount.toLocaleString(i18nLang) + " " + - $.i18n('num-categories', categoriesCount) - ); - $('.category--count').text(totalEdits.toLocaleString(i18nLang)); - $('.category--percent-of-edit-count').text( - ((totalEdits / xtools.categoryedits.userEditCount).toLocaleString(i18nLang) * 100) + '%' - ); - $('.category--pages').text(totalPages.toLocaleString(i18nLang)); - }); + xtools.application.setupToggleTable(window.countsByCategory, window.categoryChart, 'editCount', function (newData) { + var totalEdits = 0, + totalPages = 0; + Object.keys(newData).forEach(function (category) { + totalEdits += parseInt(newData[category].editCount, 10); + totalPages += parseInt(newData[category].pageCount, 10); + }); + var categoriesCount = Object.keys(newData).length; + /** global: i18nLang */ + $('.category--category').text( + categoriesCount.toLocaleString(i18nLang) + " " + + $.i18n('num-categories', categoriesCount) + ); + $('.category--count').text(totalEdits.toLocaleString(i18nLang)); + $('.category--percent-of-edit-count').text( + ((totalEdits / xtools.categoryedits.userEditCount).toLocaleString(i18nLang) * 100) + '%' + ); + $('.category--pages').text(totalPages.toLocaleString(i18nLang)); + }); - if ($('.contributions-container').length) { - loadCategoryEdits(); - } - }); + if ($('.contributions-container').length) { + loadCategoryEdits(); + } + }); }); /** @@ -55,15 +55,15 @@ $(function () { */ function loadCategoryEdits() { - // Load the contributions browser, or set up the listeners if it is already present. - var initFunc = $('.contributions-table').length ? 'setupContributionsNavListeners' : 'loadContributions'; - xtools.application[initFunc]( - function (params) { - return 'categoryedits-contributions/' + params.project + '/' + params.username + '/' + - params.categories + '/' + params.start + '/' + params.end; - }, - 'Category' - ); + // Load the contributions browser, or set up the listeners if it is already present. + var initFunc = $('.contributions-table').length ? 'setupContributionsNavListeners' : 'loadContributions'; + xtools.application[initFunc]( + function (params) { + return 'categoryedits-contributions/' + params.project + '/' + params.username + '/' + + params.categories + '/' + params.start + '/' + params.end; + }, + 'Category' + ); } /** @@ -73,53 +73,53 @@ function loadCategoryEdits() */ function setupCategoryInput(api, ns) { - // First destroy any existing Select2 inputs. - if (xtools.categoryedits.$select2Input.data('select2')) { - xtools.categoryedits.$select2Input.off('change'); - xtools.categoryedits.$select2Input.select2('val', null); - xtools.categoryedits.$select2Input.select2('data', null); - xtools.categoryedits.$select2Input.select2('destroy'); - } + // First destroy any existing Select2 inputs. + if (xtools.categoryedits.$select2Input.data('select2')) { + xtools.categoryedits.$select2Input.off('change'); + xtools.categoryedits.$select2Input.select2('val', null); + xtools.categoryedits.$select2Input.select2('data', null); + xtools.categoryedits.$select2Input.select2('destroy'); + } - var nsName = ns || xtools.categoryedits.$select2Input.data('ns'); + var nsName = ns || xtools.categoryedits.$select2Input.data('ns'); - var params = { - ajax: { - url: api || xtools.categoryedits.$select2Input.data('api'), - dataType: 'jsonp', - jsonpCallback: 'categorySuggestionCallback', - delay: 200, - data: function (search) { - return { - action: 'query', - list: 'prefixsearch', - format: 'json', - pssearch: search.term || '', - psnamespace: 14, - cirrusUseCompletionSuggester: 'yes' - }; - }, - processResults: function (data) { - var query = data ? data.query : {}, - results = []; + var params = { + ajax: { + url: api || xtools.categoryedits.$select2Input.data('api'), + dataType: 'jsonp', + jsonpCallback: 'categorySuggestionCallback', + delay: 200, + data: function (search) { + return { + action: 'query', + list: 'prefixsearch', + format: 'json', + pssearch: search.term || '', + psnamespace: 14, + cirrusUseCompletionSuggester: 'yes' + }; + }, + processResults: function (data) { + var query = data ? data.query : {}, + results = []; - if (query && query.prefixsearch.length) { - results = query.prefixsearch.map(function (elem) { - var title = elem.title.replace(new RegExp('^' + nsName + ':'), ''); - return { - id: title.replace(/ /g, '_'), - text: title - }; - }); - } + if (query && query.prefixsearch.length) { + results = query.prefixsearch.map(function (elem) { + var title = elem.title.replace(new RegExp('^' + nsName + ':'), ''); + return { + id: title.replace(/ /g, '_'), + text: title + }; + }); + } - return {results: results} - } - }, - placeholder: $.i18n('category-search'), - maximumSelectionLength: 10, - minimumInputLength: 1 - }; + return {results: results} + } + }, + placeholder: $.i18n('category-search'), + maximumSelectionLength: 10, + minimumInputLength: 1 + }; - xtools.categoryedits.$select2Input.select2(params); + xtools.categoryedits.$select2Input.select2(params); } diff --git a/assets/js/common/application.js b/assets/js/common/application.js index c2a26b786..c5f7bc84b 100644 --- a/assets/js/common/application.js +++ b/assets/js/common/application.js @@ -1,77 +1,77 @@ xtools = {}; xtools.application = {}; xtools.application.vars = { - sectionOffset: {}, + sectionOffset: {}, }; xtools.application.chartGridColor = 'rgba(0, 0, 0, 0.1)'; if (window.matchMedia("(prefers-color-scheme: dark)").matches) { - Chart.defaults.global.defaultFontColor = '#AAA'; - // Can't set a global default with our version of Chart.js, apparently, - // so each chart initialization must explicitly set the grid line color. - xtools.application.chartGridColor = '#333'; + Chart.defaults.global.defaultFontColor = '#AAA'; + // Can't set a global default with our version of Chart.js, apparently, + // so each chart initialization must explicitly set the grid line color. + xtools.application.chartGridColor = '#333'; } /** global: i18nLang */ /** global: i18nPaths */ $.i18n({ - locale: i18nLang + locale: i18nLang }).load(i18nPaths); $(function () { - // The $() around this code apparently isn't enough for Webpack, need another document-ready check. - $(document).ready(function () { - // TODO: move these listeners to a setup function and document how to use it. - $('.xt-hide').on('click', function () { - $(this).hide(); - $(this).siblings('.xt-show').show(); - - if ($(this).parents('.panel-heading').length) { - $(this).parents('.panel-heading').siblings('.panel-body').hide(); - } else { - $(this).parents('.xt-show-hide--parent').next('.xt-show-hide--target').hide(); - } - }); - $('.xt-show').on('click', function () { - $(this).hide(); - $(this).siblings('.xt-hide').show(); - - if ($(this).parents('.panel-heading').length) { - $(this).parents('.panel-heading').siblings('.panel-body').show(); - } else { - $(this).parents('.xt-show-hide--parent').next('.xt-show-hide--target').show(); - } - }); - - setupNavCollapsing(); - - xtools.application.setupColumnSorting(); - setupTOC(); - setupStickyHeader(); - setupProjectListener(); - setupAutocompletion(); - displayWaitingNoticeOnSubmission(); - setupLinkLoadingNotices(); - - // Allow to add focus to input elements with i.e. ?focus=username - if ('function' === typeof URL) { - const focusElement = new URL(window.location.href) - .searchParams - .get('focus'); - if (focusElement) { - $(`[name=${focusElement}]`).focus(); - } - } - }); - - // Re-init forms, workaround for issues with Safari and Firefox. - // See displayWaitingNoticeOnSubmission() for more. - window.onpageshow = function (e) { - if (e.persisted) { - displayWaitingNoticeOnSubmission(true); - setupLinkLoadingNotices(true); - } - }; + // The $() around this code apparently isn't enough for Webpack, need another document-ready check. + $(document).ready(function () { + // TODO: move these listeners to a setup function and document how to use it. + $('.xt-hide').on('click', function () { + $(this).hide(); + $(this).siblings('.xt-show').show(); + + if ($(this).parents('.panel-heading').length) { + $(this).parents('.panel-heading').siblings('.panel-body').hide(); + } else { + $(this).parents('.xt-show-hide--parent').next('.xt-show-hide--target').hide(); + } + }); + $('.xt-show').on('click', function () { + $(this).hide(); + $(this).siblings('.xt-hide').show(); + + if ($(this).parents('.panel-heading').length) { + $(this).parents('.panel-heading').siblings('.panel-body').show(); + } else { + $(this).parents('.xt-show-hide--parent').next('.xt-show-hide--target').show(); + } + }); + + setupNavCollapsing(); + + xtools.application.setupColumnSorting(); + setupTOC(); + setupStickyHeader(); + setupProjectListener(); + setupAutocompletion(); + displayWaitingNoticeOnSubmission(); + setupLinkLoadingNotices(); + + // Allow to add focus to input elements with i.e. ?focus=username + if ('function' === typeof URL) { + const focusElement = new URL(window.location.href) + .searchParams + .get('focus'); + if (focusElement) { + $(`[name = ${focusElement}]`).focus(); + } + } + }); + + // Re-init forms, workaround for issues with Safari and Firefox. + // See displayWaitingNoticeOnSubmission() for more. + window.onpageshow = function (e) { + if (e.persisted) { + displayWaitingNoticeOnSubmission(true); + setupLinkLoadingNotices(true); + } + }; }); /** @@ -126,47 +126,47 @@ $(function () { * item, and the third is the index of the item. */ xtools.application.setupToggleTable = function (dataSource, chartObj, valueKey, updateCallback) { - var toggleTableData; - - $('.toggle-table').on('click', '.toggle-table--toggle', function () { - if (!toggleTableData) { - // must be cloned - toggleTableData = Object.assign({}, dataSource); - } - - var index = $(this).data('index'), - key = $(this).data('key'); - - // must use .attr instead of .prop as sorting script will clone DOM elements - if ($(this).attr('data-disabled') === 'true') { - toggleTableData[key] = dataSource[key]; - if (chartObj) { - chartObj.data.datasets[0].data[index] = ( - parseInt(valueKey ? toggleTableData[key][valueKey] : toggleTableData[key], 10) - ); - } - $(this).attr('data-disabled', 'false'); - } else { - delete toggleTableData[key]; - if (chartObj) { - chartObj.data.datasets[0].data[index] = null; - } - $(this).attr('data-disabled', 'true'); - } - - // gray out row in table - $(this).parents('tr').toggleClass('excluded'); - - // change the hover icon from a 'x' to a '+' - $(this).find('.glyphicon').toggleClass('glyphicon-remove').toggleClass('glyphicon-plus'); - - // update stats - updateCallback(toggleTableData, key, index); - - if (chartObj) { - chartObj.update(); - } - }); + var toggleTableData; + + $('.toggle-table').on('click', '.toggle-table--toggle', function () { + if (!toggleTableData) { + // must be cloned + toggleTableData = Object.assign({}, dataSource); + } + + var index = $(this).data('index'), + key = $(this).data('key'); + + // must use .attr instead of .prop as sorting script will clone DOM elements + if ($(this).attr('data-disabled') === 'true') { + toggleTableData[key] = dataSource[key]; + if (chartObj) { + chartObj.data.datasets[0].data[index] = ( + parseInt(valueKey ? toggleTableData[key][valueKey] : toggleTableData[key], 10) + ); + } + $(this).attr('data-disabled', 'false'); + } else { + delete toggleTableData[key]; + if (chartObj) { + chartObj.data.datasets[0].data[index] = null; + } + $(this).attr('data-disabled', 'true'); + } + + // gray out row in table + $(this).parents('tr').toggleClass('excluded'); + + // change the hover icon from a 'x' to a '+' + $(this).find('.glyphicon').toggleClass('glyphicon-remove').toggleClass('glyphicon-plus'); + + // update stats + updateCallback(toggleTableData, key, index); + + if (chartObj) { + chartObj.update(); + } + }); }; /** @@ -175,30 +175,30 @@ xtools.application.setupToggleTable = function (dataSource, chartObj, valueKey, */ function setupNavCollapsing() { - var windowWidth = $(window).width(), - toolNavWidth = $('.tool-links').outerWidth(), - navRightWidth = $('.nav-buttons').outerWidth(); - - // Ignore if in mobile responsive view - if (windowWidth < 768) { - return; - } - - // Do this first so we account for the space the More menu takes up - if (toolNavWidth + navRightWidth > windowWidth) { - $('.tool-links--more').removeClass('hidden'); - } - - // Don't loop more than there are links in the nav. - // This more just a safeguard against an infinite loop should something go wrong. - var numLinks = $('.tool-links--entry').length; - while (numLinks > 0 && toolNavWidth + navRightWidth > windowWidth) { - // Remove the last tool link that is not the current tool being used - var $link = $('.tool-links--nav > .tool-links--entry:not(.active)').last().remove(); - $('.tool-links--more .dropdown-menu').append($link); - toolNavWidth = $('.tool-links').outerWidth(); - numLinks--; - } + var windowWidth = $(window).width(), + toolNavWidth = $('.tool-links').outerWidth(), + navRightWidth = $('.nav-buttons').outerWidth(); + + // Ignore if in mobile responsive view + if (windowWidth < 768) { + return; + } + + // Do this first so we account for the space the More menu takes up + if (toolNavWidth + navRightWidth > windowWidth) { + $('.tool-links--more').removeClass('hidden'); + } + + // Don't loop more than there are links in the nav. + // This more just a safeguard against an infinite loop should something go wrong. + var numLinks = $('.tool-links--entry').length; + while (numLinks > 0 && toolNavWidth + navRightWidth > windowWidth) { + // Remove the last tool link that is not the current tool being used + var $link = $('.tool-links--nav > .tool-links--entry:not(.active)').last().remove(); + $('.tool-links--more .dropdown-menu').append($link); + toolNavWidth = $('.tool-links').outerWidth(); + numLinks--; + } } /** @@ -222,53 +222,53 @@ function setupNavCollapsing() * floats, and strings, including date strings (e.g. "2016-01-01 12:59") */ xtools.application.setupColumnSorting = function () { - var sortDirection, sortColumn; - - $('.sort-link').on('click', function () { - sortDirection = sortColumn === $(this).data('column') ? -sortDirection : 1; - - $('.sort-link .glyphicon').removeClass('glyphicon-sort-by-alphabet-alt glyphicon-sort-by-alphabet').addClass('glyphicon-sort'); - var newSortClassName = sortDirection === 1 ? 'glyphicon-sort-by-alphabet-alt' : 'glyphicon-sort-by-alphabet'; - $(this).find('.glyphicon').addClass(newSortClassName).removeClass('glyphicon-sort'); - - sortColumn = $(this).data('column'); - var $table = $(this).parents('table'); - var $entries = $table.find('.sort-entry--' + sortColumn).parent(); - - if (!$entries.length) { - return; - } - - $entries.sort(function (a, b) { - var before = $(a).find('.sort-entry--' + sortColumn).data('value') || 0, - after = $(b).find('.sort-entry--' + sortColumn).data('value') || 0; - - // Cast numerical strings into floats for faster sorting. - if (!isNaN(before)) { - before = parseFloat(before) || 0; - } - if (!isNaN(after)) { - after = parseFloat(after) || 0; - } - - if (before < after) { - return sortDirection; - } else if (before > after) { - return -sortDirection; - } else { - return 0; - } - }); - - // Re-fill the rank column, if applicable. - if ($('.sort-entry--rank').length > 0) { - $.each($entries, function (index, entry) { - $(entry).find('.sort-entry--rank').text(index + 1); - }); - } - - $table.find('tbody').html($entries); - }); + var sortDirection, sortColumn; + + $('.sort-link').on('click', function () { + sortDirection = sortColumn === $(this).data('column') ? -sortDirection : 1; + + $('.sort-link .glyphicon').removeClass('glyphicon-sort-by-alphabet-alt glyphicon-sort-by-alphabet').addClass('glyphicon-sort'); + var newSortClassName = sortDirection === 1 ? 'glyphicon-sort-by-alphabet-alt' : 'glyphicon-sort-by-alphabet'; + $(this).find('.glyphicon').addClass(newSortClassName).removeClass('glyphicon-sort'); + + sortColumn = $(this).data('column'); + var $table = $(this).parents('table'); + var $entries = $table.find('.sort-entry--' + sortColumn).parent(); + + if (!$entries.length) { + return; + } + + $entries.sort(function (a, b) { + var before = $(a).find('.sort-entry--' + sortColumn).data('value') || 0, + after = $(b).find('.sort-entry--' + sortColumn).data('value') || 0; + + // Cast numerical strings into floats for faster sorting. + if (!isNaN(before)) { + before = parseFloat(before) || 0; + } + if (!isNaN(after)) { + after = parseFloat(after) || 0; + } + + if (before < after) { + return sortDirection; + } else if (before > after) { + return -sortDirection; + } else { + return 0; + } + }); + + // Re-fill the rank column, if applicable. + if ($('.sort-entry--rank').length > 0) { + $.each($entries, function (index, entry) { + $(entry).find('.sort-entry--rank').text(index + 1); + }); + } + + $table.find('tbody').html($entries); + }); }; /** @@ -295,81 +295,81 @@ xtools.application.setupColumnSorting = function () { */ function setupTOC() { - var $toc = $('.xt-toc'); - - if (!$toc || !$toc[0]) { - return; - } - - xtools.application.vars.tocHeight = $toc.height(); - - // listeners on the section links - var setupTocListeners = function () { - $('.xt-toc').find('a').off('click').on('click', function (e) { - document.activeElement.blur(); - var $newSection = $('#' + $(e.target).data('section')); - $(window).scrollTop($newSection.offset().top - xtools.application.vars.tocHeight); - - $(this).parents('.xt-toc').find('a').removeClass('bold'); - - createTocClone(); - xtools.application.vars.$tocClone.addClass('bold'); - }); - }; - xtools.application.setupTocListeners = setupTocListeners; - - // clone the TOC and add position:fixed - var createTocClone = function () { - if (xtools.application.vars.$tocClone) { - return; - } - xtools.application.vars.$tocClone = $toc.clone(); - xtools.application.vars.$tocClone.addClass('fixed'); - $toc.after(xtools.application.vars.$tocClone); - setupTocListeners(); - }; - - // build object containing offsets of each section - xtools.application.buildSectionOffsets = function () { - $.each($toc.find('a'), function (index, tocMember) { - var id = $(tocMember).data('section'); - xtools.application.vars.sectionOffset[id] = $('#' + id).offset().top; - }); - }; - - // rebuild section offsets when sections are shown/hidden - $('.xt-show, .xt-hide').on('click', xtools.application.buildSectionOffsets); - - xtools.application.buildSectionOffsets(); - setupTocListeners(); - - var tocOffsetTop = $toc.offset().top; - $(window).on('scroll.toc', function (e) { - var windowOffset = $(e.target).scrollTop(); - var inRange = windowOffset > tocOffsetTop; - - if (inRange) { - if (!xtools.application.vars.$tocClone) { - createTocClone(); - } - - // bolden the link for whichever section we're in - var $activeMember; - Object.keys(xtools.application.vars.sectionOffset).forEach(function (section) { - if (windowOffset > xtools.application.vars.sectionOffset[section] - xtools.application.vars.tocHeight - 1) { - $activeMember = xtools.application.vars.$tocClone.find('a[data-section="' + section + '"]'); - } - }); - xtools.application.vars.$tocClone.find('a').removeClass('bold'); - if ($activeMember) { - $activeMember.addClass('bold'); - } - } else if (!inRange && xtools.application.vars.$tocClone) { - // remove the clone once we're out of range - xtools.application.vars.$tocClone.remove(); - xtools.application.vars.$tocClone = null; - } - }); + var $toc = $('.xt-toc'); + + if (!$toc || !$toc[0]) { + return; + } + + xtools.application.vars.tocHeight = $toc.height(); + + // listeners on the section links + var setupTocListeners = function () { + $('.xt-toc').find('a').off('click').on('click', function (e) { + document.activeElement.blur(); + var $newSection = $('#' + $(e.target).data('section')); + $(window).scrollTop($newSection.offset().top - xtools.application.vars.tocHeight); + + $(this).parents('.xt-toc').find('a').removeClass('bold'); + + createTocClone(); + xtools.application.vars.$tocClone.addClass('bold'); + }); + }; + xtools.application.setupTocListeners = setupTocListeners; + + // clone the TOC and add position:fixed + var createTocClone = function () { + if (xtools.application.vars.$tocClone) { + return; + } + xtools.application.vars.$tocClone = $toc.clone(); + xtools.application.vars.$tocClone.addClass('fixed'); + $toc.after(xtools.application.vars.$tocClone); + setupTocListeners(); + }; + + // build object containing offsets of each section + xtools.application.buildSectionOffsets = function () { + $.each($toc.find('a'), function (index, tocMember) { + var id = $(tocMember).data('section'); + xtools.application.vars.sectionOffset[id] = $('#' + id).offset().top; + }); + }; + + // rebuild section offsets when sections are shown/hidden + $('.xt-show, .xt-hide').on('click', xtools.application.buildSectionOffsets); + + xtools.application.buildSectionOffsets(); + setupTocListeners(); + + var tocOffsetTop = $toc.offset().top; + $(window).on('scroll.toc', function (e) { + var windowOffset = $(e.target).scrollTop(); + var inRange = windowOffset > tocOffsetTop; + + if (inRange) { + if (!xtools.application.vars.$tocClone) { + createTocClone(); + } + + // bolden the link for whichever section we're in + var $activeMember; + Object.keys(xtools.application.vars.sectionOffset).forEach(function (section) { + if (windowOffset > xtools.application.vars.sectionOffset[section] - xtools.application.vars.tocHeight - 1) { + $activeMember = xtools.application.vars.$tocClone.find('a[data-section="' + section + '"]'); + } + }); + xtools.application.vars.$tocClone.find('a').removeClass('bold'); + if ($activeMember) { + $activeMember.addClass('bold'); + } + } else if (!inRange && xtools.application.vars.$tocClone) { + // remove the clone once we're out of range + xtools.application.vars.$tocClone.remove(); + xtools.application.vars.$tocClone = null; + } + }); } /** @@ -378,56 +378,56 @@ function setupTOC() */ function setupStickyHeader() { - var $header = $('.table-sticky-header'); - - if (!$header || !$header[0]) { - return; - } - - var $headerRow = $header.find('thead tr').eq(0), - $headerClone; - - // Make a clone of the header to maintain placement of the original header, - // making the original header the sticky one. This way event listeners on it - // (such as column sorting) will still work. - var cloneHeader = function () { - if ($headerClone) { - return; - } - - $headerClone = $headerRow.clone(); - $headerRow.addClass('sticky-heading'); - $headerRow.before($headerClone); - - // Explicitly set widths of each column, which are lost with position:absolute. - $headerRow.find('th').each(function (index) { - $(this).css('width', $headerClone.find('th').eq(index).outerWidth()); - }); - $headerRow.css('width', $headerClone.outerWidth() + 1); - }; - - var headerOffsetTop = $header.offset().top; - $(window).on('scroll.stickyHeader', function (e) { - var windowOffset = $(e.target).scrollTop(); - var inRange = windowOffset > headerOffsetTop; - - if (inRange && !$headerClone) { - cloneHeader(); - } else if (!inRange && $headerClone) { - // Remove the clone once we're out of range, - // and make the original un-sticky. - $headerRow.removeClass('sticky-heading'); - $headerClone.remove(); - $headerClone = null; - } else if ($headerClone) { - // The header is position:absolute so it will follow with X scrolling, - // but for Y we must go by the window scroll position. - $headerRow.css( - 'top', - $(window).scrollTop() - $header.offset().top - ); - } - }); + var $header = $('.table-sticky-header'); + + if (!$header || !$header[0]) { + return; + } + + var $headerRow = $header.find('thead tr').eq(0), + $headerClone; + + // Make a clone of the header to maintain placement of the original header, + // making the original header the sticky one. This way event listeners on it + // (such as column sorting) will still work. + var cloneHeader = function () { + if ($headerClone) { + return; + } + + $headerClone = $headerRow.clone(); + $headerRow.addClass('sticky-heading'); + $headerRow.before($headerClone); + + // Explicitly set widths of each column, which are lost with position:absolute. + $headerRow.find('th').each(function (index) { + $(this).css('width', $headerClone.find('th').eq(index).outerWidth()); + }); + $headerRow.css('width', $headerClone.outerWidth() + 1); + }; + + var headerOffsetTop = $header.offset().top; + $(window).on('scroll.stickyHeader', function (e) { + var windowOffset = $(e.target).scrollTop(); + var inRange = windowOffset > headerOffsetTop; + + if (inRange && !$headerClone) { + cloneHeader(); + } else if (!inRange && $headerClone) { + // Remove the clone once we're out of range, + // and make the original un-sticky. + $headerRow.removeClass('sticky-heading'); + $headerClone.remove(); + $headerClone = null; + } else if ($headerClone) { + // The header is position:absolute so it will follow with X scrolling, + // but for Y we must go by the window scroll position. + $headerRow.css( + 'top', + $(window).scrollTop() - $header.offset().top + ); + } + }); } /** @@ -435,40 +435,40 @@ function setupStickyHeader() */ function setupProjectListener() { - var $projectInput = $('#project_input'); - - // Stop here if there is no project field - if (!$projectInput) { - return; - } - - // If applicable, setup namespace selector with real time updates when changing projects. - // This will also set `apiPath` so that autocompletion will query the right wiki. - if ($projectInput.length && $('#namespace_select').length) { - setupNamespaceSelector(); - // Otherwise, if there's a user or page input field, we still need to update `apiPath` - // for the user input autocompletion when the project is changed. - } else if ($('#user_input')[0] || $('#page_input')[0]) { - // keep track of last valid project - xtools.application.vars.lastProject = $projectInput.val(); - - $projectInput.on('change', function () { - var newProject = this.value; - - /** global: xtBaseUrl */ - $.get(xtBaseUrl + 'api/project/normalize/' + newProject).done(function (data) { - // Keep track of project API path for use in page title autocompletion - xtools.application.vars.apiPath = data.api; - xtools.application.vars.lastProject = newProject; - setupAutocompletion(); - - // Other pages may listen for this custom event. - $projectInput.trigger('xtools.projectLoaded', data); - }).fail( - revertToValidProject.bind(this, newProject) - ); - }); - } + var $projectInput = $('#project_input'); + + // Stop here if there is no project field + if (!$projectInput) { + return; + } + + // If applicable, setup namespace selector with real time updates when changing projects. + // This will also set `apiPath` so that autocompletion will query the right wiki. + if ($projectInput.length && $('#namespace_select').length) { + setupNamespaceSelector(); + // Otherwise, if there's a user or page input field, we still need to update `apiPath` + // for the user input autocompletion when the project is changed. + } else if ($('#user_input')[0] || $('#page_input')[0]) { + // keep track of last valid project + xtools.application.vars.lastProject = $projectInput.val(); + + $projectInput.on('change', function () { + var newProject = this.value; + + /** global: xtBaseUrl */ + $.get(xtBaseUrl + 'api/project/normalize/' + newProject).done(function (data) { + // Keep track of project API path for use in page title autocompletion + xtools.application.vars.apiPath = data.api; + xtools.application.vars.lastProject = newProject; + setupAutocompletion(); + + // Other pages may listen for this custom event. + $projectInput.trigger('xtools.projectLoaded', data); + }).fail( + revertToValidProject.bind(this, newProject) + ); + }); + } } /** @@ -477,51 +477,51 @@ function setupProjectListener() */ function setupNamespaceSelector() { - // keep track of last valid project - xtools.application.vars.lastProject = $('#project_input').val(); - - $('#project_input').off('change').on('change', function () { - // Disable the namespace selector and show a spinner while the data loads. - $('#namespace_select').prop('disabled', true); - - var newProject = this.value; - - /** global: xtBaseUrl */ - $.get(xtBaseUrl + 'api/project/namespaces/' + newProject).done(function (data) { - // Clone the 'all' option (even if there isn't one), - // and replace the current option list with this. - var $allOption = $('#namespace_select option[value="all"]').eq(0).clone(); - $("#namespace_select").html($allOption); - - // Keep track of project API path for use in page title autocompletion. - xtools.application.vars.apiPath = data.api; - - // Add all of the new namespace options. - for (var ns in data.namespaces) { - if (!data.namespaces.hasOwnProperty(ns)) { - continue; // Skip keys from the prototype. - } - - var nsName = parseInt(ns, 10) === 0 ? $.i18n('mainspace') : data.namespaces[ns]; - $('#namespace_select').append( - "" - ); - } - // Default to mainspace being selected. - $("#namespace_select").val(0); - xtools.application.vars.lastProject = newProject; - - // Re-init autocompletion - setupAutocompletion(); - }).fail(revertToValidProject.bind(this, newProject)).always(function () { - $('#namespace_select').prop('disabled', false); - }); - }); - - // If they change the namespace, update autocompletion, - // which will ensure only pages in the selected namespace - // show up in the autocompletion - $('#namespace_select').on('change', setupAutocompletion); + // keep track of last valid project + xtools.application.vars.lastProject = $('#project_input').val(); + + $('#project_input').off('change').on('change', function () { + // Disable the namespace selector and show a spinner while the data loads. + $('#namespace_select').prop('disabled', true); + + var newProject = this.value; + + /** global: xtBaseUrl */ + $.get(xtBaseUrl + 'api/project/namespaces/' + newProject).done(function (data) { + // Clone the 'all' option (even if there isn't one), + // and replace the current option list with this. + var $allOption = $('#namespace_select option[value="all"]').eq(0).clone(); + $("#namespace_select").html($allOption); + + // Keep track of project API path for use in page title autocompletion. + xtools.application.vars.apiPath = data.api; + + // Add all of the new namespace options. + for (var ns in data.namespaces) { + if (!data.namespaces.hasOwnProperty(ns)) { + continue; // Skip keys from the prototype. + } + + var nsName = parseInt(ns, 10) === 0 ? $.i18n('mainspace') : data.namespaces[ns]; + $('#namespace_select').append( + "" + ); + } + // Default to mainspace being selected. + $("#namespace_select").val(0); + xtools.application.vars.lastProject = newProject; + + // Re-init autocompletion + setupAutocompletion(); + }).fail(revertToValidProject.bind(this, newProject)).always(function () { + $('#namespace_select').prop('disabled', false); + }); + }); + + // If they change the namespace, update autocompletion, + // which will ensure only pages in the selected namespace + // show up in the autocompletion + $('#namespace_select').on('change', setupAutocompletion); } /** @@ -531,15 +531,15 @@ function setupNamespaceSelector() */ function revertToValidProject(newProject) { - $('#project_input').val(xtools.application.vars.lastProject); - $('.site-notice').append( - "" - ); + $('#project_input').val(xtools.application.vars.lastProject); + $('.site-notice').append( + "" + ); } /** @@ -547,100 +547,100 @@ function revertToValidProject(newProject) */ function setupAutocompletion() { - var $pageInput = $('#page_input'), - $userInput = $('#user_input'), - $namespaceInput = $("#namespace_select"); - - // Make sure typeahead-compatible fields are present - if (!$pageInput[0] && !$userInput[0] && !$('#project_input')[0]) { - return; - } - - // Destroy any existing instances - if ($pageInput.data('typeahead')) { - $pageInput.data('typeahead').destroy(); - } - if ($userInput.data('typeahead')) { - $userInput.data('typeahead').destroy(); - } - - // set initial value for the API url, which is put as a data attribute in forms.html.twig - if (!xtools.application.vars.apiPath) { - xtools.application.vars.apiPath = $('#page_input').data('api') || $('#user_input').data('api'); - } - - // Defaults for typeahead options. preDispatch and preProcess will be - // set accordingly for each typeahead instance - var typeaheadOpts = { - url: xtools.application.vars.apiPath, - timeout: 200, - triggerLength: 1, - method: 'get', - preDispatch: null, - preProcess: null - }; - - if ($pageInput[0]) { - $pageInput.typeahead({ - ajax: Object.assign(typeaheadOpts, { - preDispatch: function (query) { - // If there is a namespace selector, make sure we search - // only within that namespace - if ($namespaceInput[0] && $namespaceInput.val() !== '0') { - var nsName = $namespaceInput.find('option:selected').text().trim(); - query = nsName + ':' + query; - } - return { - action: 'query', - list: 'prefixsearch', - format: 'json', - pssearch: query - }; - }, - preProcess: function (data) { - var nsName = ''; - // Strip out namespace name if applicable - if ($namespaceInput[0] && $namespaceInput.val() !== '0') { - nsName = $namespaceInput.find('option:selected').text().trim(); - } - return data.query.prefixsearch.map(function (elem) { - return elem.title.replace(new RegExp('^' + nsName + ':'), ''); - }); - } - }) - }); - } - - if ($userInput[0]) { - $userInput.typeahead({ - ajax: Object.assign(typeaheadOpts, { - preDispatch: function (query) { - return { - action: 'query', - list: 'prefixsearch', - format: 'json', - pssearch: 'User:' + query - }; - }, - preProcess: function (data) { - var results = data.query.prefixsearch.map(function (elem) { - return elem.title.split('/')[0].substr(elem.title.indexOf(':') + 1); - }); - - return results.filter(function (value, index, array) { - return array.indexOf(value) === index; - }); - } - }) - }); - } - let allowAmpersand = (e) => { - if (e.key == "&") { - $(e.target).blur().focus(); - } - }; - $pageInput.on("keydown", allowAmpersand); - $userInput.on("keydown", allowAmpersand); + var $pageInput = $('#page_input'), + $userInput = $('#user_input'), + $namespaceInput = $("#namespace_select"); + + // Make sure typeahead-compatible fields are present + if (!$pageInput[0] && !$userInput[0] && !$('#project_input')[0]) { + return; + } + + // Destroy any existing instances + if ($pageInput.data('typeahead')) { + $pageInput.data('typeahead').destroy(); + } + if ($userInput.data('typeahead')) { + $userInput.data('typeahead').destroy(); + } + + // set initial value for the API url, which is put as a data attribute in forms.html.twig + if (!xtools.application.vars.apiPath) { + xtools.application.vars.apiPath = $('#page_input').data('api') || $('#user_input').data('api'); + } + + // Defaults for typeahead options. preDispatch and preProcess will be + // set accordingly for each typeahead instance + var typeaheadOpts = { + url: xtools.application.vars.apiPath, + timeout: 200, + triggerLength: 1, + method: 'get', + preDispatch: null, + preProcess: null + }; + + if ($pageInput[0]) { + $pageInput.typeahead({ + ajax: Object.assign(typeaheadOpts, { + preDispatch: function (query) { + // If there is a namespace selector, make sure we search + // only within that namespace + if ($namespaceInput[0] && $namespaceInput.val() !== '0') { + var nsName = $namespaceInput.find('option:selected').text().trim(); + query = nsName + ':' + query; + } + return { + action: 'query', + list: 'prefixsearch', + format: 'json', + pssearch: query + }; + }, + preProcess: function (data) { + var nsName = ''; + // Strip out namespace name if applicable + if ($namespaceInput[0] && $namespaceInput.val() !== '0') { + nsName = $namespaceInput.find('option:selected').text().trim(); + } + return data.query.prefixsearch.map(function (elem) { + return elem.title.replace(new RegExp('^' + nsName + ':'), ''); + }); + } + }) + }); + } + + if ($userInput[0]) { + $userInput.typeahead({ + ajax: Object.assign(typeaheadOpts, { + preDispatch: function (query) { + return { + action: 'query', + list: 'prefixsearch', + format: 'json', + pssearch: 'User:' + query + }; + }, + preProcess: function (data) { + var results = data.query.prefixsearch.map(function (elem) { + return elem.title.split('/')[0].substr(elem.title.indexOf(':') + 1); + }); + + return results.filter(function (value, index, array) { + return array.indexOf(value) === index; + }); + } + }) + }); + } + let allowAmpersand = (e) => { + if (e.key == "&") { + $(e.target).blur().focus(); + } + }; + $pageInput.on("keydown", allowAmpersand); + $userInput.on("keydown", allowAmpersand); } @@ -656,13 +656,13 @@ let loadingTimerId; */ function createTimerInterval() { - var startTime = Date.now(); - return setInterval(function () { - var elapsedSeconds = Math.round((Date.now() - startTime) / 1000); - var minutes = Math.floor(elapsedSeconds / 60); - var seconds = ('00' + (elapsedSeconds - (minutes * 60))).slice(-2); - $('#submit_timer').text(minutes + ":" + seconds); - }, 1000); + var startTime = Date.now(); + return setInterval(function () { + var elapsedSeconds = Math.round((Date.now() - startTime) / 1000); + var minutes = Math.floor(elapsedSeconds / 60); + var seconds = ('00' + (elapsedSeconds - (minutes * 60))).slice(-2); + $('#submit_timer').text(minutes + ":" + seconds); + }, 1000); } /** @@ -674,31 +674,31 @@ function createTimerInterval() */ function displayWaitingNoticeOnSubmission(undo) { - if (undo) { - // Re-enable form - $('.form-control').prop('readonly', false); - $('.form-submit').prop('disabled', false); - $('.form-submit').text($.i18n('submit')).prop('disabled', false); - if (loadingTimerId) { - clearInterval(loadingTimerId); - loadingTimerId = null; - } - } else { - $('#content form').on('submit', function () { - // Remove focus from any active element - document.activeElement.blur(); - - // Disable the form so they can't hit Enter to re-submit - $('.form-control').prop('readonly', true); - - // Change the submit button text. - $('.form-submit').prop('disabled', true) - .html($.i18n('loading') + " "); - - // Add the counter. - loadingTimerId = createTimerInterval(); - }); - } + if (undo) { + // Re-enable form + $('.form-control').prop('readonly', false); + $('.form-submit').prop('disabled', false); + $('.form-submit').text($.i18n('submit')).prop('disabled', false); + if (loadingTimerId) { + clearInterval(loadingTimerId); + loadingTimerId = null; + } + } else { + $('#content form').on('submit', function () { + // Remove focus from any active element + document.activeElement.blur(); + + // Disable the form so they can't hit Enter to re-submit + $('.form-control').prop('readonly', true); + + // Change the submit button text. + $('.form-submit').prop('disabled', true) + .html($.i18n('loading') + " "); + + // Add the counter. + loadingTimerId = createTimerInterval(); + }); + } } /* @@ -706,13 +706,13 @@ function displayWaitingNoticeOnSubmission(undo) */ function clearLinkTimer() { - // clear the timer proper - clearInterval(loadingTimerId); - loaingTimerId = null; - // change the link's label back - let old = $("#submit_timer").parent()[0]; - $(old).html(old.initialtext); - $(old).removeClass("link-loading"); + // clear the timer proper + clearInterval(loadingTimerId); + loaingTimerId = null; + // change the link's label back + let old = $("#submit_timer").parent()[0]; + $(old).html(old.initialtext); + $(old).removeClass("link-loading"); } /** @@ -724,45 +724,45 @@ function clearLinkTimer() */ function setupLinkLoadingNotices(undo) { - if (undo) { - clearLinkTimer(); - } else { - // Get the list of links: - $("a").filter( - (index, el) => - el.className == "" && // only plain links, not buttons - el.href.startsWith(document.location.origin) && // to XTools - new URL(el.href).pathname.replaceAll(/[^\/]/g, "").length > 1 && // that include parameters (just going to a search form is not costy) - el.target != "_blank" && // that doesn't open in a new tab - el.href.split("#")[0] != document.location.href // and that isn't a section link to here. - ).on("click", (ev) => { - // And then add a listener - let el = $(ev.target); - el.prop("initialtext", el.html()); - el.html($.i18n('loading') + ' '); - el.addClass("link-loading"); - if (loadingTimerId) { - clearLinkTimer(); - } - loadingTimerId = createTimerInterval(); - }); - } + if (undo) { + clearLinkTimer(); + } else { + // Get the list of links: + $("a").filter( + (index, el) => + el.className == "" && // only plain links, not buttons + el.href.startsWith(document.location.origin) && // to XTools + new URL(el.href).pathname.replaceAll(/[^\/]/g, "").length > 1 && // that include parameters (just going to a search form is not costy) + el.target != "_blank" && // that doesn't open in a new tab + el.href.split("#")[0] != document.location.href // and that isn't a section link to here. + ).on("click", (ev) => { + // And then add a listener + let el = $(ev.target); + el.prop("initialtext", el.html()); + el.html($.i18n('loading') + ' '); + el.addClass("link-loading"); + if (loadingTimerId) { + clearLinkTimer(); + } + loadingTimerId = createTimerInterval(); + }); + } } /** * Handles the multi-select inputs on some index pages. */ xtools.application.setupMultiSelectListeners = function () { - var $inputs = $('.multi-select--body:not(.hidden) .multi-select--option'); - $inputs.on('change', function () { - // If all sections are selected, select the 'All' checkbox, and vice versa. - $('.multi-select--all').prop( - 'checked', - $('.multi-select--body:not(.hidden) .multi-select--option:checked').length === $inputs.length - ); - }); - // Uncheck/check all when the 'All' checkbox is modified. - $('.multi-select--all').on('click', function () { - $inputs.prop('checked', $(this).prop('checked')); - }); + var $inputs = $('.multi-select--body:not(.hidden) .multi-select--option'); + $inputs.on('change', function () { + // If all sections are selected, select the 'All' checkbox, and vice versa. + $('.multi-select--all').prop( + 'checked', + $('.multi-select--body:not(.hidden) .multi-select--option:checked').length === $inputs.length + ); + }); + // Uncheck/check all when the 'All' checkbox is modified. + $('.multi-select--all').on('click', function () { + $inputs.prop('checked', $(this).prop('checked')); + }); }; diff --git a/assets/js/common/contributions-lists.js b/assets/js/common/contributions-lists.js index b68b10454..9768997a5 100644 --- a/assets/js/common/contributions-lists.js +++ b/assets/js/common/contributions-lists.js @@ -1,8 +1,8 @@ Object.assign(xtools.application.vars, { - initialOffset: '', - offset: '', - prevOffsets: [], - initialLoad: false, + initialOffset: '', + offset: '', + prevOffsets: [], + initialLoad: false, }); /** @@ -11,14 +11,14 @@ Object.assign(xtools.application.vars, { */ function setInitialOffset() { - if (!xtools.application.vars.offset) { - // The initialOffset should be what was given via the .contributions-container. - // This is used to determine if we're back on the first page or not. - xtools.application.vars.initialOffset = $('.contributions-container').data('offset'); - // The offset will from here represent which page we're on, and is compared with - // intitialEditOffset to know if we're on the first page. - xtools.application.vars.offset = xtools.application.vars.initialOffset; - } + if (!xtools.application.vars.offset) { + // The initialOffset should be what was given via the .contributions-container. + // This is used to determine if we're back on the first page or not. + xtools.application.vars.initialOffset = $('.contributions-container').data('offset'); + // The offset will from here represent which page we're on, and is compared with + // intitialEditOffset to know if we're on the first page. + xtools.application.vars.offset = xtools.application.vars.initialOffset; + } } /** @@ -29,126 +29,126 @@ function setInitialOffset() * @param {String} apiTitle The name of the API (could be i18n key), used in error reporting. */ xtools.application.loadContributions = function (endpointFunc, apiTitle) { - setInitialOffset(); - - var $contributionsContainer = $('.contributions-container'), - $contributionsLoading = $('.contributions-loading'), - params = $contributionsContainer.data(), - endpoint = endpointFunc(params), - limit = parseInt(params.limit, 10) || 50, - urlParams = new URLSearchParams(window.location.search), - newUrl = xtBaseUrl + endpoint + '/' + xtools.application.vars.offset, - oldToolPath = location.pathname.split('/')[1], - newToolPath = newUrl.split('/')[1]; - - // Gray out contributions list. - $contributionsContainer.addClass('contributions-container--loading') - - // Show the 'Loading...' text. CSS will hide the "Previous" / "Next" links to prevent jumping. - $contributionsLoading.show(); - - urlParams.set('limit', limit.toString()); - urlParams.append('htmlonly', 'yes'); - - /** global: xtBaseUrl */ - $.ajax({ - // Make sure to include any URL parameters, such as tool=Huggle (for AutoEdits). - url: newUrl + '?' + urlParams.toString(), - timeout: 60000 - }).always(function () { - $contributionsContainer.removeClass('contributions-container--loading'); - $contributionsLoading.hide(); - }).done(function (data) { - $contributionsContainer.html(data).show(); - xtools.application.setupContributionsNavListeners(endpointFunc, apiTitle); - - // Set an initial offset if we don't have one already so that we know when we're on the first page of contribs. - if (!xtools.application.vars.initialOffset) { - xtools.application.vars.initialOffset = $('.contribs-row-date').first().data('value'); - - // In this case we know we are loading contribs for this first time via AJAX (such as at /autoedits), - // hence we'll set the initialLoad flag to true, so we know not to unnecessarily pollute the URL - // after we get back the data (see below). - xtools.application.vars.initialLoad = true; - } - - if (oldToolPath !== newToolPath) { - // Happens when a subrequest is made to a different controller action. - // For instance, /autoedits embeds /nonautoedits-contributions. - var regexp = new RegExp(`^/${newToolPath}/(.*)/`); - newUrl = newUrl.replace(regexp, `/${oldToolPath}/$1/`); - } - - // Do not run on the initial page load. This is to retain a clean URL: - // (i.e. /autoedits/enwiki/Example, rather than /autoedits/enwiki/Example/0///2015-07-02T15:50:48?limit=50) - // When user paginates (requests made NOT on the initial page load), we do want to update the URL. - if (!xtools.application.vars.initialLoad) { - // Update URL so we can have permalinks. - // 'htmlonly' should be removed as it's an internal param. - urlParams.delete('htmlonly'); - window.history.replaceState( - null, - document.title, - newUrl + '?' + urlParams.toString() - ); - - // Also scroll to the top of the contribs container. - $contributionsContainer.parents('.panel')[0].scrollIntoView(); - } else { - // So that pagination through the contribs will update the URL and scroll into view. - xtools.application.vars.initialLoad = false; - } - - if (xtools.application.vars.offset < xtools.application.vars.initialOffset) { - $('.contributions--prev').show(); - } else { - $('.contributions--prev').hide(); - } - if ($('.contributions-table tbody tr').length < limit) { - $('.next-edits').hide(); - } - }).fail(function (_xhr, _status, message) { - $contributionsLoading.hide(); - $contributionsContainer.html( - $.i18n('api-error', $.i18n(apiTitle) + ' API: ' + message + '') - ).show(); - }); + setInitialOffset(); + + var $contributionsContainer = $('.contributions-container'), + $contributionsLoading = $('.contributions-loading'), + params = $contributionsContainer.data(), + endpoint = endpointFunc(params), + limit = parseInt(params.limit, 10) || 50, + urlParams = new URLSearchParams(window.location.search), + newUrl = xtBaseUrl + endpoint + '/' + xtools.application.vars.offset, + oldToolPath = location.pathname.split('/')[1], + newToolPath = newUrl.split('/')[1]; + + // Gray out contributions list. + $contributionsContainer.addClass('contributions-container--loading') + + // Show the 'Loading...' text. CSS will hide the "Previous" / "Next" links to prevent jumping. + $contributionsLoading.show(); + + urlParams.set('limit', limit.toString()); + urlParams.append('htmlonly', 'yes'); + + /** global: xtBaseUrl */ + $.ajax({ + // Make sure to include any URL parameters, such as tool=Huggle (for AutoEdits). + url: newUrl + '?' + urlParams.toString(), + timeout: 60000 + }).always(function () { + $contributionsContainer.removeClass('contributions-container--loading'); + $contributionsLoading.hide(); + }).done(function (data) { + $contributionsContainer.html(data).show(); + xtools.application.setupContributionsNavListeners(endpointFunc, apiTitle); + + // Set an initial offset if we don't have one already so that we know when we're on the first page of contribs. + if (!xtools.application.vars.initialOffset) { + xtools.application.vars.initialOffset = $('.contribs-row-date').first().data('value'); + + // In this case we know we are loading contribs for this first time via AJAX (such as at /autoedits), + // hence we'll set the initialLoad flag to true, so we know not to unnecessarily pollute the URL + // after we get back the data (see below). + xtools.application.vars.initialLoad = true; + } + + if (oldToolPath !== newToolPath) { + // Happens when a subrequest is made to a different controller action. + // For instance, /autoedits embeds /nonautoedits-contributions. + var regexp = new RegExp(` ^ / ${newToolPath} / (.*) / `); + newUrl = newUrl.replace(regexp, ` / ${oldToolPath} / $1 / `); + } + + // Do not run on the initial page load. This is to retain a clean URL: + // (i.e. /autoedits/enwiki/Example, rather than /autoedits/enwiki/Example/0///2015-07-02T15:50:48?limit=50) + // When user paginates (requests made NOT on the initial page load), we do want to update the URL. + if (!xtools.application.vars.initialLoad) { + // Update URL so we can have permalinks. + // 'htmlonly' should be removed as it's an internal param. + urlParams.delete('htmlonly'); + window.history.replaceState( + null, + document.title, + newUrl + '?' + urlParams.toString() + ); + + // Also scroll to the top of the contribs container. + $contributionsContainer.parents('.panel')[0].scrollIntoView(); + } else { + // So that pagination through the contribs will update the URL and scroll into view. + xtools.application.vars.initialLoad = false; + } + + if (xtools.application.vars.offset < xtools.application.vars.initialOffset) { + $('.contributions--prev').show(); + } else { + $('.contributions--prev').hide(); + } + if ($('.contributions-table tbody tr').length < limit) { + $('.next-edits').hide(); + } + }).fail(function (_xhr, _status, message) { + $contributionsLoading.hide(); + $contributionsContainer.html( + $.i18n('api-error', $.i18n(apiTitle) + ' API: ' + message + '') + ).show(); + }); }; /** * Set up listeners for navigating contribution lists. */ xtools.application.setupContributionsNavListeners = function (endpointFunc, apiTitle) { - setInitialOffset(); - - // Previous arrow. - $('.contributions--prev').off('click').one('click', function (e) { - e.preventDefault(); - xtools.application.vars.offset = xtools.application.vars.prevOffsets.pop() - || xtools.application.vars.initialOffset; - xtools.application.loadContributions(endpointFunc, apiTitle) - }); - - // Next arrow. - $('.contributions--next').off('click').one('click', function (e) { - e.preventDefault(); - if (xtools.application.vars.offset) { - xtools.application.vars.prevOffsets.push(xtools.application.vars.offset); - } - xtools.application.vars.offset = $('.contribs-row-date').last().data('value'); - xtools.application.loadContributions(endpointFunc, apiTitle); - }); - - // The 'Limit:' dropdown. - $('#contributions_limit').on('change', function (e) { - var limit = parseInt(e.target.value, 10); - $('.contributions-container').data('limit', limit); - let capitalize = (str) => str[0].toUpperCase() + str.slice(1); - $('.contributions--prev-text').text( - capitalize($.i18n('pager-newer-n', limit)) - ); - $('.contributions--next-text').text( - capitalize($.i18n('pager-older-n', limit)) - ); - }); + setInitialOffset(); + + // Previous arrow. + $('.contributions--prev').off('click').one('click', function (e) { + e.preventDefault(); + xtools.application.vars.offset = xtools.application.vars.prevOffsets.pop() + || xtools.application.vars.initialOffset; + xtools.application.loadContributions(endpointFunc, apiTitle) + }); + + // Next arrow. + $('.contributions--next').off('click').one('click', function (e) { + e.preventDefault(); + if (xtools.application.vars.offset) { + xtools.application.vars.prevOffsets.push(xtools.application.vars.offset); + } + xtools.application.vars.offset = $('.contribs-row-date').last().data('value'); + xtools.application.loadContributions(endpointFunc, apiTitle); + }); + + // The 'Limit:' dropdown. + $('#contributions_limit').on('change', function (e) { + var limit = parseInt(e.target.value, 10); + $('.contributions-container').data('limit', limit); + let capitalize = (str) => str[0].toUpperCase() + str.slice(1); + $('.contributions--prev-text').text( + capitalize($.i18n('pager-newer-n', limit)) + ); + $('.contributions--next-text').text( + capitalize($.i18n('pager-older-n', limit)) + ); + }); }; diff --git a/assets/js/editcounter.js b/assets/js/editcounter.js index 272375f64..136429579 100644 --- a/assets/js/editcounter.js +++ b/assets/js/editcounter.js @@ -20,37 +20,37 @@ xtools.editcounter.chartLabels = {}; xtools.editcounter.maxDigits = {}; $(function () { - // Don't do anything if this isn't a Edit Counter page. - if ($('body.editcounter').length === 0) { - return; - } - - xtools.application.setupMultiSelectListeners(); - - // Set up charts. - $('.chart-wrapper').each(function () { - var chartType = $(this).data('chart-type'); - if (chartType === undefined) { - return false; - } - var data = $(this).data('chart-data'); - var labels = $(this).data('chart-labels'); - var $ctx = $('canvas', $(this)); - - /** global: Chart */ - new Chart($ctx, { - type: chartType, - data: { - labels: labels, - datasets: [ { data: data } ] - } - }); - - return undefined; - }); - - // Set up namespace toggle chart. - xtools.application.setupToggleTable(window.namespaceTotals, window.namespaceChart, null, toggleNamespace); + // Don't do anything if this isn't a Edit Counter page. + if ($('body.editcounter').length === 0) { + return; + } + + xtools.application.setupMultiSelectListeners(); + + // Set up charts. + $('.chart-wrapper').each(function () { + var chartType = $(this).data('chart-type'); + if (chartType === undefined) { + return false; + } + var data = $(this).data('chart-data'); + var labels = $(this).data('chart-labels'); + var $ctx = $('canvas', $(this)); + + /** global: Chart */ + new Chart($ctx, { + type: chartType, + data: { + labels: labels, + datasets: [ { data: data } ] + } + }); + + return undefined; + }); + + // Set up namespace toggle chart. + xtools.application.setupToggleTable(window.namespaceTotals, window.namespaceChart, null, toggleNamespace); }); /** @@ -61,69 +61,69 @@ $(function () { */ function toggleNamespace(newData, key) { - var total = 0, counts = []; - Object.keys(newData).forEach(function (namespace) { - var count = parseInt(newData[namespace], 10); - counts.push(count); - total += count; - }); - var namespaceCount = Object.keys(newData).length; - - /** global: i18nLang */ - $('.namespaces--namespaces').text( - namespaceCount.toLocaleString(i18nLang) + ' ' + - $.i18n('num-namespaces', namespaceCount) - ); - $('.namespaces--count').text(total.toLocaleString(i18nLang)); - - // Now that we have the total, loop through once more time to update percentages. - counts.forEach(function (count) { - // Calculate percentage, rounded to tenths. - var percentage = getPercentage(count, total); - - // Update text with new value and percentage. - $('.namespaces-table .sort-entry--count[data-value='+count+']').text( - count.toLocaleString(i18nLang) + ' (' + percentage + ')' - ); - }); - - // Loop through month and year charts, toggling the dataset for the newly excluded namespace. - ['year', 'month'].forEach(function (id) { - var chartObj = window[id + 'countsChart'], - nsName = window.namespaces[key] || $.i18n('mainspace'); - - // Year and month sections can be selectively hidden. - if (!chartObj) { - return; - } - - // Figure out the index of the namespace we're toggling within this chart object. - var datasetIndex = 0; - chartObj.data.datasets.forEach(function (dataset, i) { - if (dataset.label === nsName) { - datasetIndex = i; - } - }); - - // Fetch the metadata and toggle the hidden property. - var meta = chartObj.getDatasetMeta(datasetIndex); - meta.hidden = meta.hidden === null ? !chartObj.data.datasets[datasetIndex].hidden : null; - - // Add this namespace to the list of excluded namespaces. - if (meta.hidden) { - xtools.editcounter.excludedNamespaces.push(nsName); - } else { - xtools.editcounter.excludedNamespaces = xtools.editcounter.excludedNamespaces.filter(function (namespace) { - return namespace !== nsName; - }); - } - - // Update y-axis labels with the new totals. - window[id + 'countsChart'].config.data.labels = getYAxisLabels(id, chartObj.data.datasets); - - // Refresh chart. - chartObj.update(); - }); + var total = 0, counts = []; + Object.keys(newData).forEach(function (namespace) { + var count = parseInt(newData[namespace], 10); + counts.push(count); + total += count; + }); + var namespaceCount = Object.keys(newData).length; + + /** global: i18nLang */ + $('.namespaces--namespaces').text( + namespaceCount.toLocaleString(i18nLang) + ' ' + + $.i18n('num-namespaces', namespaceCount) + ); + $('.namespaces--count').text(total.toLocaleString(i18nLang)); + + // Now that we have the total, loop through once more time to update percentages. + counts.forEach(function (count) { + // Calculate percentage, rounded to tenths. + var percentage = getPercentage(count, total); + + // Update text with new value and percentage. + $('.namespaces-table .sort-entry--count[data-value=' + count + ']').text( + count.toLocaleString(i18nLang) + ' (' + percentage + ')' + ); + }); + + // Loop through month and year charts, toggling the dataset for the newly excluded namespace. + ['year', 'month'].forEach(function (id) { + var chartObj = window[id + 'countsChart'], + nsName = window.namespaces[key] || $.i18n('mainspace'); + + // Year and month sections can be selectively hidden. + if (!chartObj) { + return; + } + + // Figure out the index of the namespace we're toggling within this chart object. + var datasetIndex = 0; + chartObj.data.datasets.forEach(function (dataset, i) { + if (dataset.label === nsName) { + datasetIndex = i; + } + }); + + // Fetch the metadata and toggle the hidden property. + var meta = chartObj.getDatasetMeta(datasetIndex); + meta.hidden = meta.hidden === null ? !chartObj.data.datasets[datasetIndex].hidden : null; + + // Add this namespace to the list of excluded namespaces. + if (meta.hidden) { + xtools.editcounter.excludedNamespaces.push(nsName); + } else { + xtools.editcounter.excludedNamespaces = xtools.editcounter.excludedNamespaces.filter(function (namespace) { + return namespace !== nsName; + }); + } + + // Update y-axis labels with the new totals. + window[id + 'countsChart'].config.data.labels = getYAxisLabels(id, chartObj.data.datasets); + + // Refresh chart. + chartObj.update(); + }); } /** @@ -135,20 +135,20 @@ function toggleNamespace(newData, key) */ function getYAxisLabels(id, datasets) { - var labelsAndTotals = getMonthYearTotals(id, datasets); - - // Format labels with totals next to them. This is a bit hacky, but it works! We use tabs (\t) to make the - // labels/totals for each namespace line up perfectly. The caveat is that we can't localize the numbers because - // the commas are not monospaced :( - return Object.keys(labelsAndTotals).map(function (year) { - var digitCount = labelsAndTotals[year].toString().length; - var numTabs = (xtools.editcounter.maxDigits[id] - digitCount) * 2; - - // +5 for a bit of extra spacing. - /** global: i18nLang */ - return year + Array(numTabs + 5).join("\t") + - labelsAndTotals[year].toLocaleString(i18nLang, {useGrouping: false}); - }); + var labelsAndTotals = getMonthYearTotals(id, datasets); + + // Format labels with totals next to them. This is a bit hacky, but it works! We use tabs (\t) to make the + // labels/totals for each namespace line up perfectly. The caveat is that we can't localize the numbers because + // the commas are not monospaced :( + return Object.keys(labelsAndTotals).map(function (year) { + var digitCount = labelsAndTotals[year].toString().length; + var numTabs = (xtools.editcounter.maxDigits[id] - digitCount) * 2; + + // +5 for a bit of extra spacing. + /** global: i18nLang */ + return year + Array(numTabs + 5).join("\t") + + labelsAndTotals[year].toLocaleString(i18nLang, {useGrouping: false}); + }); } /** @@ -159,21 +159,21 @@ function getYAxisLabels(id, datasets) */ function getMonthYearTotals(id, datasets) { - var labelsAndTotals = {}; - datasets.forEach(function (namespace) { - if (xtools.editcounter.excludedNamespaces.indexOf(namespace.label) !== -1) { - return; - } - - namespace.data.forEach(function (count, index) { - if (!labelsAndTotals[xtools.editcounter.chartLabels[id][index]]) { - labelsAndTotals[xtools.editcounter.chartLabels[id][index]] = 0; - } - labelsAndTotals[xtools.editcounter.chartLabels[id][index]] += count; - }); - }); - - return labelsAndTotals; + var labelsAndTotals = {}; + datasets.forEach(function (namespace) { + if (xtools.editcounter.excludedNamespaces.indexOf(namespace.label) !== -1) { + return; + } + + namespace.data.forEach(function (count, index) { + if (!labelsAndTotals[xtools.editcounter.chartLabels[id][index]]) { + labelsAndTotals[xtools.editcounter.chartLabels[id][index]] = 0; + } + labelsAndTotals[xtools.editcounter.chartLabels[id][index]] += count; + }); + }); + + return labelsAndTotals; } /** @@ -184,8 +184,8 @@ function getMonthYearTotals(id, datasets) */ function getPercentage(numerator, denominator) { - /** global: i18nLang */ - return (numerator / denominator).toLocaleString(i18nLang, {style: 'percent'}); + /** global: i18nLang */ + return (numerator / denominator).toLocaleString(i18nLang, {style: 'percent'}); } /** @@ -198,112 +198,112 @@ function getPercentage(numerator, denominator) * @param {Boolean} showLegend Whether to show the legend above the chart. */ xtools.editcounter.setupMonthYearChart = function (id, datasets, labels, maxTotal) { - /** @type {Array} Labels for each namespace. */ - var namespaces = datasets.map(function (dataset) { - return dataset.label; - }); - xtools.editcounter.maxDigits[id] = maxTotal.toString().length; - xtools.editcounter.chartLabels[id] = labels; - - /** global: i18nRTL */ - /** global: i18nLang */ - // on 2.7 I believe we have no other way to update a chart's config - // than to tear it out and put it again. - let createchart = (type="linear") => - window[id + 'countsChart'] = new Chart($('#' + id + 'counts-canvas'), { - type: 'horizontalBar', - data: { - labels: getYAxisLabels(id, datasets), - datasets: datasets, - }, - options: { - tooltips: { - mode: 'nearest', - intersect: true, - callbacks: { - label: function (tooltip) { - var labelsAndTotals = getMonthYearTotals(id, datasets), - totals = Object.keys(labelsAndTotals).map(function (label) { - return labelsAndTotals[label]; - }), - total = totals[tooltip.index], - percentage = getPercentage(tooltip.xLabel, total); - - return tooltip.xLabel.toLocaleString(i18nLang) + ' ' + - '(' + percentage + ')'; - }, - title: function (tooltip) { - var yLabel = tooltip[0].yLabel.replace(/\t.*/, ''); - return yLabel + ' - ' + namespaces[tooltip[0].datasetIndex]; - } - } - }, - responsive: true, - maintainAspectRatio: false, - scales: { - xAxes: [{ - type: type, - stacked: true, - ticks: { - // Note: this has no effect in log scale. - beginAtZero: true, - // with linear, next line is redundant - // with log, it prevents a log(0) infinite loop - // fixed two minor chartjs versions later (2.7.2) - min: (type == "logarithmic" ? 1 : 0), - // Sadly, logarithmic breaks if reverse - reverse: (type == "logarithmic" ? false : i18nRTL), - callback: function (value) { - if (Math.floor(value) === value) { - return value.toLocaleString(i18nLang); - } - } - }, - gridLines: { - color: xtools.application.chartGridColor - }, - afterBuildTicks: function (axis) { - // For logarithmic scale, default ticks are too close and overlap. - if (type == "logarithmic") { - let newticks = []; - axis.ticks.forEach((x,i) => { - // So we enforce 1.5* distance. - if (i == 0 || newticks[newticks.length-1]*1.5 < x || x*1.5 < newticks[newticks.length-1]) { - newticks.push(x) - } - }); - axis.ticks = newticks; - } - }, - }], - yAxes: [{ - stacked: true, - position: i18nRTL ? 'right' : 'left', - gridLines: { - color: xtools.application.chartGridColor - } - }] - }, - legend: { - display: false, - } - } - }); - // Initialise it, linear by default - createchart(); - // Add checkbox listeners - $(function () { - $('.use-log-scale') - .prop('checked', false) - .on('click', function () { - let uselog = $(this).prop('checked'); - // Set the other checkbox too - $('.use-log-scale').prop('checked', uselog); - // As I said above, no other way AFAIK - window[id + 'countsChart'].destroy(); - createchart(uselog?"logarithmic":"linear"); - }); - }); + /** @type {Array} Labels for each namespace. */ + var namespaces = datasets.map(function (dataset) { + return dataset.label; + }); + xtools.editcounter.maxDigits[id] = maxTotal.toString().length; + xtools.editcounter.chartLabels[id] = labels; + + /** global: i18nRTL */ + /** global: i18nLang */ + // on 2.7 I believe we have no other way to update a chart's config + // than to tear it out and put it again. + let createchart = (type = "linear") => + window[id + 'countsChart'] = new Chart($('#' + id + 'counts-canvas'), { + type: 'horizontalBar', + data: { + labels: getYAxisLabels(id, datasets), + datasets: datasets, + }, + options: { + tooltips: { + mode: 'nearest', + intersect: true, + callbacks: { + label: function (tooltip) { + var labelsAndTotals = getMonthYearTotals(id, datasets), + totals = Object.keys(labelsAndTotals).map(function (label) { + return labelsAndTotals[label]; + }), + total = totals[tooltip.index], + percentage = getPercentage(tooltip.xLabel, total); + + return tooltip.xLabel.toLocaleString(i18nLang) + ' ' + + '(' + percentage + ')'; + }, + title: function (tooltip) { + var yLabel = tooltip[0].yLabel.replace(/\t.*/, ''); + return yLabel + ' - ' + namespaces[tooltip[0].datasetIndex]; + } + } + }, + responsive: true, + maintainAspectRatio: false, + scales: { + xAxes: [{ + type: type, + stacked: true, + ticks: { + // Note: this has no effect in log scale. + beginAtZero: true, + // with linear, next line is redundant + // with log, it prevents a log(0) infinite loop + // fixed two minor chartjs versions later (2.7.2) + min: (type == "logarithmic" ? 1 : 0), + // Sadly, logarithmic breaks if reverse + reverse: (type == "logarithmic" ? false : i18nRTL), + callback: function (value) { + if (Math.floor(value) === value) { + return value.toLocaleString(i18nLang); + } + } + }, + gridLines: { + color: xtools.application.chartGridColor + }, + afterBuildTicks: function (axis) { + // For logarithmic scale, default ticks are too close and overlap. + if (type == "logarithmic") { + let newticks = []; + axis.ticks.forEach((x,i) => { + // So we enforce 1.5* distance. + if (i == 0 || newticks[newticks.length - 1] * 1.5 < x || x * 1.5 < newticks[newticks.length - 1]) { + newticks.push(x) + } + }); + axis.ticks = newticks; + } + }, + }], + yAxes: [{ + stacked: true, + position: i18nRTL ? 'right' : 'left', + gridLines: { + color: xtools.application.chartGridColor + } + }] + }, + legend: { + display: false, + } + } + }); + // Initialise it, linear by default + createchart(); + // Add checkbox listeners + $(function () { + $('.use-log-scale') + .prop('checked', false) + .on('click', function () { + let uselog = $(this).prop('checked'); + // Set the other checkbox too + $('.use-log-scale').prop('checked', uselog); + // As I said above, no other way AFAIK + window[id + 'countsChart'].destroy(); + createchart(uselog ? "logarithmic" : "linear"); + }); + }); }; @@ -315,100 +315,101 @@ xtools.editcounter.setupMonthYearChart = function (id, datasets, labels, maxTota * @param {Array} barLabels i18n'd bar labels for additions, removals and same-size, in that order. */ xtools.editcounter.setupSizeHistogram = function (data, colors, barLabels) { - let bars = 12; // Counting the >10240 interval! - // First sanitize input, to get array. - let total = Object.keys(data).length; - data.length = total; - data = Array.from(data) - // Then make datasets - let datasetPos = {}; - datasetPos.backgroundColor = colors[0]; - datasetPos.label = barLabels[0]; - let datasetNeg = {}; - datasetNeg.backgroundColor = colors[1]; - datasetNeg.label = barLabels[1]; - let datasetZero = {}; - datasetZero.backgroundColor = colors[2]; - datasetZero.label = barLabels[2]; - // Setup counts. - datasetPos.data = new Array(bars).fill(0); - datasetNeg.data = new Array(bars).fill(0); - datasetZero.data = new Array(bars).fill(0); - data.forEach((x) => { - if (x == 0) { - datasetZero.data[0] += 1; - } else { - // That's the slice index - let index = Math.ceil( - Math.min( - bars-1, - Math.max( - 0, - Math.log( - Math.abs(x)/10 - ) - / - Math.log(2) - ) - ) - ); - ( x < 0 ? datasetNeg : datasetPos ).data[index] += ( x < 0 ? -1 : 1); - } - }); - // The labels for intervals - let bounds = [0].concat(Array.from(new Array(bars-1), (_,i) => 10*2**i)); - let labels = Array.from(new Array(bars-1), (_,i) => (new Intl.NumberFormat(i18nLang)).formatRange(bounds[i], bounds[i+1])); - labels.push(">"+bounds[bars-1].toLocaleString(i18nLang)); - - window['sizeHistogramChart'] = new Chart($("#sizechart-canvas"), { - type: 'bar', - data: { - labels: labels, - datasets: [ - // The order matters; zero must appear first to be below pos - datasetNeg, - datasetZero, - datasetPos, - ], - }, - options: { - tooltips: { - mode: 'nearest', - intersect: true, - callbacks: { - label: function (tooltip) { - // the Math.abs' serve to show the internally negative removal counts as positive - percentage = getPercentage(Math.abs(tooltip.yLabel), total); - - return Math.abs(tooltip.yLabel).toLocaleString(i18nLang) + ' ' + - '(' + percentage + ')'; - }, - } - }, - responsive: true, - maintainAspectRatio: false, - legend: { - position: "top", - }, - scales: { - yAxes: [{ - stacked: true, - gridLines: { - color: xtools.application.chartGridColor - }, - ticks: { - callback: (n) => Math.abs(n).toLocaleString(i18nLang), - }, - }], - xAxes: [{ - stacked: true, - gridLines: { - color: xtools.application.chartGridColor - } - }], - }, - } - }); + let bars = 12; // Counting the >10240 interval! + // First sanitize input, to get array. + let total = Object.keys(data).length; + data.length = total; + data = Array.from(data) + // Then make datasets + let datasetPos = {}; + datasetPos.backgroundColor = colors[0]; + datasetPos.label = barLabels[0]; + let datasetNeg = {}; + datasetNeg.backgroundColor = colors[1]; + datasetNeg.label = barLabels[1]; + let datasetZero = {}; + datasetZero.backgroundColor = colors[2]; + datasetZero.label = barLabels[2]; + // Setup counts. + datasetPos.data = new Array(bars).fill(0); + datasetNeg.data = new Array(bars).fill(0); + datasetZero.data = new Array(bars).fill(0); + data.forEach((x) => { + if (x === 0) { + datasetZero.data[0] += 1; + } else { + // That's the slice index + let index = Math.ceil( + Math.min( + bars - 1, + Math.max( + 0, + Math.log( + Math.abs(x) / 10 + ) + / + Math.log(2) + ) + ) + ); + ( x < 0 ? datasetNeg : datasetPos ).data[index] += ( x < 0 ? -1 : 1); + } + }); + // The labels for intervals + // phpcs:ignore Squiz.WhiteSpace.OperatorSpacing.NoSpaceAfter, Squiz.WhiteSpace.OperatorSpacing.NoSpaceBefore + let bounds = [0].concat(Array.from(new Array(bars - 1), (_,i) => 10 * 2 ** i)); + let labels = Array.from(new Array(bars - 1), (_,i) => (new Intl.NumberFormat(i18nLang)).formatRange(bounds[i], bounds[i + 1])); + labels.push(">" + bounds[bars - 1].toLocaleString(i18nLang)); + + window['sizeHistogramChart'] = new Chart($("#sizechart-canvas"), { + type: 'bar', + data: { + labels: labels, + datasets: [ + // The order matters; zero must appear first to be below pos + datasetNeg, + datasetZero, + datasetPos, + ], + }, + options: { + tooltips: { + mode: 'nearest', + intersect: true, + callbacks: { + label: function (tooltip) { + // the Math.abs' serve to show the internally negative removal counts as positive + percentage = getPercentage(Math.abs(tooltip.yLabel), total); + + return Math.abs(tooltip.yLabel).toLocaleString(i18nLang) + ' ' + + '(' + percentage + ')'; + }, + } + }, + responsive: true, + maintainAspectRatio: false, + legend: { + position: "top", + }, + scales: { + yAxes: [{ + stacked: true, + gridLines: { + color: xtools.application.chartGridColor + }, + ticks: { + callback: (n) => Math.abs(n).toLocaleString(i18nLang), + }, + }], + xAxes: [{ + stacked: true, + gridLines: { + color: xtools.application.chartGridColor + } + }], + }, + } + }); }; /** @@ -417,164 +418,164 @@ xtools.editcounter.setupSizeHistogram = function (data, colors, barLabels) { * @param {Object} days */ xtools.editcounter.setupTimecard = function (timeCardDatasets, days) { - var useLocalTimezone = false, - timezoneOffset = new Date().getTimezoneOffset() / 60; - timeCardDatasets = timeCardDatasets.map(function (day) { - day.backgroundColor = new Array(day.data.length).fill(day.backgroundColor); - return day; - }); - window.chart = new Chart($("#timecard-bubble-chart"), { - type: 'bubble', - data: { - datasets: timeCardDatasets - }, - options: { - responsive: true, - // maintainAspectRatio: false, - legend: { - display: false - }, - layout: { - padding: { - right: 0 - } - }, - elements: { - point: { - radius: function (context) { - var index = context.dataIndex; - var data = context.dataset.data[index]; - // Max height a bubble can have. -20 to account for bottom labels, /9 because there are a bit less than 9 such sections, and /2 to get a radius not diameter - var maxRadius = ((context.chart.height - 20) / 9 / 2); - return (data.scale / 20) * maxRadius; - }, - hitRadius: 8 - } - }, - scales: { - yAxes: [{ - ticks: { - min: 0, - max: 8, - stepSize: 1, - padding: 25, - callback: function (value, index) { - return days[index]; - } - }, - position: i18nRTL ? 'right' : 'left', - gridLines: { - color: xtools.application.chartGridColor - } - }, { - ticks: { - min: 0, - max: 8, - stepSize: 1, - padding: 25, - callback: function (value, index) { - if (index === 0 || index > 7) { - return ''; - } - let dataset = (window.chart ? window.chart.data.datasets : timeCardDatasets); - let hours = dataset.map((day) => day.data) - .flat() - .filter((datum) => datum.y == 8-index); - return (hours.reduce(function (a, b) { - return a + parseInt(b.value, 10); - }, 0)).toLocaleString(i18nLang); - } - }, - position: i18nRTL ? 'left' : 'right' - }], - xAxes: [{ - ticks: { - beginAtZero: true, - min: 0, - max: 24, - stepSize: 1, - reverse: i18nRTL, - padding: 0, - callback: function (value, a, b, c) { - // Skip the 24:00, it's only there to give room for the fractional timezones - if (value === 24) { - return ""; - } - let res = []; - // Add hour totals if wider than 1000px (else we get overlap) - if ($("#timecard-bubble-chart").attr("width") >= 1000) { - let dataset = (window.chart ? window.chart.data.datasets : timeCardDatasets); - let hours = dataset.map((day) => day.data) - .flat() - .filter((datum) => datum.x == value); - res.push((hours.reduce(function (a, b) { - return a + parseInt(b.value, 10); - }, 0)).toLocaleString(i18nLang)); - } - if (value % 2 === 0) { - res.push(value + ":00"); - } - return res; - } - }, - gridLines: { - color: xtools.application.chartGridColor - }, - position: "bottom", - }] - }, - tooltips: { - displayColors: false, - callbacks: { - title: function (items) { - return days[7 - items[0].yLabel + 1] + ' ' + parseInt(items[0].xLabel) + ':' + String(60*(items[0].xLabel%1)).padStart(2, '0'); - }, - label: function (item) { - var numEdits = [timeCardDatasets[item.datasetIndex].data[item.index].value]; - return`${numEdits.toLocaleString(i18nLang)} ${$.i18n('num-edits', [numEdits])}`; - } - } - } - } - }); - - $(function () { - $('.use-local-time') - .prop('checked', false) - .on('click', function () { - var offset = $(this).is(':checked') ? timezoneOffset : -timezoneOffset; - var color_list = new Array(7); - chart.data.datasets.forEach((day) => color_list[day.data[0].day_of_week-1] = day.backgroundColor[0]); - chart.data.datasets = chart.data.datasets.map(function (day) { - var background_colors = []; - day.data = day.data.map(function (datum) { - var newHour = (parseFloat(datum.hour) - offset); - var newDay = parseInt(datum.day_of_week, 10); - if (newHour < 0) { - newHour = 24 + newHour; - newDay = newDay - 1; - if (newDay < 1) { - newDay = 7 + newDay; - } - } else if (newHour >= 24) { - newHour = newHour - 24; - newDay = newDay + 1; - if (newDay > 7) { - newDay = newDay - 7; - } - } - datum.hour = newHour.toString(); - datum.x = newHour.toString(); - datum.day_of_week = newDay.toString(); - datum.y = (8-newDay).toString(); - background_colors.push(color_list[newDay - 1]); - return datum; - }); - day.backgroundColor = background_colors; - return day; - }); - useLocalTimezone = $(this).is(':checked'); - chart.update(); - }); - }); + var useLocalTimezone = false, + timezoneOffset = new Date().getTimezoneOffset() / 60; + timeCardDatasets = timeCardDatasets.map(function (day) { + day.backgroundColor = new Array(day.data.length).fill(day.backgroundColor); + return day; + }); + window.chart = new Chart($("#timecard-bubble-chart"), { + type: 'bubble', + data: { + datasets: timeCardDatasets + }, + options: { + responsive: true, + // maintainAspectRatio: false, + legend: { + display: false + }, + layout: { + padding: { + right: 0 + } + }, + elements: { + point: { + radius: function (context) { + var index = context.dataIndex; + var data = context.dataset.data[index]; + // Max height a bubble can have. -20 to account for bottom labels, /9 because there are a bit less than 9 such sections, and /2 to get a radius not diameter + var maxRadius = ((context.chart.height - 20) / 9 / 2); + return (data.scale / 20) * maxRadius; + }, + hitRadius: 8 + } + }, + scales: { + yAxes: [{ + ticks: { + min: 0, + max: 8, + stepSize: 1, + padding: 25, + callback: function (value, index) { + return days[index]; + } + }, + position: i18nRTL ? 'right' : 'left', + gridLines: { + color: xtools.application.chartGridColor + } + }, { + ticks: { + min: 0, + max: 8, + stepSize: 1, + padding: 25, + callback: function (value, index) { + if (index === 0 || index > 7) { + return ''; + } + let dataset = (window.chart ? window.chart.data.datasets : timeCardDatasets); + let hours = dataset.map((day) => day.data) + .flat() + .filter((datum) => datum.y == 8 - index); + return (hours.reduce(function (a, b) { + return a + parseInt(b.value, 10); + }, 0)).toLocaleString(i18nLang); + } + }, + position: i18nRTL ? 'left' : 'right' + }], + xAxes: [{ + ticks: { + beginAtZero: true, + min: 0, + max: 24, + stepSize: 1, + reverse: i18nRTL, + padding: 0, + callback: function (value, a, b, c) { + // Skip the 24:00, it's only there to give room for the fractional timezones + if (value === 24) { + return ""; + } + let res = []; + // Add hour totals if wider than 1000px (else we get overlap) + if ($("#timecard-bubble-chart").attr("width") >= 1000) { + let dataset = (window.chart ? window.chart.data.datasets : timeCardDatasets); + let hours = dataset.map((day) => day.data) + .flat() + .filter((datum) => datum.x == value); + res.push((hours.reduce(function (a, b) { + return a + parseInt(b.value, 10); + }, 0)).toLocaleString(i18nLang)); + } + if (value % 2 === 0) { + res.push(value + ":00"); + } + return res; + } + }, + gridLines: { + color: xtools.application.chartGridColor + }, + position: "bottom", + }] + }, + tooltips: { + displayColors: false, + callbacks: { + title: function (items) { + return days[7 - items[0].yLabel + 1] + ' ' + parseInt(items[0].xLabel) + ':' + String(60 * (items[0].xLabel % 1)).padStart(2, '0'); + }, + label: function (item) { + var numEdits = [timeCardDatasets[item.datasetIndex].data[item.index].value]; + return`${numEdits.toLocaleString(i18nLang)} ${$.i18n('num-edits', [numEdits])}`; + } + } + } + } + }); + + $(function () { + $('.use-local-time') + .prop('checked', false) + .on('click', function () { + var offset = $(this).is(':checked') ? timezoneOffset : -timezoneOffset; + var color_list = new Array(7); + chart.data.datasets.forEach((day) => color_list[day.data[0].day_of_week - 1] = day.backgroundColor[0]); + chart.data.datasets = chart.data.datasets.map(function (day) { + var background_colors = []; + day.data = day.data.map(function (datum) { + var newHour = (parseFloat(datum.hour) - offset); + var newDay = parseInt(datum.day_of_week, 10); + if (newHour < 0) { + newHour = 24 + newHour; + newDay = newDay - 1; + if (newDay < 1) { + newDay = 7 + newDay; + } + } else if (newHour >= 24) { + newHour = newHour - 24; + newDay = newDay + 1; + if (newDay > 7) { + newDay = newDay - 7; + } + } + datum.hour = newHour.toString(); + datum.x = newHour.toString(); + datum.day_of_week = newDay.toString(); + datum.y = (8 - newDay).toString(); + background_colors.push(color_list[newDay - 1]); + return datum; + }); + day.backgroundColor = background_colors; + return day; + }); + useLocalTimezone = $(this).is(':checked'); + chart.update(); + }); + }); } diff --git a/assets/js/globalcontribs.js b/assets/js/globalcontribs.js index 919e08e59..a5864371c 100644 --- a/assets/js/globalcontribs.js +++ b/assets/js/globalcontribs.js @@ -1,12 +1,12 @@ xtools.globalcontribs = {}; $(function () { - // Don't do anything if this isn't a Global Contribs page. - if ($('body.globalcontribs').length === 0) { - return; - } + // Don't do anything if this isn't a Global Contribs page. + if ($('body.globalcontribs').length === 0) { + return; + } - xtools.application.setupContributionsNavListeners(function (params) { - return `globalcontribs/${params.username}/${params.namespace}/${params.start}/${params.end}`; - }, 'globalcontribs'); + xtools.application.setupContributionsNavListeners(function (params) { + return `globalcontribs / ${params.username} / ${params.namespace} / ${params.start} / ${params.end}`; + }, 'globalcontribs'); }); diff --git a/assets/js/pageinfo.js b/assets/js/pageinfo.js index 22dd26c38..9211fa87c 100644 --- a/assets/js/pageinfo.js +++ b/assets/js/pageinfo.js @@ -1,45 +1,45 @@ xtools.pageinfo = {}; $(function () { - if (!$('body.pageinfo').length) { - return; - } + if (!$('body.pageinfo').length) { + return; + } - var setupToggleTable = function () { - xtools.application.setupToggleTable( - window.textshares, - window.textsharesChart, - 'percentage', - $.noop - ); - }; + var setupToggleTable = function () { + xtools.application.setupToggleTable( + window.textshares, + window.textsharesChart, + 'percentage', + $.noop + ); + }; - var $textsharesContainer = $('.textshares-container'); + var $textsharesContainer = $('.textshares-container'); - if ($textsharesContainer[0]) { - /** global: xtBaseUrl */ - var url = xtBaseUrl + 'authorship/' - + $textsharesContainer.data('project') + '/' - + $textsharesContainer.data('page') + '/' - + (xtools.pageinfo.endDate ? xtools.pageinfo.endDate + '/' : ''); - // Remove extraneous forward slash that would cause a 301 redirect, and request over HTTP instead of HTTPS. - url = `${url.replace(/\/$/, '')}?htmlonly=yes`; + if ($textsharesContainer[0]) { + /** global: xtBaseUrl */ + var url = xtBaseUrl + 'authorship/' + + $textsharesContainer.data('project') + '/' + + $textsharesContainer.data('page') + '/' + + (xtools.pageinfo.endDate ? xtools.pageinfo.endDate + '/' : ''); + // Remove extraneous forward slash that would cause a 301 redirect, and request over HTTP instead of HTTPS. + url = `${url.replace(/\/$/, '')} ? htmlonly = yes`; - $.ajax({ - url: url, - timeout: 30000 - }).done(function (data) { - $textsharesContainer.replaceWith(data); - xtools.application.buildSectionOffsets(); - xtools.application.setupTocListeners(); - xtools.application.setupColumnSorting(); - setupToggleTable(); - }).fail(function (_xhr, _status, message) { - $textsharesContainer.replaceWith( - $.i18n('api-error', 'Authorship API: ' + message + '') - ); - }); - } else if ($('.textshares-table').length) { - setupToggleTable(); - } + $.ajax({ + url: url, + timeout: 30000 + }).done(function (data) { + $textsharesContainer.replaceWith(data); + xtools.application.buildSectionOffsets(); + xtools.application.setupTocListeners(); + xtools.application.setupColumnSorting(); + setupToggleTable(); + }).fail(function (_xhr, _status, message) { + $textsharesContainer.replaceWith( + $.i18n('api-error', 'Authorship API: ' + message + '') + ); + }); + } else if ($('.textshares-table').length) { + setupToggleTable(); + } }); diff --git a/assets/js/pages.js b/assets/js/pages.js index dc9091230..84cb69c86 100644 --- a/assets/js/pages.js +++ b/assets/js/pages.js @@ -1,72 +1,72 @@ xtools.pages = {}; $(function () { - // Don't execute this code if we're not on the Pages tool - // FIXME: find a way to automate this somehow... - if (!$('body.pages').length) { - return; - } + // Don't execute this code if we're not on the Pages tool + // FIXME: find a way to automate this somehow... + if (!$('body.pages').length) { + return; + } - var deletionSummaries = {}; + var deletionSummaries = {}; - xtools.application.setupToggleTable(window.countsByNamespace, window.pieChart, 'count', function (newData) { - var totals = { - count: 0, - deleted: 0, - redirects: 0, - }; - Object.keys(newData).forEach(function (ns) { - totals.count += newData[ns].count; - totals.deleted += newData[ns].deleted; - totals.redirects += newData[ns].redirects; - }); - $('.namespaces--namespaces').text( - Object.keys(newData).length.toLocaleString() + " " + - $.i18n( - 'num-namespaces', - Object.keys(newData).length, - ) - ); - $('.namespaces--pages').text(totals.count.toLocaleString()); - $('.namespaces--deleted').text( - totals.deleted.toLocaleString() + " (" + - ((totals.deleted / totals.count) * 100).toFixed(1) + "%)" - ); - $('.namespaces--redirects').text( - totals.redirects.toLocaleString() + " (" + - ((totals.redirects / totals.count) * 100).toFixed(1) + "%)" - ); - }); + xtools.application.setupToggleTable(window.countsByNamespace, window.pieChart, 'count', function (newData) { + var totals = { + count: 0, + deleted: 0, + redirects: 0, + }; + Object.keys(newData).forEach(function (ns) { + totals.count += newData[ns].count; + totals.deleted += newData[ns].deleted; + totals.redirects += newData[ns].redirects; + }); + $('.namespaces--namespaces').text( + Object.keys(newData).length.toLocaleString() + " " + + $.i18n( + 'num-namespaces', + Object.keys(newData).length, + ) + ); + $('.namespaces--pages').text(totals.count.toLocaleString()); + $('.namespaces--deleted').text( + totals.deleted.toLocaleString() + " (" + + ((totals.deleted / totals.count) * 100).toFixed(1) + "%)" + ); + $('.namespaces--redirects').text( + totals.redirects.toLocaleString() + " (" + + ((totals.redirects / totals.count) * 100).toFixed(1) + "%)" + ); + }); - $('.deleted-page').on('mouseenter', function (e) { - var pageTitle = $(this).data('page-title'), - nsId = $(this).data('namespace'), - startTime = $(this).data('datetime').toString(), - username = $(this).data('username'); + $('.deleted-page').on('mouseenter', function (e) { + var pageTitle = $(this).data('page-title'), + nsId = $(this).data('namespace'), + startTime = $(this).data('datetime').toString(), + username = $(this).data('username'); - var showSummary = function (summary) { - $(e.target).find('.tooltip-body').html(summary); - }; + var showSummary = function (summary) { + $(e.target).find('.tooltip-body').html(summary); + }; - if (deletionSummaries[nsId + '/' + pageTitle] !== undefined) { - return showSummary(deletionSummaries[nsId + '/' + pageTitle]); - } + if (deletionSummaries[nsId + '/' + pageTitle] !== undefined) { + return showSummary(deletionSummaries[nsId + '/' + pageTitle]); + } - var showError = function () { - showSummary( - "" + $.i18n('api-error', 'Deletion Summary API') + "" - ); - }; + var showError = function () { + showSummary( + "" + $.i18n('api-error', 'Deletion Summary API') + "" + ); + }; - $.ajax({ - url: xtBaseUrl + 'pages/deletion_summary/' + wikiDomain + '/' + username + '/' + nsId + '/' + - pageTitle + '/' + startTime - }).done(function (resp) { - if (null === resp.summary) { - return showError(); - } - showSummary(resp.summary); - deletionSummaries[nsId + '/' + pageTitle] = resp.summary; - }).fail(showError); - }); + $.ajax({ + url: xtBaseUrl + 'pages/deletion_summary/' + wikiDomain + '/' + username + '/' + nsId + '/' + + pageTitle + '/' + startTime + }).done(function (resp) { + if (null === resp.summary) { + return showError(); + } + showSummary(resp.summary); + deletionSummaries[nsId + '/' + pageTitle] = resp.summary; + }).fail(showError); + }); }); diff --git a/assets/js/topedits.js b/assets/js/topedits.js index abdb68eb5..73236d3fc 100644 --- a/assets/js/topedits.js +++ b/assets/js/topedits.js @@ -1,14 +1,14 @@ xtools.topedits = {}; $(function () { - // Don't execute this code if we're not on the TopEdits tool. - // FIXME: find a way to automate this somehow... - if (!$('body.topedits').length) { - return; - } + // Don't execute this code if we're not on the TopEdits tool. + // FIXME: find a way to automate this somehow... + if (!$('body.topedits').length) { + return; + } - // Disable the page input if they select the 'All' namespace option - $('#namespace_select').on('change', function () { - $('#page_input').prop('disabled', $(this).val() === 'all'); - }); + // Disable the page input if they select the 'All' namespace option + $('#namespace_select').on('change', function () { + $('#page_input').prop('disabled', $(this).val() === 'all'); + }); }); diff --git a/composer.json b/composer.json index d345a55d8..be7ea5c7e 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,6 @@ "nelmio/cors-bundle": "^2.3", "phpdocumentor/reflection-docblock": "^5.3", "phpstan/phpdoc-parser": "^1.16", - "slevomat/coding-standard": "^8.0", "symfony/asset": "~6.4", "symfony/cache": "~6.4", "symfony/config": "~6.4", @@ -65,11 +64,11 @@ "wikimedia/ip-utils": "^5.0" }, "require-dev": { - "symfony/phpunit-bridge": "~6.4", - "squizlabs/php_codesniffer": "^3.3.0", - "mediawiki/minus-x": "^1.0.0", "dms/phpunit-arraysubset-asserts": "^0.4.0", - "symfony/browser-kit": "~6.4" + "mediawiki/mediawiki-codesniffer": "^48.0.0", + "mediawiki/minus-x": "^1.0.0", + "symfony/browser-kit": "~6.4", + "symfony/phpunit-bridge": "~6.4" }, "scripts": { "test": [ diff --git a/composer.lock b/composer.lock index dd5943c34..6d28924a0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,104 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f85505b82b61c9d6b4c5d99e0456c621", + "content-hash": "8697dde110ca7c40a5a64ea476b73bb7", "packages": [ - { - "name": "dealerdirect/phpcodesniffer-composer-installer", - "version": "v1.1.2", - "source": { - "type": "git", - "url": "https://github.com/PHPCSStandards/composer-installer.git", - "reference": "e9cf5e4bbf7eeaf9ef5db34938942602838fc2b1" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/e9cf5e4bbf7eeaf9ef5db34938942602838fc2b1", - "reference": "e9cf5e4bbf7eeaf9ef5db34938942602838fc2b1", - "shasum": "" - }, - "require": { - "composer-plugin-api": "^2.2", - "php": ">=5.4", - "squizlabs/php_codesniffer": "^2.0 || ^3.1.0 || ^4.0" - }, - "require-dev": { - "composer/composer": "^2.2", - "ext-json": "*", - "ext-zip": "*", - "php-parallel-lint/php-parallel-lint": "^1.4.0", - "phpcompatibility/php-compatibility": "^9.0", - "yoast/phpunit-polyfills": "^1.0" - }, - "type": "composer-plugin", - "extra": { - "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" - }, - "autoload": { - "psr-4": { - "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Franck Nijhof", - "email": "opensource@frenck.dev", - "homepage": "https://frenck.dev", - "role": "Open source developer" - }, - { - "name": "Contributors", - "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors" - } - ], - "description": "PHP_CodeSniffer Standards Composer Installer Plugin", - "keywords": [ - "PHPCodeSniffer", - "PHP_CodeSniffer", - "code quality", - "codesniffer", - "composer", - "installer", - "phpcbf", - "phpcs", - "plugin", - "qa", - "quality", - "standard", - "standards", - "style guide", - "stylecheck", - "tests" - ], - "support": { - "issues": "https://github.com/PHPCSStandards/composer-installer/issues", - "security": "https://github.com/PHPCSStandards/composer-installer/security/policy", - "source": "https://github.com/PHPCSStandards/composer-installer" - }, - "funding": [ - { - "url": "https://github.com/PHPCSStandards", - "type": "github" - }, - { - "url": "https://github.com/jrfnl", - "type": "github" - }, - { - "url": "https://opencollective.com/php_codesniffer", - "type": "open_collective" - }, - { - "url": "https://thanks.dev/u/gh/phpcsstandards", - "type": "thanks_dev" - } - ], - "time": "2025-07-17T20:45:56+00:00" - }, { "name": "doctrine/common", "version": "3.5.0", @@ -195,16 +99,16 @@ }, { "name": "doctrine/dbal", - "version": "4.3.2", + "version": "4.4.1", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "7669f131d43b880de168b2d2df9687d152d6c762" + "reference": "3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/7669f131d43b880de168b2d2df9687d152d6c762", - "reference": "7669f131d43b880de168b2d2df9687d152d6c762", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c", + "reference": "3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c", "shasum": "" }, "require": { @@ -214,17 +118,17 @@ "psr/log": "^1|^2|^3" }, "require-dev": { - "doctrine/coding-standard": "13.0.0", + "doctrine/coding-standard": "14.0.0", "fig/log-test": "^1", "jetbrains/phpstorm-stubs": "2023.2", - "phpstan/phpstan": "2.1.17", - "phpstan/phpstan-phpunit": "2.0.6", + "phpstan/phpstan": "2.1.30", + "phpstan/phpstan-phpunit": "2.0.7", "phpstan/phpstan-strict-rules": "^2", "phpunit/phpunit": "11.5.23", - "slevomat/coding-standard": "8.16.2", - "squizlabs/php_codesniffer": "3.13.1", - "symfony/cache": "^6.3.8|^7.0", - "symfony/console": "^5.4|^6.3|^7.0" + "slevomat/coding-standard": "8.24.0", + "squizlabs/php_codesniffer": "4.0.0", + "symfony/cache": "^6.3.8|^7.0|^8.0", + "symfony/console": "^5.4|^6.3|^7.0|^8.0" }, "suggest": { "symfony/console": "For helpful console commands such as SQL execution and import of files." @@ -281,7 +185,7 @@ ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/4.3.2" + "source": "https://github.com/doctrine/dbal/tree/4.4.1" }, "funding": [ { @@ -297,7 +201,7 @@ "type": "tidelift" } ], - "time": "2025-08-05T13:30:38+00:00" + "time": "2025-12-04T10:11:03+00:00" }, { "name": "doctrine/deprecations", @@ -349,20 +253,21 @@ }, { "name": "doctrine/doctrine-bundle", - "version": "2.15.1", + "version": "2.18.2", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineBundle.git", - "reference": "5a305c5e776f9d3eb87f5b94d40d50aff439211d" + "reference": "0ff098b29b8b3c68307c8987dcaed7fd829c6546" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/5a305c5e776f9d3eb87f5b94d40d50aff439211d", - "reference": "5a305c5e776f9d3eb87f5b94d40d50aff439211d", + "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/0ff098b29b8b3c68307c8987dcaed7fd829c6546", + "reference": "0ff098b29b8b3c68307c8987dcaed7fd829c6546", "shasum": "" }, "require": { "doctrine/dbal": "^3.7.0 || ^4.0", + "doctrine/deprecations": "^1.0", "doctrine/persistence": "^3.1 || ^4", "doctrine/sql-formatter": "^1.0.1", "php": "^8.1", @@ -370,7 +275,6 @@ "symfony/config": "^6.4 || ^7.0", "symfony/console": "^6.4 || ^7.0", "symfony/dependency-injection": "^6.4 || ^7.0", - "symfony/deprecation-contracts": "^2.1 || ^3", "symfony/doctrine-bridge": "^6.4.3 || ^7.0.3", "symfony/framework-bundle": "^6.4 || ^7.0", "symfony/service-contracts": "^2.5 || ^3" @@ -385,18 +289,17 @@ "require-dev": { "doctrine/annotations": "^1 || ^2", "doctrine/cache": "^1.11 || ^2.0", - "doctrine/coding-standard": "^13", - "doctrine/deprecations": "^1.0", + "doctrine/coding-standard": "^14", "doctrine/orm": "^2.17 || ^3.1", "friendsofphp/proxy-manager-lts": "^1.0", "phpstan/phpstan": "2.1.1", "phpstan/phpstan-phpunit": "2.0.3", "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "^9.6.22", + "phpunit/phpunit": "^10.5.53 || ^12.3.10", "psr/log": "^1.1.4 || ^2.0 || ^3.0", "symfony/doctrine-messenger": "^6.4 || ^7.0", + "symfony/expression-language": "^6.4 || ^7.0", "symfony/messenger": "^6.4 || ^7.0", - "symfony/phpunit-bridge": "^7.2", "symfony/property-info": "^6.4 || ^7.0", "symfony/security-bundle": "^6.4 || ^7.0", "symfony/stopwatch": "^6.4 || ^7.0", @@ -406,7 +309,7 @@ "symfony/var-exporter": "^6.4.1 || ^7.0.1", "symfony/web-profiler-bundle": "^6.4 || ^7.0", "symfony/yaml": "^6.4 || ^7.0", - "twig/twig": "^2.13 || ^3.0.4" + "twig/twig": "^2.14.7 || ^3.0.4" }, "suggest": { "doctrine/orm": "The Doctrine ORM integration is optional in the bundle.", @@ -451,7 +354,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineBundle/issues", - "source": "https://github.com/doctrine/DoctrineBundle/tree/2.15.1" + "source": "https://github.com/doctrine/DoctrineBundle/tree/2.18.2" }, "funding": [ { @@ -467,32 +370,32 @@ "type": "tidelift" } ], - "time": "2025-07-30T15:48:28+00:00" + "time": "2025-12-20T21:35:32+00:00" }, { "name": "doctrine/doctrine-migrations-bundle", - "version": "3.4.2", + "version": "3.7.0", "source": { "type": "git", "url": "https://github.com/doctrine/DoctrineMigrationsBundle.git", - "reference": "5a6ac7120c2924c4c070a869d08b11ccf9e277b9" + "reference": "1e380c6dd8ac8488217f39cff6b77e367f1a644b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineMigrationsBundle/zipball/5a6ac7120c2924c4c070a869d08b11ccf9e277b9", - "reference": "5a6ac7120c2924c4c070a869d08b11ccf9e277b9", + "url": "https://api.github.com/repos/doctrine/DoctrineMigrationsBundle/zipball/1e380c6dd8ac8488217f39cff6b77e367f1a644b", + "reference": "1e380c6dd8ac8488217f39cff6b77e367f1a644b", "shasum": "" }, "require": { - "doctrine/doctrine-bundle": "^2.4", + "doctrine/doctrine-bundle": "^2.4 || ^3.0", "doctrine/migrations": "^3.2", "php": "^7.2 || ^8.0", "symfony/deprecation-contracts": "^2.1 || ^3", - "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0" + "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { "composer/semver": "^3.0", - "doctrine/coding-standard": "^12", + "doctrine/coding-standard": "^12 || ^14", "doctrine/orm": "^2.6 || ^3", "phpstan/phpstan": "^1.4 || ^2", "phpstan/phpstan-deprecation-rules": "^1 || ^2", @@ -500,8 +403,8 @@ "phpstan/phpstan-strict-rules": "^1.1 || ^2", "phpstan/phpstan-symfony": "^1.3 || ^2", "phpunit/phpunit": "^8.5 || ^9.5", - "symfony/phpunit-bridge": "^6.3 || ^7", - "symfony/var-exporter": "^5.4 || ^6 || ^7" + "symfony/phpunit-bridge": "^6.3 || ^7 || ^8", + "symfony/var-exporter": "^5.4 || ^6 || ^7 || ^8" }, "type": "symfony-bundle", "autoload": { @@ -536,7 +439,7 @@ ], "support": { "issues": "https://github.com/doctrine/DoctrineMigrationsBundle/issues", - "source": "https://github.com/doctrine/DoctrineMigrationsBundle/tree/3.4.2" + "source": "https://github.com/doctrine/DoctrineMigrationsBundle/tree/3.7.0" }, "funding": [ { @@ -552,7 +455,7 @@ "type": "tidelift" } ], - "time": "2025-03-11T17:36:26+00:00" + "time": "2025-11-15T19:02:59+00:00" }, { "name": "doctrine/event-manager", @@ -794,16 +697,16 @@ }, { "name": "doctrine/migrations", - "version": "3.9.4", + "version": "3.9.5", "source": { "type": "git", "url": "https://github.com/doctrine/migrations.git", - "reference": "1b88fcb812f2cd6e77c83d16db60e3cf1e35c66c" + "reference": "1b823afbc40f932dae8272574faee53f2755eac5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/migrations/zipball/1b88fcb812f2cd6e77c83d16db60e3cf1e35c66c", - "reference": "1b88fcb812f2cd6e77c83d16db60e3cf1e35c66c", + "url": "https://api.github.com/repos/doctrine/migrations/zipball/1b823afbc40f932dae8272574faee53f2755eac5", + "reference": "1b823afbc40f932dae8272574faee53f2755eac5", "shasum": "" }, "require": { @@ -813,15 +716,15 @@ "doctrine/event-manager": "^1.2 || ^2.0", "php": "^8.1", "psr/log": "^1.1.3 || ^2 || ^3", - "symfony/console": "^5.4 || ^6.0 || ^7.0", - "symfony/stopwatch": "^5.4 || ^6.0 || ^7.0", - "symfony/var-exporter": "^6.2 || ^7.0" + "symfony/console": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/stopwatch": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/var-exporter": "^6.2 || ^7.0 || ^8.0" }, "conflict": { "doctrine/orm": "<2.12 || >=4" }, "require-dev": { - "doctrine/coding-standard": "^13", + "doctrine/coding-standard": "^14", "doctrine/orm": "^2.13 || ^3", "doctrine/persistence": "^2 || ^3 || ^4", "doctrine/sql-formatter": "^1.0", @@ -833,9 +736,9 @@ "phpstan/phpstan-strict-rules": "^2", "phpstan/phpstan-symfony": "^2", "phpunit/phpunit": "^10.3 || ^11.0 || ^12.0", - "symfony/cache": "^5.4 || ^6.0 || ^7.0", - "symfony/process": "^5.4 || ^6.0 || ^7.0", - "symfony/yaml": "^5.4 || ^6.0 || ^7.0" + "symfony/cache": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/process": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, "suggest": { "doctrine/sql-formatter": "Allows to generate formatted SQL with the diff command.", @@ -877,7 +780,7 @@ ], "support": { "issues": "https://github.com/doctrine/migrations/issues", - "source": "https://github.com/doctrine/migrations/tree/3.9.4" + "source": "https://github.com/doctrine/migrations/tree/3.9.5" }, "funding": [ { @@ -893,20 +796,20 @@ "type": "tidelift" } ], - "time": "2025-08-19T06:41:07+00:00" + "time": "2025-11-20T11:15:36+00:00" }, { "name": "doctrine/persistence", - "version": "4.0.0", + "version": "4.1.1", "source": { "type": "git", "url": "https://github.com/doctrine/persistence.git", - "reference": "45004aca79189474f113cbe3a53847c2115a55fa" + "reference": "b9c49ad3558bb77ef973f4e173f2e9c2eca9be09" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/persistence/zipball/45004aca79189474f113cbe3a53847c2115a55fa", - "reference": "45004aca79189474f113cbe3a53847c2115a55fa", + "url": "https://api.github.com/repos/doctrine/persistence/zipball/b9c49ad3558bb77ef973f4e173f2e9c2eca9be09", + "reference": "b9c49ad3558bb77ef973f4e173f2e9c2eca9be09", "shasum": "" }, "require": { @@ -914,16 +817,14 @@ "php": "^8.1", "psr/cache": "^1.0 || ^2.0 || ^3.0" }, - "conflict": { - "doctrine/common": "<2.10" - }, "require-dev": { - "doctrine/coding-standard": "^12", - "phpstan/phpstan": "1.12.7", - "phpstan/phpstan-phpunit": "^1", - "phpstan/phpstan-strict-rules": "^1.1", - "phpunit/phpunit": "^9.6", - "symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0" + "doctrine/coding-standard": "^14", + "phpstan/phpstan": "2.1.30", + "phpstan/phpstan-phpunit": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.58 || ^12", + "symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0", + "symfony/finder": "^4.4 || ^5.4 || ^6.0 || ^7.0" }, "type": "library", "autoload": { @@ -972,7 +873,7 @@ ], "support": { "issues": "https://github.com/doctrine/persistence/issues", - "source": "https://github.com/doctrine/persistence/tree/4.0.0" + "source": "https://github.com/doctrine/persistence/tree/4.1.1" }, "funding": [ { @@ -988,30 +889,30 @@ "type": "tidelift" } ], - "time": "2024-11-01T21:49:07+00:00" + "time": "2025-10-16T20:13:18+00:00" }, { "name": "doctrine/sql-formatter", - "version": "1.5.2", + "version": "1.5.3", "source": { "type": "git", "url": "https://github.com/doctrine/sql-formatter.git", - "reference": "d6d00aba6fd2957fe5216fe2b7673e9985db20c8" + "reference": "a8af23a8e9d622505baa2997465782cbe8bb7fc7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/d6d00aba6fd2957fe5216fe2b7673e9985db20c8", - "reference": "d6d00aba6fd2957fe5216fe2b7673e9985db20c8", + "url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/a8af23a8e9d622505baa2997465782cbe8bb7fc7", + "reference": "a8af23a8e9d622505baa2997465782cbe8bb7fc7", "shasum": "" }, "require": { "php": "^8.1" }, "require-dev": { - "doctrine/coding-standard": "^12", - "ergebnis/phpunit-slow-test-detector": "^2.14", - "phpstan/phpstan": "^1.10", - "phpunit/phpunit": "^10.5" + "doctrine/coding-standard": "^14", + "ergebnis/phpunit-slow-test-detector": "^2.20", + "phpstan/phpstan": "^2.1.31", + "phpunit/phpunit": "^10.5.58" }, "bin": [ "bin/sql-formatter" @@ -1041,9 +942,9 @@ ], "support": { "issues": "https://github.com/doctrine/sql-formatter/issues", - "source": "https://github.com/doctrine/sql-formatter/tree/1.5.2" + "source": "https://github.com/doctrine/sql-formatter/tree/1.5.3" }, - "time": "2025-01-24T11:45:48+00:00" + "time": "2025-10-26T09:35:14+00:00" }, { "name": "egulias/email-validator", @@ -1114,16 +1015,16 @@ }, { "name": "eightpoints/guzzle-bundle", - "version": "v8.5.2", + "version": "v8.6.0", "source": { "type": "git", "url": "https://github.com/8p/EightPointsGuzzleBundle.git", - "reference": "5df72be234fb0e22d2750e7013003968ec373bff" + "reference": "de8361861881828b17ca846f038ec88f7eacabdc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/8p/EightPointsGuzzleBundle/zipball/5df72be234fb0e22d2750e7013003968ec373bff", - "reference": "5df72be234fb0e22d2750e7013003968ec373bff", + "url": "https://api.github.com/repos/8p/EightPointsGuzzleBundle/zipball/de8361861881828b17ca846f038ec88f7eacabdc", + "reference": "de8361861881828b17ca846f038ec88f7eacabdc", "shasum": "" }, "require": { @@ -1132,15 +1033,15 @@ "guzzlehttp/psr7": "^1.9.1|^2.5", "php": ">=7.2", "psr/log": "~1.0|~2.0|~3.0", - "symfony/expression-language": "~5.0|~6.0|~7.0", - "symfony/framework-bundle": "~5.0|~6.0|~7.0", - "symfony/stopwatch": "~5.0|~6.0|~7.0" + "symfony/expression-language": "~5.0|~6.0|~7.0|~8.0", + "symfony/framework-bundle": "~5.0|~6.0|~7.0|~8.0", + "symfony/stopwatch": "~5.0|~6.0|~7.0|~8.0" }, "require-dev": { - "symfony/phpunit-bridge": "~5.0|~6.0|~7.0", - "symfony/twig-bundle": "~5.0|~6.0|~7.0", - "symfony/var-dumper": "~5.0|~6.0|~7.0", - "symfony/yaml": "~5.0|~6.0|~7.0" + "symfony/phpunit-bridge": "~5.0|~6.0|~7.0|~8.0", + "symfony/twig-bundle": "~5.0|~6.0|~7.0|~8.0", + "symfony/var-dumper": "~5.0|~6.0|~7.0|~8.0", + "symfony/yaml": "~5.0|~6.0|~7.0|~8.0" }, "suggest": { "namshi/cuzzle": "Outputs Curl command on profiler's page for debugging purposes" @@ -1180,28 +1081,28 @@ ], "support": { "issues": "https://github.com/8p/EightPointsGuzzleBundle/issues", - "source": "https://github.com/8p/EightPointsGuzzleBundle/tree/v8.5.2" + "source": "https://github.com/8p/EightPointsGuzzleBundle/tree/v8.6.0" }, - "time": "2025-01-03T09:42:03+00:00" + "time": "2025-12-08T12:58:18+00:00" }, { "name": "guzzlehttp/guzzle", - "version": "7.9.3", + "version": "7.10.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77" + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", - "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^1.5.3 || ^2.0.3", - "guzzlehttp/psr7": "^2.7.0", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.2 || ^3.0" @@ -1292,7 +1193,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.9.3" + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" }, "funding": [ { @@ -1308,20 +1209,20 @@ "type": "tidelift" } ], - "time": "2025-03-27T13:37:11+00:00" + "time": "2025-08-23T22:36:01+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.2.0", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c" + "reference": "481557b130ef3790cf82b713667b43030dc9c957" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/7c69f28996b0a6920945dd20b3857e499d9ca96c", - "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", "shasum": "" }, "require": { @@ -1329,7 +1230,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.39 || ^9.6.20" + "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "type": "library", "extra": { @@ -1375,7 +1276,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.2.0" + "source": "https://github.com/guzzle/promises/tree/2.3.0" }, "funding": [ { @@ -1391,20 +1292,20 @@ "type": "tidelift" } ], - "time": "2025-03-27T13:27:01+00:00" + "time": "2025-08-22T14:34:08+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.7.1", + "version": "2.8.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16" + "reference": "21dc724a0583619cd1652f673303492272778051" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16", - "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", "shasum": "" }, "require": { @@ -1420,7 +1321,7 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "http-interop/http-factory-tests": "0.9.0", - "phpunit/phpunit": "^8.5.39 || ^9.6.20" + "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -1491,7 +1392,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.7.1" + "source": "https://github.com/guzzle/psr7/tree/2.8.0" }, "funding": [ { @@ -1507,33 +1408,33 @@ "type": "tidelift" } ], - "time": "2025-03-27T12:30:47+00:00" + "time": "2025-08-23T21:21:41+00:00" }, { "name": "jms/metadata", - "version": "2.8.0", + "version": "2.9.0", "source": { "type": "git", "url": "https://github.com/schmittjoh/metadata.git", - "reference": "7ca240dcac0c655eb15933ee55736ccd2ea0d7a6" + "reference": "554319d2e5f0c5d8ccaeffe755eac924e14da330" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/metadata/zipball/7ca240dcac0c655eb15933ee55736ccd2ea0d7a6", - "reference": "7ca240dcac0c655eb15933ee55736ccd2ea0d7a6", + "url": "https://api.github.com/repos/schmittjoh/metadata/zipball/554319d2e5f0c5d8ccaeffe755eac924e14da330", + "reference": "554319d2e5f0c5d8ccaeffe755eac924e14da330", "shasum": "" }, "require": { "php": "^7.2|^8.0" }, "require-dev": { - "doctrine/cache": "^1.0", + "doctrine/cache": "^1.0|^2.0", "doctrine/coding-standard": "^8.0", "mikey179/vfsstream": "^1.6.7", - "phpunit/phpunit": "^8.5|^9.0", + "phpunit/phpunit": "^8.5.42|^9.6.23", "psr/container": "^1.0|^2.0", - "symfony/cache": "^3.1|^4.0|^5.0", - "symfony/dependency-injection": "^3.1|^4.0|^5.0" + "symfony/cache": "^3.1|^4.0|^5.0|^6.0|^7.0|^8.0", + "symfony/dependency-injection": "^3.1|^4.0|^5.0|^6.0|^7.0|^8.0" }, "type": "library", "extra": { @@ -1569,22 +1470,22 @@ ], "support": { "issues": "https://github.com/schmittjoh/metadata/issues", - "source": "https://github.com/schmittjoh/metadata/tree/2.8.0" + "source": "https://github.com/schmittjoh/metadata/tree/2.9.0" }, - "time": "2023-02-15T13:44:18+00:00" + "time": "2025-11-30T20:12:26+00:00" }, { "name": "jms/serializer", - "version": "3.32.5", + "version": "3.32.6", "source": { "type": "git", "url": "https://github.com/schmittjoh/serializer.git", - "reference": "7c88b1b02ff868eecc870eeddbb3b1250e4bd89c" + "reference": "b02a6c00d8335ef68c163bf7c9e39f396dc5853f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/serializer/zipball/7c88b1b02ff868eecc870eeddbb3b1250e4bd89c", - "reference": "7c88b1b02ff868eecc870eeddbb3b1250e4bd89c", + "url": "https://api.github.com/repos/schmittjoh/serializer/zipball/b02a6c00d8335ef68c163bf7c9e39f396dc5853f", + "reference": "b02a6c00d8335ef68c163bf7c9e39f396dc5853f", "shasum": "" }, "require": { @@ -1607,16 +1508,15 @@ "phpstan/phpstan": "^2.0", "phpunit/phpunit": "^9.0 || ^10.0 || ^11.0", "psr/container": "^1.0 || ^2.0", - "rector/rector": "^1.0.0 || ^2.0@dev", - "slevomat/coding-standard": "dev-master#f2cc4c553eae68772624ffd7dd99022343b69c31 as 8.11.9999", - "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0", - "symfony/expression-language": "^5.4 || ^6.0 || ^7.0", - "symfony/filesystem": "^5.4 || ^6.0 || ^7.0", - "symfony/form": "^5.4 || ^6.0 || ^7.0", - "symfony/translation": "^5.4 || ^6.0 || ^7.0", - "symfony/uid": "^5.4 || ^6.0 || ^7.0", - "symfony/validator": "^5.4 || ^6.0 || ^7.0", - "symfony/yaml": "^5.4 || ^6.0 || ^7.0", + "rector/rector": "^1.0.0 || ^2.0", + "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/expression-language": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/form": "^5.4.45 || ^6.4.27 || ^7.0 || ^8.0", + "symfony/translation": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/uid": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/validator": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0 || ^8.0", "twig/twig": "^1.34 || ^2.4 || ^3.0" }, "suggest": { @@ -1661,7 +1561,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/serializer/issues", - "source": "https://github.com/schmittjoh/serializer/tree/3.32.5" + "source": "https://github.com/schmittjoh/serializer/tree/3.32.6" }, "funding": [ { @@ -1673,44 +1573,44 @@ "type": "github" } ], - "time": "2025-05-26T15:55:41+00:00" + "time": "2025-11-28T12:37:32+00:00" }, { "name": "jms/serializer-bundle", - "version": "5.5.1", + "version": "5.5.2", "source": { "type": "git", "url": "https://github.com/schmittjoh/JMSSerializerBundle.git", - "reference": "0538a2bae32a448fdeded53d729308816b5ad2e8" + "reference": "34d01be85521e99ca29079438002672af35ff9b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/JMSSerializerBundle/zipball/0538a2bae32a448fdeded53d729308816b5ad2e8", - "reference": "0538a2bae32a448fdeded53d729308816b5ad2e8", + "url": "https://api.github.com/repos/schmittjoh/JMSSerializerBundle/zipball/34d01be85521e99ca29079438002672af35ff9b0", + "reference": "34d01be85521e99ca29079438002672af35ff9b0", "shasum": "" }, "require": { "jms/metadata": "^2.6", "jms/serializer": "^3.31", "php": "^7.4 || ^8.0", - "symfony/config": "^5.4 || ^6.0 || ^7.0", - "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0", - "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0" + "symfony/config": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { "doctrine/annotations": "^1.14 || ^2.0", "doctrine/coding-standard": "^12.0", - "doctrine/orm": "^2.14", + "doctrine/orm": "^2.14 || ^3.0", "phpunit/phpunit": "^8.0 || ^9.0", - "symfony/expression-language": "^5.4 || ^6.0 || ^7.0", - "symfony/finder": "^5.4 || ^6.0 || ^7.0", - "symfony/form": "^5.4 || ^6.0 || ^7.0", - "symfony/stopwatch": "^5.4 || ^6.0 || ^7.0", + "symfony/expression-language": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/finder": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/form": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/stopwatch": "^5.4 || ^6.0 || ^7.0 || ^8.0", "symfony/templating": "^5.4 || ^6.0", - "symfony/twig-bundle": "^5.4 || ^6.0 || ^7.0", - "symfony/uid": "^5.4 || ^6.0 || ^7.0", - "symfony/validator": "^5.4 || ^6.0 || ^7.0", - "symfony/yaml": "^5.4 || ^6.0 || ^7.0" + "symfony/twig-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/uid": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/validator": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, "suggest": { "symfony/expression-language": "Required for opcache preloading ^5.4 || ^6.0 || ^7.0", @@ -1754,7 +1654,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/JMSSerializerBundle/issues", - "source": "https://github.com/schmittjoh/JMSSerializerBundle/tree/5.5.1" + "source": "https://github.com/schmittjoh/JMSSerializerBundle/tree/5.5.2" }, "funding": [ { @@ -1762,7 +1662,7 @@ "type": "github" } ], - "time": "2024-11-06T12:45:22+00:00" + "time": "2025-11-25T21:41:31+00:00" }, { "name": "krinkle/intuition", @@ -2040,16 +1940,16 @@ }, { "name": "nelmio/api-doc-bundle", - "version": "v4.38.2", + "version": "v4.38.6", "source": { "type": "git", "url": "https://github.com/nelmio/NelmioApiDocBundle.git", - "reference": "fdc1cf5bc57287787db59f205a8e77485bd22072" + "reference": "3a087da90e3455af3590dfc5edeff6b67da50c32" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nelmio/NelmioApiDocBundle/zipball/fdc1cf5bc57287787db59f205a8e77485bd22072", - "reference": "fdc1cf5bc57287787db59f205a8e77485bd22072", + "url": "https://api.github.com/repos/nelmio/NelmioApiDocBundle/zipball/3a087da90e3455af3590dfc5edeff6b67da50c32", + "reference": "3a087da90e3455af3590dfc5edeff6b67da50c32", "shasum": "" }, "require": { @@ -2073,7 +1973,7 @@ "zircote/swagger-php": "^4.11.1 || ^5.0" }, "conflict": { - "zircote/swagger-php": "4.8.7" + "zircote/swagger-php": "4.8.7 || 5.5.0" }, "require-dev": { "api-platform/core": "^2.7.0 || ^3", @@ -2150,7 +2050,7 @@ ], "support": { "issues": "https://github.com/nelmio/NelmioApiDocBundle/issues", - "source": "https://github.com/nelmio/NelmioApiDocBundle/tree/v4.38.2" + "source": "https://github.com/nelmio/NelmioApiDocBundle/tree/v4.38.6" }, "funding": [ { @@ -2158,29 +2058,32 @@ "type": "github" } ], - "time": "2025-03-24T15:00:53+00:00" + "time": "2025-11-28T10:30:54+00:00" }, { "name": "nelmio/cors-bundle", - "version": "2.5.0", + "version": "2.6.0", "source": { "type": "git", "url": "https://github.com/nelmio/NelmioCorsBundle.git", - "reference": "3a526fe025cd20e04a6a11370cf5ab28dbb5a544" + "reference": "530217472204881cacd3671909f634b960c7b948" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nelmio/NelmioCorsBundle/zipball/3a526fe025cd20e04a6a11370cf5ab28dbb5a544", - "reference": "3a526fe025cd20e04a6a11370cf5ab28dbb5a544", + "url": "https://api.github.com/repos/nelmio/NelmioCorsBundle/zipball/530217472204881cacd3671909f634b960c7b948", + "reference": "530217472204881cacd3671909f634b960c7b948", "shasum": "" }, "require": { "psr/log": "^1.0 || ^2.0 || ^3.0", - "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0" + "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { - "mockery/mockery": "^1.3.6", - "symfony/phpunit-bridge": "^5.4 || ^6.0 || ^7.0" + "phpstan/phpstan": "^1.11.5", + "phpstan/phpstan-deprecation-rules": "^1.2.0", + "phpstan/phpstan-phpunit": "^1.4", + "phpstan/phpstan-symfony": "^1.4.4", + "phpunit/phpunit": "^8" }, "type": "symfony-bundle", "extra": { @@ -2218,22 +2121,22 @@ ], "support": { "issues": "https://github.com/nelmio/NelmioCorsBundle/issues", - "source": "https://github.com/nelmio/NelmioCorsBundle/tree/2.5.0" + "source": "https://github.com/nelmio/NelmioCorsBundle/tree/2.6.0" }, - "time": "2024-06-24T21:25:28+00:00" + "time": "2025-10-23T06:57:22+00:00" }, { "name": "nikic/php-parser", - "version": "v5.6.1", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", - "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -2276,9 +2179,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2025-08-13T20:13:15+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "phpdocumentor/reflection-common", @@ -2335,16 +2238,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.3", + "version": "5.6.6", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "94f8051919d1b0369a6bcc7931d679a511c03fe9" + "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/94f8051919d1b0369a6bcc7931d679a511c03fe9", - "reference": "94f8051919d1b0369a6bcc7931d679a511c03fe9", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8", "shasum": "" }, "require": { @@ -2354,7 +2257,7 @@ "phpdocumentor/reflection-common": "^2.2", "phpdocumentor/type-resolver": "^1.7", "phpstan/phpdoc-parser": "^1.7|^2.0", - "webmozart/assert": "^1.9.1" + "webmozart/assert": "^1.9.1 || ^2" }, "require-dev": { "mockery/mockery": "~1.3.5 || ~1.6.0", @@ -2393,22 +2296,22 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.3" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6" }, - "time": "2025-08-01T19:43:32+00:00" + "time": "2025-12-22T21:13:58+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.10.0", + "version": "1.12.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a" + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/679e3ce485b99e84c775d28e2e96fade9a7fb50a", - "reference": "679e3ce485b99e84c775d28e2e96fade9a7fb50a", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", "shasum": "" }, "require": { @@ -2451,9 +2354,9 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.10.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" }, - "time": "2024-11-09T15:12:26+00:00" + "time": "2025-11-21T15:09:14+00:00" }, { "name": "phpstan/phpdoc-parser", @@ -2908,155 +2811,6 @@ }, "time": "2019-03-08T08:55:37+00:00" }, - { - "name": "slevomat/coding-standard", - "version": "8.15.0", - "source": { - "type": "git", - "url": "https://github.com/slevomat/coding-standard.git", - "reference": "7d1d957421618a3803b593ec31ace470177d7817" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/7d1d957421618a3803b593ec31ace470177d7817", - "reference": "7d1d957421618a3803b593ec31ace470177d7817", - "shasum": "" - }, - "require": { - "dealerdirect/phpcodesniffer-composer-installer": "^0.6.2 || ^0.7 || ^1.0", - "php": "^7.2 || ^8.0", - "phpstan/phpdoc-parser": "^1.23.1", - "squizlabs/php_codesniffer": "^3.9.0" - }, - "require-dev": { - "phing/phing": "2.17.4", - "php-parallel-lint/php-parallel-lint": "1.3.2", - "phpstan/phpstan": "1.10.60", - "phpstan/phpstan-deprecation-rules": "1.1.4", - "phpstan/phpstan-phpunit": "1.3.16", - "phpstan/phpstan-strict-rules": "1.5.2", - "phpunit/phpunit": "8.5.21|9.6.8|10.5.11" - }, - "type": "phpcodesniffer-standard", - "extra": { - "branch-alias": { - "dev-master": "8.x-dev" - } - }, - "autoload": { - "psr-4": { - "SlevomatCodingStandard\\": "SlevomatCodingStandard/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Slevomat Coding Standard for PHP_CodeSniffer complements Consistence Coding Standard by providing sniffs with additional checks.", - "keywords": [ - "dev", - "phpcs" - ], - "support": { - "issues": "https://github.com/slevomat/coding-standard/issues", - "source": "https://github.com/slevomat/coding-standard/tree/8.15.0" - }, - "funding": [ - { - "url": "https://github.com/kukulich", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/slevomat/coding-standard", - "type": "tidelift" - } - ], - "time": "2024-03-09T15:20:58+00:00" - }, - { - "name": "squizlabs/php_codesniffer", - "version": "3.13.2", - "source": { - "type": "git", - "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "5b5e3821314f947dd040c70f7992a64eac89025c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/5b5e3821314f947dd040c70f7992a64eac89025c", - "reference": "5b5e3821314f947dd040c70f7992a64eac89025c", - "shasum": "" - }, - "require": { - "ext-simplexml": "*", - "ext-tokenizer": "*", - "ext-xmlwriter": "*", - "php": ">=5.4.0" - }, - "require-dev": { - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" - }, - "bin": [ - "bin/phpcbf", - "bin/phpcs" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Greg Sherwood", - "role": "Former lead" - }, - { - "name": "Juliette Reinders Folmer", - "role": "Current lead" - }, - { - "name": "Contributors", - "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" - } - ], - "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", - "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", - "keywords": [ - "phpcs", - "standards", - "static analysis" - ], - "support": { - "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", - "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", - "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", - "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" - }, - "funding": [ - { - "url": "https://github.com/PHPCSStandards", - "type": "github" - }, - { - "url": "https://github.com/jrfnl", - "type": "github" - }, - { - "url": "https://opencollective.com/php_codesniffer", - "type": "open_collective" - }, - { - "url": "https://thanks.dev/u/gh/phpcsstandards", - "type": "thanks_dev" - } - ], - "time": "2025-06-17T22:17:01+00:00" - }, { "name": "symfony/asset", "version": "v6.4.24", @@ -3132,16 +2886,16 @@ }, { "name": "symfony/cache", - "version": "v6.4.24", + "version": "v6.4.30", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "d038cd3054aeaf1c674022a77048b2ef6376a175" + "reference": "eb3272ed2daed13ed24816e862d73f73d995972a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/d038cd3054aeaf1c674022a77048b2ef6376a175", - "reference": "d038cd3054aeaf1c674022a77048b2ef6376a175", + "url": "https://api.github.com/repos/symfony/cache/zipball/eb3272ed2daed13ed24816e862d73f73d995972a", + "reference": "eb3272ed2daed13ed24816e862d73f73d995972a", "shasum": "" }, "require": { @@ -3208,7 +2962,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v6.4.24" + "source": "https://github.com/symfony/cache/tree/v6.4.30" }, "funding": [ { @@ -3228,7 +2982,7 @@ "type": "tidelift" } ], - "time": "2025-07-30T09:32:03+00:00" + "time": "2025-12-01T16:41:59+00:00" }, { "name": "symfony/cache-contracts", @@ -3308,16 +3062,16 @@ }, { "name": "symfony/config", - "version": "v6.4.24", + "version": "v6.4.28", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "80e2cf005cf17138c97193be0434cdcfd1b2212e" + "reference": "15947c18ef3ddb0b2f4ec936b9e90e2520979f62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/80e2cf005cf17138c97193be0434cdcfd1b2212e", - "reference": "80e2cf005cf17138c97193be0434cdcfd1b2212e", + "url": "https://api.github.com/repos/symfony/config/zipball/15947c18ef3ddb0b2f4ec936b9e90e2520979f62", + "reference": "15947c18ef3ddb0b2f4ec936b9e90e2520979f62", "shasum": "" }, "require": { @@ -3363,7 +3117,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v6.4.24" + "source": "https://github.com/symfony/config/tree/v6.4.28" }, "funding": [ { @@ -3383,20 +3137,20 @@ "type": "tidelift" } ], - "time": "2025-07-26T13:50:30+00:00" + "time": "2025-11-01T19:52:02+00:00" }, { "name": "symfony/console", - "version": "v6.4.24", + "version": "v6.4.30", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "59266a5bf6a596e3e0844fd95e6ad7ea3c1d3350" + "reference": "1b2813049506b39eb3d7e64aff033fd5ca26c97e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/59266a5bf6a596e3e0844fd95e6ad7ea3c1d3350", - "reference": "59266a5bf6a596e3e0844fd95e6ad7ea3c1d3350", + "url": "https://api.github.com/repos/symfony/console/zipball/1b2813049506b39eb3d7e64aff033fd5ca26c97e", + "reference": "1b2813049506b39eb3d7e64aff033fd5ca26c97e", "shasum": "" }, "require": { @@ -3461,7 +3215,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.24" + "source": "https://github.com/symfony/console/tree/v6.4.30" }, "funding": [ { @@ -3481,7 +3235,7 @@ "type": "tidelift" } ], - "time": "2025-07-30T10:38:54+00:00" + "time": "2025-12-05T13:47:41+00:00" }, { "name": "symfony/css-selector", @@ -3554,16 +3308,16 @@ }, { "name": "symfony/dependency-injection", - "version": "v6.4.24", + "version": "v6.4.30", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "929ab73b93247a15166ee79e807ccee4f930322d" + "reference": "5328f994cbb0855ba25c3a54f4a31a279511640f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/929ab73b93247a15166ee79e807ccee4f930322d", - "reference": "929ab73b93247a15166ee79e807ccee4f930322d", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/5328f994cbb0855ba25c3a54f4a31a279511640f", + "reference": "5328f994cbb0855ba25c3a54f4a31a279511640f", "shasum": "" }, "require": { @@ -3615,7 +3369,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v6.4.24" + "source": "https://github.com/symfony/dependency-injection/tree/v6.4.30" }, "funding": [ { @@ -3635,7 +3389,7 @@ "type": "tidelift" } ], - "time": "2025-07-30T17:30:48+00:00" + "time": "2025-12-07T09:29:59+00:00" }, { "name": "symfony/deprecation-contracts", @@ -3706,16 +3460,16 @@ }, { "name": "symfony/doctrine-bridge", - "version": "v7.3.2", + "version": "v7.4.1", "source": { "type": "git", "url": "https://github.com/symfony/doctrine-bridge.git", - "reference": "a2cbc12baf9bcc5d0c125e4c0f8330b98af841ca" + "reference": "7acd7ce1b71601b25d698bc2da6b52e43f3c72b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/a2cbc12baf9bcc5d0c125e4c0f8330b98af841ca", - "reference": "a2cbc12baf9bcc5d0c125e4c0f8330b98af841ca", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/7acd7ce1b71601b25d698bc2da6b52e43f3c72b3", + "reference": "7acd7ce1b71601b25d698bc2da6b52e43f3c72b3", "shasum": "" }, "require": { @@ -3742,7 +3496,7 @@ "symfony/property-info": "<6.4", "symfony/security-bundle": "<6.4", "symfony/security-core": "<6.4", - "symfony/validator": "<6.4" + "symfony/validator": "<7.4" }, "require-dev": { "doctrine/collections": "^1.8|^2.0", @@ -3750,24 +3504,24 @@ "doctrine/dbal": "^3.6|^4", "doctrine/orm": "^2.15|^3", "psr/log": "^1|^2|^3", - "symfony/cache": "^6.4|^7.0", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/doctrine-messenger": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/form": "^6.4.6|^7.0.6", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/property-access": "^6.4|^7.0", - "symfony/property-info": "^6.4|^7.0", - "symfony/security-core": "^6.4|^7.0", - "symfony/stopwatch": "^6.4|^7.0", - "symfony/translation": "^6.4|^7.0", - "symfony/type-info": "^7.1.8", - "symfony/uid": "^6.4|^7.0", - "symfony/validator": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0" + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/doctrine-messenger": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/form": "^7.2|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/security-core": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/type-info": "^7.1.8|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "symfony-bridge", "autoload": { @@ -3795,7 +3549,7 @@ "description": "Provides integration for Doctrine with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/doctrine-bridge/tree/v7.3.2" + "source": "https://github.com/symfony/doctrine-bridge/tree/v7.4.1" }, "funding": [ { @@ -3815,20 +3569,20 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:36:08+00:00" + "time": "2025-12-04T17:15:58+00:00" }, { "name": "symfony/dom-crawler", - "version": "v6.4.24", + "version": "v6.4.25", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "202a37e973b7e789604b96fba6473f74c43da045" + "reference": "976302990f9f2a6d4c07206836dd4ca77cae9524" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/202a37e973b7e789604b96fba6473f74c43da045", - "reference": "202a37e973b7e789604b96fba6473f74c43da045", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/976302990f9f2a6d4c07206836dd4ca77cae9524", + "reference": "976302990f9f2a6d4c07206836dd4ca77cae9524", "shasum": "" }, "require": { @@ -3866,7 +3620,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v6.4.24" + "source": "https://github.com/symfony/dom-crawler/tree/v6.4.25" }, "funding": [ { @@ -3886,20 +3640,20 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:14:14+00:00" + "time": "2025-08-05T18:56:08+00:00" }, { "name": "symfony/dotenv", - "version": "v6.4.24", + "version": "v6.4.30", "source": { "type": "git", "url": "https://github.com/symfony/dotenv.git", - "reference": "234b6c602f12b00693f4b0d1054386fb30dfc8ff" + "reference": "924edbc9631b75302def0258ed1697948b17baf6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dotenv/zipball/234b6c602f12b00693f4b0d1054386fb30dfc8ff", - "reference": "234b6c602f12b00693f4b0d1054386fb30dfc8ff", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/924edbc9631b75302def0258ed1697948b17baf6", + "reference": "924edbc9631b75302def0258ed1697948b17baf6", "shasum": "" }, "require": { @@ -3944,7 +3698,7 @@ "environment" ], "support": { - "source": "https://github.com/symfony/dotenv/tree/v6.4.24" + "source": "https://github.com/symfony/dotenv/tree/v6.4.30" }, "funding": [ { @@ -3964,36 +3718,37 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:14:14+00:00" + "time": "2025-11-14T17:33:48+00:00" }, { "name": "symfony/error-handler", - "version": "v7.3.2", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3" + "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/0b31a944fcd8759ae294da4d2808cbc53aebd0c3", - "reference": "0b31a944fcd8759ae294da4d2808cbc53aebd0c3", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/48be2b0653594eea32dcef130cca1c811dcf25c2", + "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2", "shasum": "" }, "require": { "php": ">=8.2", "psr/log": "^1|^2|^3", - "symfony/var-dumper": "^6.4|^7.0" + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "conflict": { "symfony/deprecation-contracts": "<2.5", "symfony/http-kernel": "<6.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0|^8.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ @@ -4025,7 +3780,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.3.2" + "source": "https://github.com/symfony/error-handler/tree/v7.4.0" }, "funding": [ { @@ -4045,20 +3800,20 @@ "type": "tidelift" } ], - "time": "2025-07-07T08:17:57+00:00" + "time": "2025-11-05T14:29:59+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.3.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "497f73ac996a598c92409b44ac43b6690c4f666d" + "reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/497f73ac996a598c92409b44ac43b6690c4f666d", - "reference": "497f73ac996a598c92409b44ac43b6690c4f666d", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/9dddcddff1ef974ad87b3708e4b442dc38b2261d", + "reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d", "shasum": "" }, "require": { @@ -4075,13 +3830,14 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/error-handler": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^6.4|^7.0" + "symfony/stopwatch": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4109,7 +3865,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.0" }, "funding": [ { @@ -4120,12 +3876,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-22T09:11:45+00:00" + "time": "2025-10-28T09:38:46+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -4205,21 +3965,21 @@ }, { "name": "symfony/expression-language", - "version": "v7.3.2", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/expression-language.git", - "reference": "32d2d19c62e58767e6552166c32fb259975d2b23" + "reference": "8b9bbbb8c71f79a09638f6ea77c531e511139efa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/expression-language/zipball/32d2d19c62e58767e6552166c32fb259975d2b23", - "reference": "32d2d19c62e58767e6552166c32fb259975d2b23", + "url": "https://api.github.com/repos/symfony/expression-language/zipball/8b9bbbb8c71f79a09638f6ea77c531e511139efa", + "reference": "8b9bbbb8c71f79a09638f6ea77c531e511139efa", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/cache": "^6.4|^7.0", + "symfony/cache": "^6.4|^7.0|^8.0", "symfony/deprecation-contracts": "^2.5|^3", "symfony/service-contracts": "^2.5|^3" }, @@ -4249,7 +4009,7 @@ "description": "Provides an engine that can compile and evaluate expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/expression-language/tree/v7.3.2" + "source": "https://github.com/symfony/expression-language/tree/v7.4.0" }, "funding": [ { @@ -4269,20 +4029,20 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:29:33+00:00" + "time": "2025-11-12T15:39:26+00:00" }, { "name": "symfony/filesystem", - "version": "v7.3.2", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "edcbb768a186b5c3f25d0643159a787d3e63b7fd" + "reference": "d551b38811096d0be9c4691d406991b47c0c630a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/edcbb768a186b5c3f25d0643159a787d3e63b7fd", - "reference": "edcbb768a186b5c3f25d0643159a787d3e63b7fd", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d551b38811096d0be9c4691d406991b47c0c630a", + "reference": "d551b38811096d0be9c4691d406991b47c0c630a", "shasum": "" }, "require": { @@ -4291,7 +4051,7 @@ "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "symfony/process": "^6.4|^7.0" + "symfony/process": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4319,7 +4079,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.3.2" + "source": "https://github.com/symfony/filesystem/tree/v7.4.0" }, "funding": [ { @@ -4339,27 +4099,27 @@ "type": "tidelift" } ], - "time": "2025-07-07T08:17:47+00:00" + "time": "2025-11-27T13:27:24+00:00" }, { "name": "symfony/finder", - "version": "v7.3.2", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe" + "reference": "340b9ed7320570f319028a2cbec46d40535e94bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/2a6614966ba1074fa93dae0bc804227422df4dfe", - "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe", + "url": "https://api.github.com/repos/symfony/finder/zipball/340b9ed7320570f319028a2cbec46d40535e94bd", + "reference": "340b9ed7320570f319028a2cbec46d40535e94bd", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0" + "symfony/filesystem": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4387,7 +4147,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.3.2" + "source": "https://github.com/symfony/finder/tree/v7.4.0" }, "funding": [ { @@ -4407,7 +4167,7 @@ "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2025-11-05T05:42:40+00:00" }, { "name": "symfony/flex", @@ -4479,16 +4239,16 @@ }, { "name": "symfony/framework-bundle", - "version": "v6.4.24", + "version": "v6.4.30", "source": { "type": "git", "url": "https://github.com/symfony/framework-bundle.git", - "reference": "869b94902dd38f2f33718908f2b5d4868e3b9241" + "reference": "3c212ec5cac588da8357f5c061194363a4e91010" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/869b94902dd38f2f33718908f2b5d4868e3b9241", - "reference": "869b94902dd38f2f33718908f2b5d4868e3b9241", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/3c212ec5cac588da8357f5c061194363a4e91010", + "reference": "3c212ec5cac588da8357f5c061194363a4e91010", "shasum": "" }, "require": { @@ -4608,7 +4368,7 @@ "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/framework-bundle/tree/v6.4.24" + "source": "https://github.com/symfony/framework-bundle/tree/v6.4.30" }, "funding": [ { @@ -4628,27 +4388,26 @@ "type": "tidelift" } ], - "time": "2025-07-30T07:06:12+00:00" + "time": "2025-11-29T11:31:32+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.3.2", + "version": "v7.4.1", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6" + "reference": "bd1af1e425811d6f077db240c3a588bdb405cd27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/6877c122b3a6cc3695849622720054f6e6fa5fa6", - "reference": "6877c122b3a6cc3695849622720054f6e6fa5fa6", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bd1af1e425811d6f077db240c3a588bdb405cd27", + "reference": "bd1af1e425811d6f077db240c3a588bdb405cd27", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3.0", - "symfony/polyfill-mbstring": "~1.1", - "symfony/polyfill-php83": "^1.27" + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" }, "conflict": { "doctrine/dbal": "<3.6", @@ -4657,13 +4416,13 @@ "require-dev": { "doctrine/dbal": "^3.6|^4", "predis/predis": "^1.1|^2.0", - "symfony/cache": "^6.4.12|^7.1.5", - "symfony/clock": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/mime": "^6.4|^7.0", - "symfony/rate-limiter": "^6.4|^7.0" + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4691,7 +4450,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.3.2" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.1" }, "funding": [ { @@ -4711,20 +4470,20 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:47:49+00:00" + "time": "2025-12-07T11:13:10+00:00" }, { "name": "symfony/http-kernel", - "version": "v6.4.24", + "version": "v6.4.30", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "b81dcdbe34b8e8f7b3fc7b2a47fa065d5bf30726" + "reference": "ceac681e74e824bbf90468eb231d40988d6d18a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/b81dcdbe34b8e8f7b3fc7b2a47fa065d5bf30726", - "reference": "b81dcdbe34b8e8f7b3fc7b2a47fa065d5bf30726", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/ceac681e74e824bbf90468eb231d40988d6d18a5", + "reference": "ceac681e74e824bbf90468eb231d40988d6d18a5", "shasum": "" }, "require": { @@ -4809,7 +4568,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v6.4.24" + "source": "https://github.com/symfony/http-kernel/tree/v6.4.30" }, "funding": [ { @@ -4829,20 +4588,20 @@ "type": "tidelift" } ], - "time": "2025-07-31T09:23:30+00:00" + "time": "2025-12-07T15:49:34+00:00" }, { "name": "symfony/mailer", - "version": "v6.4.24", + "version": "v6.4.27", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "b4d7fa2c69641109979ed06e98a588d245362062" + "reference": "2f096718ed718996551f66e3a24e12b2ed027f95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/b4d7fa2c69641109979ed06e98a588d245362062", - "reference": "b4d7fa2c69641109979ed06e98a588d245362062", + "url": "https://api.github.com/repos/symfony/mailer/zipball/2f096718ed718996551f66e3a24e12b2ed027f95", + "reference": "2f096718ed718996551f66e3a24e12b2ed027f95", "shasum": "" }, "require": { @@ -4893,7 +4652,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v6.4.24" + "source": "https://github.com/symfony/mailer/tree/v6.4.27" }, "funding": [ { @@ -4913,24 +4672,25 @@ "type": "tidelift" } ], - "time": "2025-07-24T08:25:04+00:00" + "time": "2025-10-24T13:29:09+00:00" }, { "name": "symfony/mime", - "version": "v7.3.2", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1" + "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/e0a0f859148daf1edf6c60b398eb40bfc96697d1", - "reference": "e0a0f859148daf1edf6c60b398eb40bfc96697d1", + "url": "https://api.github.com/repos/symfony/mime/zipball/bdb02729471be5d047a3ac4a69068748f1a6be7a", + "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" }, @@ -4945,11 +4705,11 @@ "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/property-access": "^6.4|^7.0", - "symfony/property-info": "^6.4|^7.0", - "symfony/serializer": "^6.4.3|^7.0.3" + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.3|^7.0.3|^8.0" }, "type": "library", "autoload": { @@ -4981,7 +4741,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.3.2" + "source": "https://github.com/symfony/mime/tree/v7.4.0" }, "funding": [ { @@ -5001,20 +4761,20 @@ "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2025-11-16T10:14:42+00:00" }, { "name": "symfony/monolog-bridge", - "version": "v6.4.24", + "version": "v6.4.28", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bridge.git", - "reference": "b0ff45e8d9289062a963deaf8b55e92488322e3f" + "reference": "d2f4b68e3247cf44d93f48545c8c072a75c17e5b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/b0ff45e8d9289062a963deaf8b55e92488322e3f", - "reference": "b0ff45e8d9289062a963deaf8b55e92488322e3f", + "url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/d2f4b68e3247cf44d93f48545c8c072a75c17e5b", + "reference": "d2f4b68e3247cf44d93f48545c8c072a75c17e5b", "shasum": "" }, "require": { @@ -5064,7 +4824,7 @@ "description": "Provides integration for Monolog with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/monolog-bridge/tree/v6.4.24" + "source": "https://github.com/symfony/monolog-bridge/tree/v6.4.28" }, "funding": [ { @@ -5084,48 +4844,43 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:14:14+00:00" + "time": "2025-10-30T19:57:08+00:00" }, { "name": "symfony/monolog-bundle", - "version": "v3.10.0", + "version": "v3.11.1", "source": { "type": "git", "url": "https://github.com/symfony/monolog-bundle.git", - "reference": "414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181" + "reference": "0e675a6e08f791ef960dc9c7e392787111a3f0c1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181", - "reference": "414f951743f4aa1fd0f5bf6a0e9c16af3fe7f181", + "url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/0e675a6e08f791ef960dc9c7e392787111a3f0c1", + "reference": "0e675a6e08f791ef960dc9c7e392787111a3f0c1", "shasum": "" }, "require": { + "composer-runtime-api": "^2.0", "monolog/monolog": "^1.25.1 || ^2.0 || ^3.0", - "php": ">=7.2.5", - "symfony/config": "^5.4 || ^6.0 || ^7.0", - "symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0", - "symfony/http-kernel": "^5.4 || ^6.0 || ^7.0", - "symfony/monolog-bridge": "^5.4 || ^6.0 || ^7.0" + "php": ">=8.1", + "symfony/config": "^6.4 || ^7.0", + "symfony/dependency-injection": "^6.4 || ^7.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/http-kernel": "^6.4 || ^7.0", + "symfony/monolog-bridge": "^6.4 || ^7.0", + "symfony/polyfill-php84": "^1.30" }, "require-dev": { - "symfony/console": "^5.4 || ^6.0 || ^7.0", - "symfony/phpunit-bridge": "^6.3 || ^7.0", - "symfony/yaml": "^5.4 || ^6.0 || ^7.0" + "symfony/console": "^6.4 || ^7.0", + "symfony/phpunit-bridge": "^7.3.3", + "symfony/yaml": "^6.4 || ^7.0" }, "type": "symfony-bundle", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, "autoload": { "psr-4": { - "Symfony\\Bundle\\MonologBundle\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\Bundle\\MonologBundle\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -5149,7 +4904,7 @@ ], "support": { "issues": "https://github.com/symfony/monolog-bundle/issues", - "source": "https://github.com/symfony/monolog-bundle/tree/v3.10.0" + "source": "https://github.com/symfony/monolog-bundle/tree/v3.11.1" }, "funding": [ { @@ -5160,25 +4915,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2023-11-06T17:08:13+00:00" + "time": "2025-12-08T07:58:26+00:00" }, { "name": "symfony/options-resolver", - "version": "v7.3.2", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "119bcf13e67dbd188e5dbc74228b1686f66acd37" + "reference": "b38026df55197f9e39a44f3215788edf83187b80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/119bcf13e67dbd188e5dbc74228b1686f66acd37", - "reference": "119bcf13e67dbd188e5dbc74228b1686f66acd37", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b38026df55197f9e39a44f3215788edf83187b80", + "reference": "b38026df55197f9e39a44f3215788edf83187b80", "shasum": "" }, "require": { @@ -5216,7 +4975,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v7.3.2" + "source": "https://github.com/symfony/options-resolver/tree/v7.4.0" }, "funding": [ { @@ -5236,20 +4995,20 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:36:08+00:00" + "time": "2025-11-12T15:39:26+00:00" }, { "name": "symfony/password-hasher", - "version": "v7.3.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/password-hasher.git", - "reference": "31fbe66af859582a20b803f38be96be8accdf2c3" + "reference": "aa075ce6f54fe931f03c1e382597912f4fd94e1e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/password-hasher/zipball/31fbe66af859582a20b803f38be96be8accdf2c3", - "reference": "31fbe66af859582a20b803f38be96be8accdf2c3", + "url": "https://api.github.com/repos/symfony/password-hasher/zipball/aa075ce6f54fe931f03c1e382597912f4fd94e1e", + "reference": "aa075ce6f54fe931f03c1e382597912f4fd94e1e", "shasum": "" }, "require": { @@ -5259,8 +5018,8 @@ "symfony/security-core": "<6.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0", - "symfony/security-core": "^6.4|^7.0" + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/security-core": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -5292,7 +5051,7 @@ "password" ], "support": { - "source": "https://github.com/symfony/password-hasher/tree/v7.3.0" + "source": "https://github.com/symfony/password-hasher/tree/v7.4.0" }, "funding": [ { @@ -5303,12 +5062,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-02-04T08:22:58+00:00" + "time": "2025-08-13T16:46:49+00:00" }, { "name": "symfony/polyfill-ctype", @@ -5817,17 +5580,97 @@ "time": "2025-01-02T08:10:11+00:00" }, { - "name": "symfony/polyfill-php83", + "name": "symfony/polyfill-php84", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-24T13:30:11+00:00" + }, + { + "name": "symfony/polyfill-php85", "version": "v1.33.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", "shasum": "" }, "require": { @@ -5845,7 +5688,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php83\\": "" + "Symfony\\Polyfill\\Php85\\": "" }, "classmap": [ "Resources/stubs" @@ -5865,7 +5708,7 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", @@ -5874,7 +5717,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" }, "funding": [ { @@ -5894,20 +5737,20 @@ "type": "tidelift" } ], - "time": "2025-07-08T02:45:35+00:00" + "time": "2025-06-23T16:12:55+00:00" }, { "name": "symfony/property-access", - "version": "v6.4.24", + "version": "v6.4.25", "source": { "type": "git", "url": "https://github.com/symfony/property-access.git", - "reference": "a33acdae7c76f837c1db5465cc3445adf3ace94a" + "reference": "fedc771326d4978a7d3167fa009a509b06a2e168" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-access/zipball/a33acdae7c76f837c1db5465cc3445adf3ace94a", - "reference": "a33acdae7c76f837c1db5465cc3445adf3ace94a", + "url": "https://api.github.com/repos/symfony/property-access/zipball/fedc771326d4978a7d3167fa009a509b06a2e168", + "reference": "fedc771326d4978a7d3167fa009a509b06a2e168", "shasum": "" }, "require": { @@ -5955,7 +5798,7 @@ "reflection" ], "support": { - "source": "https://github.com/symfony/property-access/tree/v6.4.24" + "source": "https://github.com/symfony/property-access/tree/v6.4.25" }, "funding": [ { @@ -5975,20 +5818,20 @@ "type": "tidelift" } ], - "time": "2025-07-15T12:03:16+00:00" + "time": "2025-08-12T15:42:57+00:00" }, { "name": "symfony/property-info", - "version": "v6.4.24", + "version": "v6.4.30", "source": { "type": "git", "url": "https://github.com/symfony/property-info.git", - "reference": "1056ae3621eeddd78d7c5ec074f1c1784324eec6" + "reference": "13243e748cb77b3d2300c0bffa21c2d325dd6e98" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/property-info/zipball/1056ae3621eeddd78d7c5ec074f1c1784324eec6", - "reference": "1056ae3621eeddd78d7c5ec074f1c1784324eec6", + "url": "https://api.github.com/repos/symfony/property-info/zipball/13243e748cb77b3d2300c0bffa21c2d325dd6e98", + "reference": "13243e748cb77b3d2300c0bffa21c2d325dd6e98", "shasum": "" }, "require": { @@ -6045,7 +5888,7 @@ "validator" ], "support": { - "source": "https://github.com/symfony/property-info/tree/v6.4.24" + "source": "https://github.com/symfony/property-info/tree/v6.4.30" }, "funding": [ { @@ -6065,20 +5908,20 @@ "type": "tidelift" } ], - "time": "2025-07-14T16:38:25+00:00" + "time": "2025-11-29T16:02:37+00:00" }, { "name": "symfony/routing", - "version": "v6.4.24", + "version": "v6.4.30", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "e4f94e625c8e6f910aa004a0042f7b2d398278f5" + "reference": "ea50a13c2711eebcbb66b38ef6382e62e3262859" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/e4f94e625c8e6f910aa004a0042f7b2d398278f5", - "reference": "e4f94e625c8e6f910aa004a0042f7b2d398278f5", + "url": "https://api.github.com/repos/symfony/routing/zipball/ea50a13c2711eebcbb66b38ef6382e62e3262859", + "reference": "ea50a13c2711eebcbb66b38ef6382e62e3262859", "shasum": "" }, "require": { @@ -6132,7 +5975,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v6.4.24" + "source": "https://github.com/symfony/routing/tree/v6.4.30" }, "funding": [ { @@ -6152,20 +5995,20 @@ "type": "tidelift" } ], - "time": "2025-07-15T08:46:37+00:00" + "time": "2025-11-22T09:51:35+00:00" }, { "name": "symfony/runtime", - "version": "v7.3.1", + "version": "v7.4.1", "source": { "type": "git", "url": "https://github.com/symfony/runtime.git", - "reference": "9516056d432f8acdac9458eb41b80097da7a05c9" + "reference": "876f902a6cb6b26c003de244188c06b2ba1c172f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/runtime/zipball/9516056d432f8acdac9458eb41b80097da7a05c9", - "reference": "9516056d432f8acdac9458eb41b80097da7a05c9", + "url": "https://api.github.com/repos/symfony/runtime/zipball/876f902a6cb6b26c003de244188c06b2ba1c172f", + "reference": "876f902a6cb6b26c003de244188c06b2ba1c172f", "shasum": "" }, "require": { @@ -6177,10 +6020,10 @@ }, "require-dev": { "composer/composer": "^2.6", - "symfony/console": "^6.4|^7.0", - "symfony/dotenv": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0" + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dotenv": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0" }, "type": "composer-plugin", "extra": { @@ -6215,7 +6058,7 @@ "runtime" ], "support": { - "source": "https://github.com/symfony/runtime/tree/v7.3.1" + "source": "https://github.com/symfony/runtime/tree/v7.4.1" }, "funding": [ { @@ -6226,32 +6069,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-06-13T07:48:40+00:00" + "time": "2025-12-05T14:04:53+00:00" }, { "name": "symfony/security-core", - "version": "v7.3.2", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/security-core.git", - "reference": "d8e1bb0de26266e2e4525beda0aed7f774e9c80d" + "reference": "fe4d25e5700a2f3b605bf23f520be57504ae5c51" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/security-core/zipball/d8e1bb0de26266e2e4525beda0aed7f774e9c80d", - "reference": "d8e1bb0de26266e2e4525beda0aed7f774e9c80d", + "url": "https://api.github.com/repos/symfony/security-core/zipball/fe4d25e5700a2f3b605bf23f520be57504ae5c51", + "reference": "fe4d25e5700a2f3b605bf23f520be57504ae5c51", "shasum": "" }, "require": { "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/event-dispatcher-contracts": "^2.5|^3", - "symfony/password-hasher": "^6.4|^7.0", + "symfony/password-hasher": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3" }, "conflict": { @@ -6266,15 +6113,15 @@ "psr/cache": "^1.0|^2.0|^3.0", "psr/container": "^1.1|^2.0", "psr/log": "^1|^2|^3", - "symfony/cache": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/ldap": "^6.4|^7.0", - "symfony/string": "^6.4|^7.0", - "symfony/translation": "^6.4.3|^7.0.3", - "symfony/validator": "^6.4|^7.0" + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/ldap": "^6.4|^7.0|^8.0", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4.3|^7.0.3|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -6302,7 +6149,7 @@ "description": "Symfony Security Component - Core Library", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/security-core/tree/v7.3.2" + "source": "https://github.com/symfony/security-core/tree/v7.4.0" }, "funding": [ { @@ -6322,7 +6169,7 @@ "type": "tidelift" } ], - "time": "2025-07-23T09:11:24+00:00" + "time": "2025-11-21T15:26:00+00:00" }, { "name": "symfony/security-csrf", @@ -6398,16 +6245,16 @@ }, { "name": "symfony/serializer", - "version": "v6.4.24", + "version": "v6.4.30", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "c01c719c8a837173dc100f2bd141a6271ea68a1d" + "reference": "d7976be554af097c788d7df25e10dd99facbfe65" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/c01c719c8a837173dc100f2bd141a6271ea68a1d", - "reference": "c01c719c8a837173dc100f2bd141a6271ea68a1d", + "url": "https://api.github.com/repos/symfony/serializer/zipball/d7976be554af097c788d7df25e10dd99facbfe65", + "reference": "d7976be554af097c788d7df25e10dd99facbfe65", "shasum": "" }, "require": { @@ -6476,7 +6323,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v6.4.24" + "source": "https://github.com/symfony/serializer/tree/v6.4.30" }, "funding": [ { @@ -6496,20 +6343,20 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:14:14+00:00" + "time": "2025-11-12T13:46:18+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.6.0", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", "shasum": "" }, "require": { @@ -6563,7 +6410,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" }, "funding": [ { @@ -6574,25 +6421,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-25T09:37:31+00:00" + "time": "2025-07-15T11:30:57+00:00" }, { "name": "symfony/stopwatch", - "version": "v7.3.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "5a49289e2b308214c8b9c2fda4ea454d8b8ad7cd" + "reference": "8a24af0a2e8a872fb745047180649b8418303084" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/5a49289e2b308214c8b9c2fda4ea454d8b8ad7cd", - "reference": "5a49289e2b308214c8b9c2fda4ea454d8b8ad7cd", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/8a24af0a2e8a872fb745047180649b8418303084", + "reference": "8a24af0a2e8a872fb745047180649b8418303084", "shasum": "" }, "require": { @@ -6625,7 +6476,7 @@ "description": "Provides a way to profile code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stopwatch/tree/v7.3.0" + "source": "https://github.com/symfony/stopwatch/tree/v7.4.0" }, "funding": [ { @@ -6636,31 +6487,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-02-24T10:49:57+00:00" + "time": "2025-08-04T07:05:15+00:00" }, { "name": "symfony/string", - "version": "v7.3.2", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca" + "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/42f505aff654e62ac7ac2ce21033818297ca89ca", - "reference": "42f505aff654e62ac7ac2ce21033818297ca89ca", + "url": "https://api.github.com/repos/symfony/string/zipball/d50e862cb0a0e0886f73ca1f31b865efbb795003", + "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-grapheme": "~1.33", "symfony/polyfill-intl-normalizer": "~1.0", "symfony/polyfill-mbstring": "~1.0" }, @@ -6668,12 +6524,11 @@ "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.1", - "symfony/error-handler": "^6.4|^7.0", - "symfony/http-client": "^6.4|^7.0", - "symfony/intl": "^6.4|^7.0", + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0" + "symfony/var-exporter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -6712,7 +6567,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.3.2" + "source": "https://github.com/symfony/string/tree/v7.4.0" }, "funding": [ { @@ -6732,20 +6587,20 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:47:49+00:00" + "time": "2025-11-27T13:27:24+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.6.0", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d" + "reference": "65a8bc82080447fae78373aa10f8d13b38338977" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d", - "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977", "shasum": "" }, "require": { @@ -6794,7 +6649,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" }, "funding": [ { @@ -6805,25 +6660,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-27T08:32:26+00:00" + "time": "2025-07-15T13:41:35+00:00" }, { "name": "symfony/twig-bridge", - "version": "v6.4.24", + "version": "v6.4.30", "source": { "type": "git", "url": "https://github.com/symfony/twig-bridge.git", - "reference": "af9ef04e348f93410c83d04d2806103689a3d924" + "reference": "d77a78c7fffaf7cb0158d28db824ba78d89a9f34" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/af9ef04e348f93410c83d04d2806103689a3d924", - "reference": "af9ef04e348f93410c83d04d2806103689a3d924", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/d77a78c7fffaf7cb0158d28db824ba78d89a9f34", + "reference": "d77a78c7fffaf7cb0158d28db824ba78d89a9f34", "shasum": "" }, "require": { @@ -6836,7 +6695,7 @@ "phpdocumentor/reflection-docblock": "<3.2.2", "phpdocumentor/type-resolver": "<1.4.0", "symfony/console": "<5.4", - "symfony/form": "<6.3", + "symfony/form": "<6.4", "symfony/http-foundation": "<5.4", "symfony/http-kernel": "<6.4", "symfony/mime": "<6.2", @@ -6854,7 +6713,7 @@ "symfony/dependency-injection": "^5.4|^6.0|^7.0", "symfony/expression-language": "^5.4|^6.0|^7.0", "symfony/finder": "^5.4|^6.0|^7.0", - "symfony/form": "^6.4.20|^7.2.5", + "symfony/form": "^6.4.30|~7.3.8|^7.4.1", "symfony/html-sanitizer": "^6.1|^7.0", "symfony/http-foundation": "^5.4|^6.0|^7.0", "symfony/http-kernel": "^6.4|^7.0", @@ -6903,7 +6762,7 @@ "description": "Provides integration for Twig with various Symfony components", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/twig-bridge/tree/v6.4.24" + "source": "https://github.com/symfony/twig-bridge/tree/v6.4.30" }, "funding": [ { @@ -6923,7 +6782,7 @@ "type": "tidelift" } ], - "time": "2025-07-26T12:47:35+00:00" + "time": "2025-12-05T13:01:31+00:00" }, { "name": "symfony/twig-bundle", @@ -7015,16 +6874,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.3.2", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "53205bea27450dc5c65377518b3275e126d45e75" + "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/53205bea27450dc5c65377518b3275e126d45e75", - "reference": "53205bea27450dc5c65377518b3275e126d45e75", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/41fd6c4ae28c38b294b42af6db61446594a0dece", + "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece", "shasum": "" }, "require": { @@ -7036,10 +6895,10 @@ "symfony/console": "<6.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/uid": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", "twig/twig": "^3.12" }, "bin": [ @@ -7078,7 +6937,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.3.2" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.0" }, "funding": [ { @@ -7098,20 +6957,20 @@ "type": "tidelift" } ], - "time": "2025-07-29T20:02:46+00:00" + "time": "2025-10-27T20:36:44+00:00" }, { "name": "symfony/var-exporter", - "version": "v7.3.2", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "05b3e90654c097817325d6abd284f7938b05f467" + "reference": "03a60f169c79a28513a78c967316fbc8bf17816f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/05b3e90654c097817325d6abd284f7938b05f467", - "reference": "05b3e90654c097817325d6abd284f7938b05f467", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/03a60f169c79a28513a78c967316fbc8bf17816f", + "reference": "03a60f169c79a28513a78c967316fbc8bf17816f", "shasum": "" }, "require": { @@ -7119,9 +6978,9 @@ "symfony/deprecation-contracts": "^2.5|^3" }, "require-dev": { - "symfony/property-access": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0" + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -7159,7 +7018,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v7.3.2" + "source": "https://github.com/symfony/var-exporter/tree/v7.4.0" }, "funding": [ { @@ -7179,20 +7038,20 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:47:49+00:00" + "time": "2025-09-11T10:15:23+00:00" }, { "name": "symfony/web-profiler-bundle", - "version": "v6.4.24", + "version": "v6.4.27", "source": { "type": "git", "url": "https://github.com/symfony/web-profiler-bundle.git", - "reference": "ae16f886ab3e3ed0a8db07d2a7c4d9d60b1eafcd" + "reference": "4c2ab411372e8bd854678cd7c81f1a9bfd6914aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/ae16f886ab3e3ed0a8db07d2a7c4d9d60b1eafcd", - "reference": "ae16f886ab3e3ed0a8db07d2a7c4d9d60b1eafcd", + "url": "https://api.github.com/repos/symfony/web-profiler-bundle/zipball/4c2ab411372e8bd854678cd7c81f1a9bfd6914aa", + "reference": "4c2ab411372e8bd854678cd7c81f1a9bfd6914aa", "shasum": "" }, "require": { @@ -7245,7 +7104,7 @@ "dev" ], "support": { - "source": "https://github.com/symfony/web-profiler-bundle/tree/v6.4.24" + "source": "https://github.com/symfony/web-profiler-bundle/tree/v6.4.27" }, "funding": [ { @@ -7265,7 +7124,7 @@ "type": "tidelift" } ], - "time": "2025-07-20T15:15:57+00:00" + "time": "2025-10-05T13:55:43+00:00" }, { "name": "symfony/webpack-encore-bundle", @@ -7342,16 +7201,16 @@ }, { "name": "symfony/yaml", - "version": "v6.4.24", + "version": "v6.4.30", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "742a8efc94027624b36b10ba58e23d402f961f51" + "reference": "8207ae83da19ee3748d6d4f567b4d9a7c656e331" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/742a8efc94027624b36b10ba58e23d402f961f51", - "reference": "742a8efc94027624b36b10ba58e23d402f961f51", + "url": "https://api.github.com/repos/symfony/yaml/zipball/8207ae83da19ee3748d6d4f567b4d9a7c656e331", + "reference": "8207ae83da19ee3748d6d4f567b4d9a7c656e331", "shasum": "" }, "require": { @@ -7394,7 +7253,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v6.4.24" + "source": "https://github.com/symfony/yaml/tree/v6.4.30" }, "funding": [ { @@ -7414,20 +7273,20 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:14:14+00:00" + "time": "2025-12-02T11:50:18+00:00" }, { "name": "twig/twig", - "version": "v3.21.1", + "version": "v3.22.2", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "285123877d4dd97dd7c11842ac5fb7e86e60d81d" + "reference": "946ddeafa3c9f4ce279d1f34051af041db0e16f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/285123877d4dd97dd7c11842ac5fb7e86e60d81d", - "reference": "285123877d4dd97dd7c11842ac5fb7e86e60d81d", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/946ddeafa3c9f4ce279d1f34051af041db0e16f2", + "reference": "946ddeafa3c9f4ce279d1f34051af041db0e16f2", "shasum": "" }, "require": { @@ -7481,7 +7340,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.21.1" + "source": "https://github.com/twigphp/Twig/tree/v3.22.2" }, "funding": [ { @@ -7493,37 +7352,37 @@ "type": "tidelift" } ], - "time": "2025-05-03T07:21:55+00:00" + "time": "2025-12-14T11:28:47+00:00" }, { "name": "webmozart/assert", - "version": "1.11.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" + "reference": "1b34b004e35a164bc5bb6ebd33c844b2d8069a54" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", - "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/1b34b004e35a164bc5bb6ebd33c844b2d8069a54", + "reference": "1b34b004e35a164bc5bb6ebd33c844b2d8069a54", "shasum": "" }, "require": { "ext-ctype": "*", - "php": "^7.2 || ^8.0" - }, - "conflict": { - "phpstan/phpstan": "<0.12.20", - "vimeo/psalm": "<4.6.1 || 4.6.2" + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" }, - "require-dev": { - "phpunit/phpunit": "^8.5.13" + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.10-dev" + "dev-feature/2-0": "2.0-dev" } }, "autoload": { @@ -7539,6 +7398,10 @@ { "name": "Bernhard Schussek", "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" } ], "description": "Assertions to validate method input/output with nice error messages.", @@ -7549,9 +7412,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.11.0" + "source": "https://github.com/webmozarts/assert/tree/2.0.0" }, - "time": "2022-06-03T18:03:27+00:00" + "time": "2025-12-16T21:36:00+00:00" }, { "name": "wikimedia/base-convert", @@ -7658,16 +7521,16 @@ }, { "name": "zircote/swagger-php", - "version": "5.3.1", + "version": "5.4.2", "source": { "type": "git", "url": "https://github.com/zircote/swagger-php.git", - "reference": "e174ef759a934c337209dc41c7490919c2362df8" + "reference": "4f6bac8bdb9e762c6a4de12ef62160d4e5a17caa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zircote/swagger-php/zipball/e174ef759a934c337209dc41c7490919c2362df8", - "reference": "e174ef759a934c337209dc41c7490919c2362df8", + "url": "https://api.github.com/repos/zircote/swagger-php/zipball/4f6bac8bdb9e762c6a4de12ef62160d4e5a17caa", + "reference": "4f6bac8bdb9e762c6a4de12ef62160d4e5a17caa", "shasum": "" }, "require": { @@ -7738,39 +7601,43 @@ ], "support": { "issues": "https://github.com/zircote/swagger-php/issues", - "source": "https://github.com/zircote/swagger-php/tree/5.3.1" + "source": "https://github.com/zircote/swagger-php/tree/5.4.2" }, - "time": "2025-08-16T22:59:55+00:00" + "time": "2025-10-09T01:32:43+00:00" } ], "packages-dev": [ { - "name": "dms/phpunit-arraysubset-asserts", - "version": "v0.4.0", + "name": "composer/semver", + "version": "3.4.4", "source": { "type": "git", - "url": "https://github.com/rdohms/phpunit-arraysubset-asserts.git", - "reference": "428293c2a00eceefbad71a2dbdfb913febb35de2" + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rdohms/phpunit-arraysubset-asserts/zipball/428293c2a00eceefbad71a2dbdfb913febb35de2", - "reference": "428293c2a00eceefbad71a2dbdfb913febb35de2", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", "shasum": "" }, "require": { - "php": "^5.4 || ^7.0 || ^8.0", - "phpunit/phpunit": "^4.8.36 || ^5.7.21 || ^6.0 || ^7.0 || ^8.0 || ^9.0" + "php": "^5.3.2 || ^7.0 || ^8.0" }, "require-dev": { - "dms/coding-standard": "^9", - "squizlabs/php_codesniffer": "^3.4" + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, "autoload": { - "files": [ - "assertarraysubset-autoload.php" - ] + "psr-4": { + "Composer\\Semver\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -7778,93 +7645,394 @@ ], "authors": [ { - "name": "Rafael Dohms", - "email": "rdohms@gmail.com" + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" } ], - "description": "This package provides ArraySubset and related asserts once deprecated in PHPUnit 8", + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], "support": { - "issues": "https://github.com/rdohms/phpunit-arraysubset-asserts/issues", - "source": "https://github.com/rdohms/phpunit-arraysubset-asserts/tree/v0.4.0" + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" }, - "time": "2022-02-13T15:00:28+00:00" + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" }, { - "name": "mediawiki/minus-x", - "version": "1.1.3", + "name": "composer/spdx-licenses", + "version": "1.5.9", "source": { "type": "git", - "url": "https://github.com/wikimedia/mediawiki-tools-minus-x.git", - "reference": "553f920ad53f78b33ea654f8623c2a50b5ac7efd" + "url": "https://github.com/composer/spdx-licenses.git", + "reference": "edf364cefe8c43501e21e88110aac10b284c3c9f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wikimedia/mediawiki-tools-minus-x/zipball/553f920ad53f78b33ea654f8623c2a50b5ac7efd", - "reference": "553f920ad53f78b33ea654f8623c2a50b5ac7efd", + "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/edf364cefe8c43501e21e88110aac10b284c3c9f", + "reference": "edf364cefe8c43501e21e88110aac10b284c3c9f", "shasum": "" }, "require": { - "php": ">=7.2.9", - "symfony/console": "^3.3.5 || ^4 || ^5 || ^6 || ^7" + "php": "^5.3.2 || ^7.0 || ^8.0" }, "require-dev": { - "mediawiki/mediawiki-codesniffer": "43.0.0", - "php-parallel-lint/php-console-highlighter": "1.0.0", - "php-parallel-lint/php-parallel-lint": "1.3.2" + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" }, - "bin": [ - "bin/minus-x" - ], "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, "autoload": { "psr-4": { - "MediaWiki\\MinusX\\": "src/" + "Composer\\Spdx\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "GPL-3.0-or-later" + "MIT" ], "authors": [ { - "name": "Kunal Mehta", - "email": "legoktm@member.fsf.org" + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" } ], - "description": "Removes executable bit from files that shouldn't be executable", - "homepage": "https://www.mediawiki.org/wiki/MinusX", + "description": "SPDX licenses list and validation library.", + "keywords": [ + "license", + "spdx", + "validator" + ], "support": { - "source": "https://github.com/wikimedia/mediawiki-tools-minus-x/tree/1.1.3" + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/spdx-licenses/issues", + "source": "https://github.com/composer/spdx-licenses/tree/1.5.9" }, - "time": "2024-05-04T16:06:11+00:00" + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2025-05-12T21:07:07+00:00" }, { - "name": "myclabs/deep-copy", - "version": "1.13.4", + "name": "dealerdirect/phpcodesniffer-composer-installer", + "version": "v1.2.0", "source": { "type": "git", - "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + "url": "https://github.com/PHPCSStandards/composer-installer.git", + "reference": "845eb62303d2ca9b289ef216356568ccc075ffd1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", - "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/845eb62303d2ca9b289ef216356568ccc075ffd1", + "reference": "845eb62303d2ca9b289ef216356568ccc075ffd1", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" - }, - "conflict": { - "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3 <3.2.2" + "composer-plugin-api": "^2.2", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.1.0 || ^4.0" }, "require-dev": { - "doctrine/collections": "^1.6.8", - "doctrine/common": "^2.13.3 || ^3.2.2", - "phpspec/prophecy": "^1.10", - "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" - }, + "composer/composer": "^2.2", + "ext-json": "*", + "ext-zip": "*", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^9.0 || ^10.0.0@dev", + "yoast/phpunit-polyfills": "^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Franck Nijhof", + "email": "opensource@frenck.dev", + "homepage": "https://frenck.dev", + "role": "Open source developer" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer Standards Composer Installer Plugin", + "keywords": [ + "PHPCodeSniffer", + "PHP_CodeSniffer", + "code quality", + "codesniffer", + "composer", + "installer", + "phpcbf", + "phpcs", + "plugin", + "qa", + "quality", + "standard", + "standards", + "style guide", + "stylecheck", + "tests" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/composer-installer/issues", + "security": "https://github.com/PHPCSStandards/composer-installer/security/policy", + "source": "https://github.com/PHPCSStandards/composer-installer" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-11T04:32:07+00:00" + }, + { + "name": "dms/phpunit-arraysubset-asserts", + "version": "v0.4.0", + "source": { + "type": "git", + "url": "https://github.com/rdohms/phpunit-arraysubset-asserts.git", + "reference": "428293c2a00eceefbad71a2dbdfb913febb35de2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/rdohms/phpunit-arraysubset-asserts/zipball/428293c2a00eceefbad71a2dbdfb913febb35de2", + "reference": "428293c2a00eceefbad71a2dbdfb913febb35de2", + "shasum": "" + }, + "require": { + "php": "^5.4 || ^7.0 || ^8.0", + "phpunit/phpunit": "^4.8.36 || ^5.7.21 || ^6.0 || ^7.0 || ^8.0 || ^9.0" + }, + "require-dev": { + "dms/coding-standard": "^9", + "squizlabs/php_codesniffer": "^3.4" + }, + "type": "library", + "autoload": { + "files": [ + "assertarraysubset-autoload.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Rafael Dohms", + "email": "rdohms@gmail.com" + } + ], + "description": "This package provides ArraySubset and related asserts once deprecated in PHPUnit 8", + "support": { + "issues": "https://github.com/rdohms/phpunit-arraysubset-asserts/issues", + "source": "https://github.com/rdohms/phpunit-arraysubset-asserts/tree/v0.4.0" + }, + "time": "2022-02-13T15:00:28+00:00" + }, + { + "name": "mediawiki/mediawiki-codesniffer", + "version": "v48.0.0", + "source": { + "type": "git", + "url": "https://github.com/wikimedia/mediawiki-tools-codesniffer.git", + "reference": "6d46ca2334d5e1c5be10bf28e01f6010cfbff212" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wikimedia/mediawiki-tools-codesniffer/zipball/6d46ca2334d5e1c5be10bf28e01f6010cfbff212", + "reference": "6d46ca2334d5e1c5be10bf28e01f6010cfbff212", + "shasum": "" + }, + "require": { + "composer/semver": "^3.4.2", + "composer/spdx-licenses": "~1.5.2", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=8.1.0", + "phpcsstandards/phpcsextra": "1.4.0", + "squizlabs/php_codesniffer": "3.13.2" + }, + "require-dev": { + "ext-dom": "*", + "mediawiki/mediawiki-phan-config": "0.17.0", + "mediawiki/minus-x": "1.1.3", + "php-parallel-lint/php-console-highlighter": "1.0.0", + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpunit/phpunit": "9.6.21" + }, + "type": "phpcodesniffer-standard", + "autoload": { + "psr-4": { + "MediaWiki\\Sniffs\\": "MediaWiki/Sniffs/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "description": "MediaWiki CodeSniffer Standards", + "homepage": "https://www.mediawiki.org/wiki/Manual:Coding_conventions/PHP", + "keywords": [ + "codesniffer", + "mediawiki" + ], + "support": { + "source": "https://github.com/wikimedia/mediawiki-tools-codesniffer/tree/v48.0.0" + }, + "time": "2025-09-04T20:12:57+00:00" + }, + { + "name": "mediawiki/minus-x", + "version": "1.1.3", + "source": { + "type": "git", + "url": "https://github.com/wikimedia/mediawiki-tools-minus-x.git", + "reference": "553f920ad53f78b33ea654f8623c2a50b5ac7efd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wikimedia/mediawiki-tools-minus-x/zipball/553f920ad53f78b33ea654f8623c2a50b5ac7efd", + "reference": "553f920ad53f78b33ea654f8623c2a50b5ac7efd", + "shasum": "" + }, + "require": { + "php": ">=7.2.9", + "symfony/console": "^3.3.5 || ^4 || ^5 || ^6 || ^7" + }, + "require-dev": { + "mediawiki/mediawiki-codesniffer": "43.0.0", + "php-parallel-lint/php-console-highlighter": "1.0.0", + "php-parallel-lint/php-parallel-lint": "1.3.2" + }, + "bin": [ + "bin/minus-x" + ], + "type": "library", + "autoload": { + "psr-4": { + "MediaWiki\\MinusX\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-3.0-or-later" + ], + "authors": [ + { + "name": "Kunal Mehta", + "email": "legoktm@member.fsf.org" + } + ], + "description": "Removes executable bit from files that shouldn't be executable", + "homepage": "https://www.mediawiki.org/wiki/MinusX", + "support": { + "source": "https://github.com/wikimedia/mediawiki-tools-minus-x/tree/1.1.3" + }, + "time": "2024-05-04T16:06:11+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, "type": "library", "autoload": { "files": [ @@ -8016,6 +8184,181 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpcsstandards/phpcsextra", + "version": "1.4.0", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", + "reference": "fa4b8d051e278072928e32d817456a7fdb57b6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/fa4b8d051e278072928e32d817456a7fdb57b6ca", + "reference": "fa4b8d051e278072928e32d817456a7fdb57b6ca", + "shasum": "" + }, + "require": { + "php": ">=5.4", + "phpcsstandards/phpcsutils": "^1.1.0", + "squizlabs/php_codesniffer": "^3.13.0 || ^4.0" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.1.6", + "phpcsstandards/phpcsdevtools": "^1.2.1", + "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-stable": "1.x-dev", + "dev-develop": "1.x-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHPCSExtra/graphs/contributors" + } + ], + "description": "A collection of sniffs and standards for use with PHP_CodeSniffer.", + "keywords": [ + "PHP_CodeSniffer", + "phpcbf", + "phpcodesniffer-standard", + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHPCSExtra/issues", + "security": "https://github.com/PHPCSStandards/PHPCSExtra/security/policy", + "source": "https://github.com/PHPCSStandards/PHPCSExtra" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-06-14T07:40:39+00:00" + }, + { + "name": "phpcsstandards/phpcsutils", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHPCSUtils.git", + "reference": "f7eb16f2fa4237d5db9e8fed8050239bee17a9bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/f7eb16f2fa4237d5db9e8fed8050239bee17a9bd", + "reference": "f7eb16f2fa4237d5db9e8fed8050239bee17a9bd", + "shasum": "" + }, + "require": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.13.0 || ^4.0" + }, + "require-dev": { + "ext-filter": "*", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.1.6", + "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0 || ^3.0.0" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-stable": "1.x-dev", + "dev-develop": "1.x-dev" + } + }, + "autoload": { + "classmap": [ + "PHPCSUtils/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHPCSUtils/graphs/contributors" + } + ], + "description": "A suite of utility functions for use with PHP_CodeSniffer", + "homepage": "https://phpcsutils.com/", + "keywords": [ + "PHP_CodeSniffer", + "phpcbf", + "phpcodesniffer-standard", + "phpcs", + "phpcs3", + "phpcs4", + "standards", + "static analysis", + "tokens", + "utility" + ], + "support": { + "docs": "https://phpcsutils.com/", + "issues": "https://github.com/PHPCSStandards/PHPCSUtils/issues", + "security": "https://github.com/PHPCSStandards/PHPCSUtils/security/policy", + "source": "https://github.com/PHPCSStandards/PHPCSUtils" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-08-10T01:04:45+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "9.2.32", @@ -8337,16 +8680,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.24", + "version": "9.6.31", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "ea49afa29aeea25ea7bf9de9fdd7cab163cc0701" + "reference": "945d0b7f346a084ce5549e95289962972c4272e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ea49afa29aeea25ea7bf9de9fdd7cab163cc0701", - "reference": "ea49afa29aeea25ea7bf9de9fdd7cab163cc0701", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/945d0b7f346a084ce5549e95289962972c4272e5", + "reference": "945d0b7f346a084ce5549e95289962972c4272e5", "shasum": "" }, "require": { @@ -8371,7 +8714,7 @@ "sebastian/comparator": "^4.0.9", "sebastian/diff": "^4.0.6", "sebastian/environment": "^5.1.5", - "sebastian/exporter": "^4.0.6", + "sebastian/exporter": "^4.0.8", "sebastian/global-state": "^5.0.8", "sebastian/object-enumerator": "^4.0.4", "sebastian/resource-operations": "^3.0.4", @@ -8420,7 +8763,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.24" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.31" }, "funding": [ { @@ -8444,7 +8787,7 @@ "type": "tidelift" } ], - "time": "2025-08-10T08:32:42+00:00" + "time": "2025-12-06T07:45:52+00:00" }, { "name": "sebastian/cli-parser", @@ -8887,16 +9230,16 @@ }, { "name": "sebastian/exporter", - "version": "4.0.6", + "version": "4.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72" + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c", "shasum": "" }, "require": { @@ -8952,15 +9295,27 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6" + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2024-03-02T06:33:00+00:00" + "time": "2025-09-24T06:03:27+00:00" }, { "name": "sebastian/global-state", @@ -9445,18 +9800,102 @@ ], "time": "2020-09-28T06:39:44+00:00" }, + { + "name": "squizlabs/php_codesniffer", + "version": "3.13.2", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "5b5e3821314f947dd040c70f7992a64eac89025c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/5b5e3821314f947dd040c70f7992a64eac89025c", + "reference": "5b5e3821314f947dd040c70f7992a64eac89025c", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "bin": [ + "bin/phpcbf", + "bin/phpcs" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-06-17T22:17:01+00:00" + }, { "name": "symfony/browser-kit", - "version": "v6.4.24", + "version": "v6.4.28", "source": { "type": "git", "url": "https://github.com/symfony/browser-kit.git", - "reference": "3537d17782f8c20795b194acb6859071b60c6fac" + "reference": "067e301786bbb58048077fc10507aceb18226e23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/3537d17782f8c20795b194acb6859071b60c6fac", - "reference": "3537d17782f8c20795b194acb6859071b60c6fac", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/067e301786bbb58048077fc10507aceb18226e23", + "reference": "067e301786bbb58048077fc10507aceb18226e23", "shasum": "" }, "require": { @@ -9495,7 +9934,7 @@ "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/browser-kit/tree/v6.4.24" + "source": "https://github.com/symfony/browser-kit/tree/v6.4.28" }, "funding": [ { @@ -9515,20 +9954,20 @@ "type": "tidelift" } ], - "time": "2025-07-10T08:14:14+00:00" + "time": "2025-10-16T22:35:35+00:00" }, { "name": "symfony/phpunit-bridge", - "version": "v6.4.24", + "version": "v6.4.26", "source": { "type": "git", "url": "https://github.com/symfony/phpunit-bridge.git", - "reference": "c7bd97db095cb2f560b675e3fa0ae5ca6a2e5f59" + "reference": "406aa80401bf960e7a173a3ccf268ae82b6bc93f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/c7bd97db095cb2f560b675e3fa0ae5ca6a2e5f59", - "reference": "c7bd97db095cb2f560b675e3fa0ae5ca6a2e5f59", + "url": "https://api.github.com/repos/symfony/phpunit-bridge/zipball/406aa80401bf960e7a173a3ccf268ae82b6bc93f", + "reference": "406aa80401bf960e7a173a3ccf268ae82b6bc93f", "shasum": "" }, "require": { @@ -9584,7 +10023,7 @@ "testing" ], "support": { - "source": "https://github.com/symfony/phpunit-bridge/tree/v6.4.24" + "source": "https://github.com/symfony/phpunit-bridge/tree/v6.4.26" }, "funding": [ { @@ -9604,20 +10043,20 @@ "type": "tidelift" } ], - "time": "2025-07-24T11:44:59+00:00" + "time": "2025-09-12T08:37:02+00:00" }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { @@ -9646,7 +10085,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -9654,7 +10093,7 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-11-17T20:03:58+00:00" } ], "aliases": [], diff --git a/config/bundles.php b/config/bundles.php index 4fd3b1f9d..f11faa28d 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -1,17 +1,17 @@ ['all' => true], - Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], - Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], - Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], - Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], - Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], - EightPoints\Bundle\GuzzleBundle\EightPointsGuzzleBundle::class => ['all' => true], - JMS\SerializerBundle\JMSSerializerBundle::class => ['all' => true], - Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], - Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], - Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true], + Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => [ 'all' => true ], + Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => [ 'all' => true ], + Symfony\Bundle\TwigBundle\TwigBundle::class => [ 'all' => true ], + Symfony\Bundle\MonologBundle\MonologBundle::class => [ 'all' => true ], + Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => [ 'dev' => true, 'test' => true ], + Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => [ 'all' => true ], + EightPoints\Bundle\GuzzleBundle\EightPointsGuzzleBundle::class => [ 'all' => true ], + JMS\SerializerBundle\JMSSerializerBundle::class => [ 'all' => true ], + Nelmio\CorsBundle\NelmioCorsBundle::class => [ 'all' => true ], + Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => [ 'all' => true ], + Nelmio\ApiDocBundle\NelmioApiDocBundle::class => [ 'all' => true ], ]; diff --git a/config/packages/nelmio_api_doc.yaml b/config/packages/nelmio_api_doc.yaml index bf443245c..a185cccb3 100644 --- a/config/packages/nelmio_api_doc.yaml +++ b/config/packages/nelmio_api_doc.yaml @@ -447,4 +447,4 @@ nelmio_api_doc: areas: # to filter documented areas path_patterns: - - ^/api(\.json$|\/)(?!project/parser|page/articleinfo) + - ^/api(\.json$|\/)(?!project/parser|page/articleinfo|pages/deletion_summary) diff --git a/config/preload.php b/config/preload.php index 234fbcc21..6ded82183 100644 --- a/config/preload.php +++ b/config/preload.php @@ -1,7 +1,7 @@ - - . - vendor/ - public/ - migrations/ - var/ - node_modules/ - assets/vendor/ - *.min.js - bootstrap.php - bin/.phpunit/ + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + . + vendor/ + public/ + migrations/ + var/ + node_modules/ + assets/vendor/ + *.min.js + bootstrap.php + bin/.phpunit/ - - - - - - - src/Kernel.php - - - src/Kernel.php - + + + + + + + src/Kernel.php + + + src/Kernel.php + diff --git a/public/build/app.9cc563c1.js b/public/build/app.9cc563c1.js new file mode 100644 index 000000000..9ed83c579 --- /dev/null +++ b/public/build/app.9cc563c1.js @@ -0,0 +1,2 @@ +/*! For license information please see app.9cc563c1.js.LICENSE.txt */ +(self.webpackChunkxtools=self.webpackChunkxtools||[]).push([[524],{3441:()=>{xtools.adminstats={},$((function(){var t=$("#project_input"),e=t.val();0!==$("body.adminstats, body.patrollerstats, body.stewardstats").length&&(xtools.application.setupMultiSelectListeners(),$(".group-selector").on("change",(function(){$(".action-selector").addClass("hidden"),$(".action-selector--"+$(this).val()).removeClass("hidden"),$(".xt-page-title--title").text($.i18n("tool-"+$(this).val()+"stats")),$(".xt-page-title--desc").text($.i18n("tool-"+$(this).val()+"stats-desc"));var n=$.i18n("tool-"+$(this).val()+"stats")+" - "+$.i18n("xtools-title");document.title=n,history.replaceState({},n,"/"+$(this).val()+"stats"),"steward"===$(this).val()?(e=t.val(),t.val("meta.wikimedia.org")):t.val(e),xtools.application.setupMultiSelectListeners()})))}))},9654:(t,e,n)=>{n(8636),n(5086),$((function(){if($("body.authorship").length){var t=$("#show_selector");t.on("change",(function(t){$(".show-option").addClass("hidden").find("input").prop("disabled",!0),$(".show - option--".concat(t.target.value)).removeClass("hidden").find("input").prop("disabled",!1)})),window.onload=function(){return t.trigger("change")}}}))},5611:(t,e,n)=>{n(8476),n(5086),n(8379),n(7899),n(2231),n(115),xtools.autoedits={},$((function(){if($("body.autoedits").length){var t=$(".contributions-container"),e=$("#tool_selector");if(e.length)return xtools.autoedits.fetchTools=function(t){e.prop("disabled",!0),$.get("/api/project/automated_tools/"+t).done((function(t){t.error||(delete t.project,delete t.elapsed_time,e.html('"),Object.keys(t).forEach((function(n){e.append('")}))),e.prop("disabled",!1)}))},$(document).ready((function(){$("#project_input").on("change.autoedits",(function(){xtools.autoedits.fetchTools($("#project_input").val())}))})),void xtools.autoedits.fetchTools($("#project_input").val());if(xtools.application.setupToggleTable(window.countsByTool,window.toolsChart,"count",(function(t){var e=0;Object.keys(t).forEach((function(n){e+=parseInt(t[n].count,10)}));var n=Object.keys(t).length;$(".tools--tools").text(n.toLocaleString(i18nLang)+" "+$.i18n("num-tools",n)),$(".tools--count").text(e.toLocaleString(i18nLang))})),t.length){var n=$(".contributions-table").length?"setupContributionsNavListeners":"loadContributions";xtools.application[n]((function(t){return"".concat(t.target," - contributions / ").concat(t.project," / ").concat(t.username)+" / ".concat(t.namespace," / ").concat(t.start," / ").concat(t.end)}),t.data("target"))}}}))},3600:(t,e,n)=>{n(7136),n(173),n(9073),n(6048),n(8636),n(5086),xtools.blame={},$((function(){if($("body.blame").length){$(".diff-empty").length===$(".diff tr").length-1&&$(".diff-empty").eq(0).text("(".concat($.i18n("diff-empty").toLowerCase(),")")).addClass("text-muted text-center").prop("width","20%"),$(".diff-addedline").each((function(){var t=xtools.blame.query.replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&"),e=function(e){var n=new RegExp("(".concat(t,")"),"gi");$(e).html($(e).html().replace(n," < strong > $1 < / strong > "))};$(this).find(".diffchange-inline").length?$(".diffchange-inline").each((function(){e(this)})):e(this)}));var t=$("#show_selector");t.on("change",(function(t){$(".show-option").addClass("hidden").find("input").prop("disabled",!0),$(".show - option--".concat(t.target.value)).removeClass("hidden").find("input").prop("disabled",!1)})),window.onload=function(){return t.trigger("change")}}}))},514:(t,e,n)=>{function a(t,e){xtools.categoryedits.$select2Input.data("select2")&&(xtools.categoryedits.$select2Input.off("change"),xtools.categoryedits.$select2Input.select2("val",null),xtools.categoryedits.$select2Input.select2("data",null),xtools.categoryedits.$select2Input.select2("destroy"));var n=e||xtools.categoryedits.$select2Input.data("ns"),a={ajax:{url:t||xtools.categoryedits.$select2Input.data("api"),dataType:"jsonp",jsonpCallback:"categorySuggestionCallback",delay:200,data:function(t){return{action:"query",list:"prefixsearch",format:"json",pssearch:t.term||"",psnamespace:14,cirrusUseCompletionSuggester:"yes"}},processResults:function(t){var e=t?t.query:{},a=[];return e&&e.prefixsearch.length&&(a=e.prefixsearch.map((function(t){var e=t.title.replace(new RegExp("^"+n+":"),"");return{id:e.replace(/ /g,"_"),text:e}}))),{results:a}}},placeholder:$.i18n("category-search"),maximumSelectionLength:10,minimumInputLength:1};xtools.categoryedits.$select2Input.select2(a)}n(475),n(8476),n(5086),n(8379),n(7899),n(2231),n(9581),n(7136),n(173),n(9073),n(6048),xtools.categoryedits={},$((function(){$("body.categoryedits").length&&$(document).ready((function(){var t;xtools.categoryedits.$select2Input=$("#category_selector"),a(),$("#project_input").on("xtools.projectLoaded",(function(t,e){$.get(xtBaseUrl+"api/project/namespaces/"+e.project).done((function(t){a(t.api,t.namespaces[14])}))})),$("form").on("submit",(function(){$("#category_input").val(xtools.categoryedits.$select2Input.val().join("|"))})),xtools.application.setupToggleTable(window.countsByCategory,window.categoryChart,"editCount",(function(t){var e=0,n=0;Object.keys(t).forEach((function(a){e+=parseInt(t[a].editCount,10),n+=parseInt(t[a].pageCount,10)}));var a=Object.keys(t).length;$(".category--category").text(a.toLocaleString(i18nLang)+" "+$.i18n("num-categories",a)),$(".category--count").text(e.toLocaleString(i18nLang)),$(".category--percent-of-edit-count").text(100*(e/xtools.categoryedits.userEditCount).toLocaleString(i18nLang)+"%"),$(".category--pages").text(n.toLocaleString(i18nLang))})),$(".contributions-container").length&&(t=$(".contributions-table").length?"setupContributionsNavListeners":"loadContributions",xtools.application[t]((function(t){return"categoryedits-contributions/"+t.project+"/"+t.username+"/"+t.categories+"/"+t.start+"/"+t.end}),"Category"))}))}))},5779:(t,e,n)=>{function a(t){$("#project_input").val(xtools.application.vars.lastProject),$(".site-notice").append("")}function o(){var t=$("#page_input"),e=$("#user_input"),n=$("#namespace_select");if(t[0]||e[0]||$("#project_input")[0]){t.data("typeahead")&&t.data("typeahead").destroy(),e.data("typeahead")&&e.data("typeahead").destroy(),xtools.application.vars.apiPath||(xtools.application.vars.apiPath=$("#page_input").data("api")||$("#user_input").data("api"));var a={url:xtools.application.vars.apiPath,timeout:200,triggerLength:1,method:"get",preDispatch:null,preProcess:null};t[0]&&t.typeahead({ajax:Object.assign(a,{preDispatch:function(t){n[0]&&"0"!==n.val()&&(t=n.find("option:selected").text().trim()+":"+t);return{action:"query",list:"prefixsearch",format:"json",pssearch:t}},preProcess:function(t){var e="";return n[0]&&"0"!==n.val()&&(e=n.find("option:selected").text().trim()),t.query.prefixsearch.map((function(t){return t.title.replace(new RegExp("^"+e+":"),"")}))}})}),e[0]&&e.typeahead({ajax:Object.assign(a,{preDispatch:function(t){return{action:"query",list:"prefixsearch",format:"json",pssearch:"User:"+t}},preProcess:function(t){return t.query.prefixsearch.map((function(t){return t.title.split("/")[0].substr(t.title.indexOf(":")+1)})).filter((function(t,e,n){return n.indexOf(t)===e}))}})});var o=function(t){"&"==t.key&&$(t.target).blur().focus()};t.on("keydown",o),e.on("keydown",o)}}var i;function r(){var t=Date.now();return setInterval((function(){var e=Math.round((Date.now()-t)/1e3),n=Math.floor(e/60),a=("00"+(e-60*n)).slice(-2);$("#submit_timer").text(n+":"+a)}),1e3)}function s(t){t?($(".form-control").prop("readonly",!1),$(".form-submit").prop("disabled",!1),$(".form-submit").text($.i18n("submit")).prop("disabled",!1),i&&(clearInterval(i),i=null)):$("#content form").on("submit",(function(){document.activeElement.blur(),$(".form-control").prop("readonly",!0),$(".form-submit").prop("disabled",!0).html($.i18n("loading")+" "),i=r()}))}function l(){clearInterval(i),loaingTimerId=null;var t=$("#submit_timer").parent()[0];$(t).html(t.initialtext),$(t).removeClass("link-loading")}function u(t){t?l():$("a").filter((function(t,e){return""==e.className&&e.href.startsWith(document.location.origin)&&new URL(e.href).pathname.replaceAll(/[^\/]/g,"").length>1&&"_blank"!=e.target&&e.href.split("#")[0]!=document.location.href})).on("click",(function(t){var e=$(t.target);e.prop("initialtext",e.html()),e.html($.i18n("loading")+" "),e.addClass("link-loading"),i&&l(),i=r()}))}n(8665),n(5086),n(9979),n(4602),n(789),n(933),n(9218),n(2231),n(8636),n(5231),n(6088),n(8476),n(8379),n(7899),n(4189),n(8329),n(9581),n(7136),n(173),n(9073),n(6048),n(9693),n(17),n(9560),n(9389),n(8772),n(4913),n(4989),n(460),xtools={},xtools.application={},xtools.application.vars={sectionOffset:{}},xtools.application.chartGridColor="rgba(0, 0, 0, 0.1)",window.matchMedia("(prefers-color-scheme: dark)").matches&&(Chart.defaults.global.defaultFontColor="#AAA",xtools.application.chartGridColor="#333"),$.i18n({locale:i18nLang}).load(i18nPaths),$((function(){$(document).ready((function(){if($(".xt-hide").on("click",(function(){$(this).hide(),$(this).siblings(".xt-show").show(),$(this).parents(".panel-heading").length?$(this).parents(".panel-heading").siblings(".panel-body").hide():$(this).parents(".xt-show-hide--parent").next(".xt-show-hide--target").hide()})),$(".xt-show").on("click",(function(){$(this).hide(),$(this).siblings(".xt-hide").show(),$(this).parents(".panel-heading").length?$(this).parents(".panel-heading").siblings(".panel-body").show():$(this).parents(".xt-show-hide--parent").next(".xt-show-hide--target").show()})),function(){var t=$(window).width(),e=$(".tool-links").outerWidth(),n=$(".nav-buttons").outerWidth();if(t<768)return;e+n>t&&$(".tool-links--more").removeClass("hidden");var a=$(".tool-links--entry").length;for(;a>0&&e+n>t;){var o=$(".tool-links--nav > .tool-links--entry:not(.active)").last().remove();$(".tool-links--more .dropdown-menu").append(o),e=$(".tool-links").outerWidth(),a--}}(),xtools.application.setupColumnSorting(),function(){var t=$(".xt-toc");if(!t||!t[0])return;xtools.application.vars.tocHeight=t.height();var e=function(){$(".xt-toc").find("a").off("click").on("click",(function(t){document.activeElement.blur();var e=$("#"+$(t.target).data("section"));$(window).scrollTop(e.offset().top-xtools.application.vars.tocHeight),$(this).parents(".xt-toc").find("a").removeClass("bold"),n(),xtools.application.vars.$tocClone.addClass("bold")}))};xtools.application.setupTocListeners=e;var n=function(){xtools.application.vars.$tocClone||(xtools.application.vars.$tocClone=t.clone(),xtools.application.vars.$tocClone.addClass("fixed"),t.after(xtools.application.vars.$tocClone),e())};xtools.application.buildSectionOffsets=function(){$.each(t.find("a"),(function(t,e){var n=$(e).data("section");xtools.application.vars.sectionOffset[n]=$("#"+n).offset().top}))},$(".xt-show, .xt-hide").on("click",xtools.application.buildSectionOffsets),xtools.application.buildSectionOffsets(),e();var a=t.offset().top;$(window).on("scroll.toc",(function(t){var e,o=$(t.target).scrollTop(),i=o>a;i?(xtools.application.vars.$tocClone||n(),Object.keys(xtools.application.vars.sectionOffset).forEach((function(t){o>xtools.application.vars.sectionOffset[t]-xtools.application.vars.tocHeight-1&&(e=xtools.application.vars.$tocClone.find('a[data-section="'+t+'"]'))})),xtools.application.vars.$tocClone.find("a").removeClass("bold"),e&&e.addClass("bold")):!i&&xtools.application.vars.$tocClone&&(xtools.application.vars.$tocClone.remove(),xtools.application.vars.$tocClone=null)}))}(),function(){var t=$(".table-sticky-header");if(!t||!t[0])return;var e,n=t.find("thead tr").eq(0),a=function(){e||(e=n.clone(),n.addClass("sticky-heading"),n.before(e),n.find("th").each((function(t){$(this).css("width",e.find("th").eq(t).outerWidth())})),n.css("width",e.outerWidth()+1))},o=t.offset().top;$(window).on("scroll.stickyHeader",(function(i){var r=$(i.target).scrollTop()>o;r&&!e?a():!r&&e?(n.removeClass("sticky-heading"),e.remove(),e=null):e&&n.css("top",$(window).scrollTop()-t.offset().top)}))}(),function(){var t=$("#project_input");if(!t)return;t.length&&$("#namespace_select").length?(xtools.application.vars.lastProject=$("#project_input").val(),$("#project_input").off("change").on("change",(function(){$("#namespace_select").prop("disabled",!0);var t=this.value;$.get(xtBaseUrl+"api/project/namespaces/"+t).done((function(e){var n=$('#namespace_select option[value="all"]').eq(0).clone();for(var a in $("#namespace_select").html(n),xtools.application.vars.apiPath=e.api,e.namespaces)if(e.namespaces.hasOwnProperty(a)){var i=0===parseInt(a,10)?$.i18n("mainspace"):e.namespaces[a];$("#namespace_select").append("")}$("#namespace_select").val(0),xtools.application.vars.lastProject=t,o()})).fail(a.bind(this,t)).always((function(){$("#namespace_select").prop("disabled",!1)}))})),$("#namespace_select").on("change",o)):($("#user_input")[0]||$("#page_input")[0])&&(xtools.application.vars.lastProject=t.val(),t.on("change",(function(){var e=this.value;$.get(xtBaseUrl+"api/project/normalize/"+e).done((function(n){xtools.application.vars.apiPath=n.api,xtools.application.vars.lastProject=e,o(),t.trigger("xtools.projectLoaded",n)})).fail(a.bind(this,e))})))}(),o(),s(),u(),"function"==typeof URL){var t=new URL(window.location.href).searchParams.get("focus");t&&$("[name = ".concat(t,"]")).focus()}})),window.onpageshow=function(t){t.persisted&&(s(!0),u(!0))}})),xtools.application.setupToggleTable=function(t,e,n,a){var o;$(".toggle-table").on("click",".toggle-table--toggle",(function(){o||(o=Object.assign({},t));var i=$(this).data("index"),r=$(this).data("key");"true"===$(this).attr("data-disabled")?(o[r]=t[r],e&&(e.data.datasets[0].data[i]=parseInt(n?o[r][n]:o[r],10)),$(this).attr("data-disabled","false")):(delete o[r],e&&(e.data.datasets[0].data[i]=null),$(this).attr("data-disabled","true")),$(this).parents("tr").toggleClass("excluded"),$(this).find(".glyphicon").toggleClass("glyphicon-remove").toggleClass("glyphicon-plus"),a(o,r,i),e&&e.update()}))},xtools.application.setupColumnSorting=function(){var t,e;$(".sort-link").on("click",(function(){t=e===$(this).data("column")?-t:1,$(".sort-link .glyphicon").removeClass("glyphicon-sort-by-alphabet-alt glyphicon-sort-by-alphabet").addClass("glyphicon-sort");var n=1===t?"glyphicon-sort-by-alphabet-alt":"glyphicon-sort-by-alphabet";$(this).find(".glyphicon").addClass(n).removeClass("glyphicon-sort"),e=$(this).data("column");var a=$(this).parents("table"),o=a.find(".sort-entry--"+e).parent();o.length&&(o.sort((function(n,a){var o=$(n).find(".sort-entry--"+e).data("value")||0,i=$(a).find(".sort-entry--"+e).data("value")||0;return isNaN(o)||(o=parseFloat(o)||0),isNaN(i)||(i=parseFloat(i)||0),oi?-t:0})),$(".sort-entry--rank").length>0&&$.each(o,(function(t,e){$(e).find(".sort-entry--rank").text(t+1)})),a.find("tbody").html(o))}))},xtools.application.setupMultiSelectListeners=function(){var t=$(".multi-select--body:not(.hidden) .multi-select--option");t.on("change",(function(){$(".multi-select--all").prop("checked",$(".multi-select--body:not(.hidden) .multi-select--option:checked").length===t.length)})),$(".multi-select--all").on("click",(function(){t.prop("checked",$(this).prop("checked"))}))}},6618:(t,e,n)=>{function a(){xtools.application.vars.offset||(xtools.application.vars.initialOffset=$(".contributions-container").data("offset"),xtools.application.vars.offset=xtools.application.vars.initialOffset)}n(9218),n(2231),n(8665),n(5086),n(9979),n(4602),n(933),n(7136),n(785),n(9389),n(6048),n(9073),n(173),n(4913),Object.assign(xtools.application.vars,{initialOffset:"",offset:"",prevOffsets:[],initialLoad:!1}),xtools.application.loadContributions=function(t,e){a();var n=$(".contributions-container"),o=$(".contributions-loading"),i=n.data(),r=t(i),s=parseInt(i.limit,10)||50,l=new URLSearchParams(window.location.search),u=xtBaseUrl+r+"/"+xtools.application.vars.offset,c=location.pathname.split("/")[1],d=u.split("/")[1];n.addClass("contributions-container--loading"),o.show(),l.set("limit",s.toString()),l.append("htmlonly","yes"),$.ajax({url:u+"?"+l.toString(),timeout:6e4}).always((function(){n.removeClass("contributions-container--loading"),o.hide()})).done((function(a){if(n.html(a).show(),xtools.application.setupContributionsNavListeners(t,e),xtools.application.vars.initialOffset||(xtools.application.vars.initialOffset=$(".contribs-row-date").first().data("value"),xtools.application.vars.initialLoad=!0),c!==d){var o=new RegExp(" ^ / ".concat(d," / (.*) / "));u=u.replace(o," / ".concat(c," / $1 / "))}xtools.application.vars.initialLoad?xtools.application.vars.initialLoad=!1:(l.delete("htmlonly"),window.history.replaceState(null,document.title,u+"?"+l.toString()),n.parents(".panel")[0].scrollIntoView()),xtools.application.vars.offset"+i+"")).show()}))},xtools.application.setupContributionsNavListeners=function(t,e){a(),$(".contributions--prev").off("click").one("click",(function(n){n.preventDefault(),xtools.application.vars.offset=xtools.application.vars.prevOffsets.pop()||xtools.application.vars.initialOffset,xtools.application.loadContributions(t,e)})),$(".contributions--next").off("click").one("click",(function(n){n.preventDefault(),xtools.application.vars.offset&&xtools.application.vars.prevOffsets.push(xtools.application.vars.offset),xtools.application.vars.offset=$(".contribs-row-date").last().data("value"),xtools.application.loadContributions(t,e)})),$("#contributions_limit").on("change",(function(t){var e=parseInt(t.target.value,10);$(".contributions-container").data("limit",e);var n=function(t){return t[0].toUpperCase()+t.slice(1)};$(".contributions--prev-text").text(n($.i18n("pager-newer-n",e))),$(".contributions--next-text").text(n($.i18n("pager-older-n",e)))}))}},9307:(t,e,n)=>{function a(t,e){var n=0,a=[];Object.keys(t).forEach((function(e){var o=parseInt(t[e],10);a.push(o),n+=o}));var i=Object.keys(t).length;$(".namespaces--namespaces").text(i.toLocaleString(i18nLang)+" "+$.i18n("num-namespaces",i)),$(".namespaces--count").text(n.toLocaleString(i18nLang)),a.forEach((function(t){var e=r(t,n);$(".namespaces-table .sort-entry--count[data-value="+t+"]").text(t.toLocaleString(i18nLang)+" ("+e+")")})),["year","month"].forEach((function(t){var n=window[t+"countsChart"],a=window.namespaces[e]||$.i18n("mainspace");if(n){var i=0;n.data.datasets.forEach((function(t,e){t.label===a&&(i=e)}));var r=n.getDatasetMeta(i);r.hidden=null===r.hidden?!n.data.datasets[i].hidden:null,r.hidden?xtools.editcounter.excludedNamespaces.push(a):xtools.editcounter.excludedNamespaces=xtools.editcounter.excludedNamespaces.filter((function(t){return t!==a})),window[t+"countsChart"].config.data.labels=o(t,n.data.datasets),n.update()}}))}function o(t,e){var n=i(t,e);return Object.keys(n).map((function(e){var a=n[e].toString().length,o=2*(xtools.editcounter.maxDigits[t]-a);return e+Array(o+5).join("\t")+n[e].toLocaleString(i18nLang,{useGrouping:!1})}))}function i(t,e){var n={};return e.forEach((function(e){-1===xtools.editcounter.excludedNamespaces.indexOf(e.label)&&e.data.forEach((function(e,a){n[xtools.editcounter.chartLabels[t][a]]||(n[xtools.editcounter.chartLabels[t][a]]=0),n[xtools.editcounter.chartLabels[t][a]]+=e}))})),n}function r(t,e){return(t/e).toLocaleString(i18nLang,{style:"percent"})}n(8476),n(5086),n(8379),n(7899),n(2231),n(17),n(9581),n(9389),n(6048),n(475),n(9693),n(7136),n(173),n(5195),n(9979),n(2982),n(115),n(1128),n(5843),n(533),n(8825),n(6088),xtools.editcounter={},xtools.editcounter.excludedNamespaces=[],xtools.editcounter.chartLabels={},xtools.editcounter.maxDigits={},$((function(){0!==$("body.editcounter").length&&(xtools.application.setupMultiSelectListeners(),$(".chart-wrapper").each((function(){var t=$(this).data("chart-type");if(void 0===t)return!1;var e=$(this).data("chart-data"),n=$(this).data("chart-labels"),a=$("canvas",$(this));new Chart(a,{type:t,data:{labels:n,datasets:[{data:e}]}})})),xtools.application.setupToggleTable(window.namespaceTotals,window.namespaceChart,null,a))})),xtools.editcounter.setupMonthYearChart=function(t,e,n,a){var s=e.map((function(t){return t.label}));xtools.editcounter.maxDigits[t]=a.toString().length,xtools.editcounter.chartLabels[t]=n;var l=function(){var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"linear";return window[t+"countsChart"]=new Chart($("#"+t+"counts-canvas"),{type:"horizontalBar",data:{labels:o(t,e),datasets:e},options:{tooltips:{mode:"nearest",intersect:!0,callbacks:{label:function(n){var a=i(t,e),o=Object.keys(a).map((function(t){return a[t]})),s=o[n.index],l=r(n.xLabel,s);return n.xLabel.toLocaleString(i18nLang)+" ("+l+")"},title:function(t){return t[0].yLabel.replace(/\t.*/,"")+" - "+s[t[0].datasetIndex]}}},responsive:!0,maintainAspectRatio:!1,scales:{xAxes:[{type:n,stacked:!0,ticks:{beginAtZero:!0,min:"logarithmic"==n?1:0,reverse:"logarithmic"!=n&&i18nRTL,callback:function(t){if(Math.floor(t)===t)return t.toLocaleString(i18nLang)}},gridLines:{color:xtools.application.chartGridColor},afterBuildTicks:function(t){if("logarithmic"==n){var e=[];t.ticks.forEach((function(t,n){(0==n||1.5*e[e.length-1]"+u[11].toLocaleString(i18nLang)),window.sizeHistogramChart=new Chart($("#sizechart-canvas"),{type:"bar",data:{labels:c,datasets:[s,l,i]},options:{tooltips:{mode:"nearest",intersect:!0,callbacks:{label:function(t){return percentage=r(Math.abs(t.yLabel),o),Math.abs(t.yLabel).toLocaleString(i18nLang)+" ("+percentage+")"}}},responsive:!0,maintainAspectRatio:!1,legend:{position:"top"},scales:{yAxes:[{stacked:!0,gridLines:{color:xtools.application.chartGridColor},ticks:{callback:function(t){return Math.abs(t).toLocaleString(i18nLang)}}}],xAxes:[{stacked:!0,gridLines:{color:xtools.application.chartGridColor}}]}}})},xtools.editcounter.setupTimecard=function(t,e){var n=(new Date).getTimezoneOffset()/60;t=t.map((function(t){return t.backgroundColor=new Array(t.data.length).fill(t.backgroundColor),t})),window.chart=new Chart($("#timecard-bubble-chart"),{type:"bubble",data:{datasets:t},options:{responsive:!0,legend:{display:!1},layout:{padding:{right:0}},elements:{point:{radius:function(t){var e=t.dataIndex,n=t.dataset.data[e],a=(t.chart.height-20)/9/2;return n.scale/20*a},hitRadius:8}},scales:{yAxes:[{ticks:{min:0,max:8,stepSize:1,padding:25,callback:function(t,n){return e[n]}},position:i18nRTL?"right":"left",gridLines:{color:xtools.application.chartGridColor}},{ticks:{min:0,max:8,stepSize:1,padding:25,callback:function(e,n){return 0===n||n>7?"":(window.chart?window.chart.data.datasets:t).map((function(t){return t.data})).flat().filter((function(t){return t.y==8-n})).reduce((function(t,e){return t+parseInt(e.value,10)}),0).toLocaleString(i18nLang)}},position:i18nRTL?"left":"right"}],xAxes:[{ticks:{beginAtZero:!0,min:0,max:24,stepSize:1,reverse:i18nRTL,padding:0,callback:function(e,n,a,o){if(24===e)return"";var i=[];if($("#timecard-bubble-chart").attr("width")>=1e3){var r=(window.chart?window.chart.data.datasets:t).map((function(t){return t.data})).flat().filter((function(t){return t.x==e}));i.push(r.reduce((function(t,e){return t+parseInt(e.value,10)}),0).toLocaleString(i18nLang))}return e%2==0&&i.push(e+":00"),i}},gridLines:{color:xtools.application.chartGridColor},position:"bottom"}]},tooltips:{displayColors:!1,callbacks:{title:function(t){return e[7-t[0].yLabel+1]+" "+parseInt(t[0].xLabel)+":"+String(t[0].xLabel%1*60).padStart(2,"0")},label:function(e){var n=[t[e.datasetIndex].data[e.index].value];return"".concat(n.toLocaleString(i18nLang)," ").concat($.i18n("num-edits",[n]))}}}}}),$((function(){$(".use-local-time").prop("checked",!1).on("click",(function(){var t=$(this).is(":checked")?n:-n,e=new Array(7);chart.data.datasets.forEach((function(t){return e[t.data[0].day_of_week-1]=t.backgroundColor[0]})),chart.data.datasets=chart.data.datasets.map((function(n){var a=[];return n.data=n.data.map((function(n){var o=parseFloat(n.hour)-t,i=parseInt(n.day_of_week,10);return o<0?(o=24+o,(i-=1)<1&&(i=7+i)):o>=24&&(o-=24,(i+=1)>7&&(i-=7)),n.hour=o.toString(),n.x=o.toString(),n.day_of_week=i.toString(),n.y=(8-i).toString(),a.push(e[i-1]),n})),n.backgroundColor=a,n})),$(this).is(":checked"),chart.update()}))}))}},6730:(t,e,n)=>{n(115),xtools.globalcontribs={},$((function(){0!==$("body.globalcontribs").length&&xtools.application.setupContributionsNavListeners((function(t){return"globalcontribs / ".concat(t.username," / ").concat(t.namespace," / ").concat(t.start," / ").concat(t.end)}),"globalcontribs")}))},1680:(t,e,n)=>{n(7136),n(173),xtools.pageinfo={},$((function(){if($("body.pageinfo").length){var t=function(){xtools.application.setupToggleTable(window.textshares,window.textsharesChart,"percentage",$.noop)},e=$(".textshares-container");if(e[0]){var n=xtBaseUrl+"authorship/"+e.data("project")+"/"+e.data("page")+"/"+(xtools.pageinfo.endDate?xtools.pageinfo.endDate+"/":"");n="".concat(n.replace(/\/$/,"")," ? htmlonly = yes"),$.ajax({url:n,timeout:3e4}).done((function(n){e.replaceWith(n),xtools.application.buildSectionOffsets(),xtools.application.setupTocListeners(),xtools.application.setupColumnSorting(),t()})).fail((function(t,n,a){e.replaceWith($.i18n("api-error","Authorship API: "+a+""))}))}else $(".textshares-table").length&&t()}}))},1595:(t,e,n)=>{n(8476),n(5086),n(8379),n(7899),n(4867),n(9389),n(6048),n(8636),xtools.pages={},$((function(){if($("body.pages").length){var t={};xtools.application.setupToggleTable(window.countsByNamespace,window.pieChart,"count",(function(t){var e={count:0,deleted:0,redirects:0};Object.keys(t).forEach((function(n){e.count+=t[n].count,e.deleted+=t[n].deleted,e.redirects+=t[n].redirects})),$(".namespaces--namespaces").text(Object.keys(t).length.toLocaleString()+" "+$.i18n("num-namespaces",Object.keys(t).length)),$(".namespaces--pages").text(e.count.toLocaleString()),$(".namespaces--deleted").text(e.deleted.toLocaleString()+" ("+(e.deleted/e.count*100).toFixed(1)+"%)"),$(".namespaces--redirects").text(e.redirects.toLocaleString()+" ("+(e.redirects/e.count*100).toFixed(1)+"%)")})),$(".deleted-page").on("mouseenter",(function(e){var n=$(this).data("page-title"),a=$(this).data("namespace"),o=$(this).data("datetime").toString(),i=$(this).data("username"),r=function(t){$(e.target).find(".tooltip-body").html(t)};if(void 0!==t[a+"/"+n])return r(t[a+"/"+n]);var s=function(){r(""+$.i18n("api-error","Deletion Summary API")+"")};$.ajax({url:xtBaseUrl+"pages/deletion_summary/"+wikiDomain+"/"+i+"/"+a+"/"+n+"/"+o}).done((function(e){if(null===e.summary)return s();r(e.summary),t[a+"/"+n]=e.summary})).fail(s)}))}}))},1223:()=>{xtools.topedits={},$((function(){$("body.topedits").length&&$("#namespace_select").on("change",(function(){$("#page_input").prop("disabled","all"===$(this).val())}))}))},7852:(t,e,n)=>{var a,o,i,s;function l(t){return l="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},l(t)}n(7136),n(6255),n(2231),n(4913),n(6088),n(9389),n(5086),n(6048),n(8665),n(4602),n(115),n(8476),n(9693),n(475),n(9581),n(2982),n(4009),n(17),n(2157),n(8763),n(9560),n(5852),n(8379),n(7899),n(533),n(4538),n(1145),n(6943),n(8772),n(5231),n(4867),n(4895),n(4189),n(557),n(8844),n(2006),n(3534),n(590),n(4216),n(9979),s=function(){return function t(e,n,a){function o(r,s){if(!n[r]){if(!e[r]){if(i)return i(r,!0);var l=new Error("Cannot find module '"+r+"'");throw l.code="MODULE_NOT_FOUND",l}var u=n[r]={exports:{}};e[r][0].call(u.exports,(function(t){return o(e[r][1][t]||t)}),u,u.exports,t,e,n,a)}return n[r].exports}for(var i=void 0,r=0;rn?(e+.05)/(n+.05):(n+.05)/(e+.05)},level:function(t){var e=this.contrast(t);return e>=7.1?"AAA":e>=4.5?"AA":""},dark:function(){var t=this.values.rgb;return(299*t[0]+587*t[1]+114*t[2])/1e3<128},light:function(){return!this.dark()},negate:function(){for(var t=[],e=0;e<3;e++)t[e]=255-this.values.rgb[e];return this.setValues("rgb",t),this},lighten:function(t){var e=this.values.hsl;return e[2]+=e[2]*t,this.setValues("hsl",e),this},darken:function(t){var e=this.values.hsl;return e[2]-=e[2]*t,this.setValues("hsl",e),this},saturate:function(t){var e=this.values.hsl;return e[1]+=e[1]*t,this.setValues("hsl",e),this},desaturate:function(t){var e=this.values.hsl;return e[1]-=e[1]*t,this.setValues("hsl",e),this},whiten:function(t){var e=this.values.hwb;return e[1]+=e[1]*t,this.setValues("hwb",e),this},blacken:function(t){var e=this.values.hwb;return e[2]+=e[2]*t,this.setValues("hwb",e),this},greyscale:function(){var t=this.values.rgb,e=.3*t[0]+.59*t[1]+.11*t[2];return this.setValues("rgb",[e,e,e]),this},clearer:function(t){var e=this.values.alpha;return this.setValues("alpha",e-e*t),this},opaquer:function(t){var e=this.values.alpha;return this.setValues("alpha",e+e*t),this},rotate:function(t){var e=this.values.hsl,n=(e[0]+t)%360;return e[0]=n<0?360+n:n,this.setValues("hsl",e),this},mix:function(t,e){var n=this,a=t,o=void 0===e?.5:e,i=2*o-1,r=n.alpha()-a.alpha(),s=((i*r==-1?i:(i+r)/(1+i*r))+1)/2,l=1-s;return this.rgb(s*n.red()+l*a.red(),s*n.green()+l*a.green(),s*n.blue()+l*a.blue()).alpha(n.alpha()*o+a.alpha()*(1-o))},toJSON:function(){return this.rgb()},clone:function(){var t,e,n=new i,a=this.values,o=n.values;for(var r in a)a.hasOwnProperty(r)&&(t=a[r],"[object Array]"===(e={}.toString.call(t))?o[r]=t.slice(0):"[object Number]"===e?o[r]=t:console.error("unexpected color value:",t));return n}},i.prototype.spaces={rgb:["red","green","blue"],hsl:["hue","saturation","lightness"],hsv:["hue","saturation","value"],hwb:["hue","whiteness","blackness"],cmyk:["cyan","magenta","yellow","black"]},i.prototype.maxes={rgb:[255,255,255],hsl:[360,100,100],hsv:[360,100,100],hwb:[360,100,100],cmyk:[100,100,100,100]},i.prototype.getValues=function(t){for(var e=this.values,n={},a=0;a.04045?Math.pow((e+.055)/1.055,2.4):e/12.92)+.3576*(n=n>.04045?Math.pow((n+.055)/1.055,2.4):n/12.92)+.1805*(a=a>.04045?Math.pow((a+.055)/1.055,2.4):a/12.92)),100*(.2126*e+.7152*n+.0722*a),100*(.0193*e+.1192*n+.9505*a)]}function c(t){var e=u(t),n=e[0],a=e[1],o=e[2];return a/=100,o/=108.883,n=(n/=95.047)>.008856?Math.pow(n,1/3):7.787*n+16/116,[116*(a=a>.008856?Math.pow(a,1/3):7.787*a+16/116)-16,500*(n-a),200*(a-(o=o>.008856?Math.pow(o,1/3):7.787*o+16/116))]}function d(t){var e,n,a,o,i,r=t[0]/360,s=t[1]/100,l=t[2]/100;if(0==s)return[i=255*l,i,i];e=2*l-(n=l<.5?l*(1+s):l+s-l*s),o=[0,0,0];for(var u=0;u<3;u++)(a=r+1/3*-(u-1))<0&&a++,a>1&&a--,i=6*a<1?e+6*(n-e)*a:2*a<1?n:3*a<2?e+(n-e)*(2/3-a)*6:e,o[u]=255*i;return o}function h(t){var e=t[0]/60,n=t[1]/100,a=t[2]/100,o=Math.floor(e)%6,i=e-Math.floor(e),r=255*a*(1-n),s=255*a*(1-n*i),l=255*a*(1-n*(1-i));switch(a*=255,o){case 0:return[a,l,r];case 1:return[s,a,r];case 2:return[r,a,l];case 3:return[r,s,a];case 4:return[l,r,a];case 5:return[a,r,s]}}function f(t){var e,n,a,o,i=t[0]/360,s=t[1]/100,l=t[2]/100,u=s+l;switch(u>1&&(s/=u,l/=u),a=6*i-(e=Math.floor(6*i)),!!(1&e)&&(a=1-a),o=s+a*((n=1-l)-s),e){default:case 6:case 0:r=n,g=o,b=s;break;case 1:r=o,g=n,b=s;break;case 2:r=s,g=n,b=o;break;case 3:r=s,g=o,b=n;break;case 4:r=o,g=s,b=n;break;case 5:r=n,g=s,b=o}return[255*r,255*g,255*b]}function p(t){var e=t[0]/100,n=t[1]/100,a=t[2]/100,o=t[3]/100;return[255*(1-Math.min(1,e*(1-o)+o)),255*(1-Math.min(1,n*(1-o)+o)),255*(1-Math.min(1,a*(1-o)+o))]}function v(t){var e,n,a,o=t[0]/100,i=t[1]/100,r=t[2]/100;return n=-.9689*o+1.8758*i+.0415*r,a=.0557*o+-.204*i+1.057*r,e=(e=3.2406*o+-1.5372*i+-.4986*r)>.0031308?1.055*Math.pow(e,1/2.4)-.055:e*=12.92,n=n>.0031308?1.055*Math.pow(n,1/2.4)-.055:n*=12.92,a=a>.0031308?1.055*Math.pow(a,1/2.4)-.055:a*=12.92,[255*(e=Math.min(Math.max(0,e),1)),255*(n=Math.min(Math.max(0,n),1)),255*(a=Math.min(Math.max(0,a),1))]}function m(t){var e=t[0],n=t[1],a=t[2];return n/=100,a/=108.883,e=(e/=95.047)>.008856?Math.pow(e,1/3):7.787*e+16/116,[116*(n=n>.008856?Math.pow(n,1/3):7.787*n+16/116)-16,500*(e-n),200*(n-(a=a>.008856?Math.pow(a,1/3):7.787*a+16/116))]}function x(t){var e,n,a,o,i=t[0],r=t[1],s=t[2];return i<=8?o=(n=100*i/903.3)/100*7.787+16/116:(n=100*Math.pow((i+16)/116,3),o=Math.pow(n/100,1/3)),[e=e/95.047<=.008856?e=95.047*(r/500+o-16/116)/7.787:95.047*Math.pow(r/500+o,3),n,a=a/108.883<=.008859?a=108.883*(o-s/200-16/116)/7.787:108.883*Math.pow(o-s/200,3)]}function y(t){var e,n=t[0],a=t[1],o=t[2];return(e=360*Math.atan2(o,a)/2/Math.PI)<0&&(e+=360),[n,Math.sqrt(a*a+o*o),e]}function k(t){return v(x(t))}function w(t){var e,n=t[0],a=t[1];return e=t[2]/360*2*Math.PI,[n,a*Math.cos(e),a*Math.sin(e)]}function C(t){return S[t]}e.exports={rgb2hsl:a,rgb2hsv:o,rgb2hwb:i,rgb2cmyk:s,rgb2keyword:l,rgb2xyz:u,rgb2lab:c,rgb2lch:function(t){return y(c(t))},hsl2rgb:d,hsl2hsv:function(t){var e=t[0],n=t[1]/100,a=t[2]/100;return 0===a?[0,0,0]:[e,2*(n*=(a*=2)<=1?a:2-a)/(a+n)*100,(a+n)/2*100]},hsl2hwb:function(t){return i(d(t))},hsl2cmyk:function(t){return s(d(t))},hsl2keyword:function(t){return l(d(t))},hsv2rgb:h,hsv2hsl:function(t){var e,n,a=t[0],o=t[1]/100,i=t[2]/100;return e=o*i,[a,100*(e=(e/=(n=(2-o)*i)<=1?n:2-n)||0),100*(n/=2)]},hsv2hwb:function(t){return i(h(t))},hsv2cmyk:function(t){return s(h(t))},hsv2keyword:function(t){return l(h(t))},hwb2rgb:f,hwb2hsl:function(t){return a(f(t))},hwb2hsv:function(t){return o(f(t))},hwb2cmyk:function(t){return s(f(t))},hwb2keyword:function(t){return l(f(t))},cmyk2rgb:p,cmyk2hsl:function(t){return a(p(t))},cmyk2hsv:function(t){return o(p(t))},cmyk2hwb:function(t){return i(p(t))},cmyk2keyword:function(t){return l(p(t))},keyword2rgb:C,keyword2hsl:function(t){return a(C(t))},keyword2hsv:function(t){return o(C(t))},keyword2hwb:function(t){return i(C(t))},keyword2cmyk:function(t){return s(C(t))},keyword2lab:function(t){return c(C(t))},keyword2xyz:function(t){return u(C(t))},xyz2rgb:v,xyz2lab:m,xyz2lch:function(t){return y(m(t))},lab2xyz:x,lab2rgb:k,lab2lch:y,lch2lab:w,lch2xyz:function(t){return x(w(t))},lch2rgb:function(t){return k(w(t))}};var S={aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],grey:[128,128,128],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],rebeccapurple:[102,51,153],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]},M={};for(var _ in S)M[JSON.stringify(S[_])]=_},{}],5:[function(t,e,n){var a=t(4),o=function(){return new u};for(var i in a){o[i+"Raw"]=function(t){return function(e){return"number"==typeof e&&(e=Array.prototype.slice.call(arguments)),a[t](e)}}(i);var r=/(\w+)2(\w+)/.exec(i),s=r[1],l=r[2];(o[s]=o[s]||{})[l]=o[i]=function(t){return function(e){"number"==typeof e&&(e=Array.prototype.slice.call(arguments));var n=a[t](e);if("string"==typeof n||void 0===n)return n;for(var o=0;o0&&(t[0].yLabel?n=t[0].yLabel:e.labels.length>0&&t[0].index=0&&o>0)&&(v+=o));return i=d.getPixelForValue(v),{size:s=((r=d.getPixelForValue(v+f))-i)/2,base:i,head:r,center:r+s/2}},calculateBarIndexPixels:function(t,e,n){var a,o,r,s,l,u=n.scale.options,c=this.getStackIndex(t),d=n.pixels,h=d[e],f=d.length,p=n.start,g=n.end;return 1===f?(a=h>p?h-p:g-h,o=h0&&(a=(h-d[e-1])/2,e===f-1&&(o=a)),e');var n=t.data,a=n.datasets,o=n.labels;if(a.length)for(var i=0;i'),o[i]&&e.push(o[i]),e.push("");return e.push(""),e.join("")},legend:{labels:{generateLabels:function(t){var e=t.data;return e.labels.length&&e.datasets.length?e.labels.map((function(n,a){var o=t.getDatasetMeta(0),r=e.datasets[0],s=o.data[a],l=s&&s.custom||{},u=i.valueAtIndexOrDefault,c=t.options.elements.arc;return{text:n,fillStyle:l.backgroundColor?l.backgroundColor:u(r.backgroundColor,a,c.backgroundColor),strokeStyle:l.borderColor?l.borderColor:u(r.borderColor,a,c.borderColor),lineWidth:l.borderWidth?l.borderWidth:u(r.borderWidth,a,c.borderWidth),hidden:isNaN(r.data[a])||o.data[a].hidden,index:a}})):[]}},onClick:function(t,e){var n,a,o,i=e.index,r=this.chart;for(n=0,a=(r.data.datasets||[]).length;n=Math.PI?-1:p<-Math.PI?1:0))+f,v={x:Math.cos(p),y:Math.sin(p)},m={x:Math.cos(g),y:Math.sin(g)},b=p<=0&&g>=0||p<=2*Math.PI&&2*Math.PI<=g,x=p<=.5*Math.PI&&.5*Math.PI<=g||p<=2.5*Math.PI&&2.5*Math.PI<=g,y=p<=-Math.PI&&-Math.PI<=g||p<=Math.PI&&Math.PI<=g,k=p<=.5*-Math.PI&&.5*-Math.PI<=g||p<=1.5*Math.PI&&1.5*Math.PI<=g,w=h/100,C={x:y?-1:Math.min(v.x*(v.x<0?1:w),m.x*(m.x<0?1:w)),y:k?-1:Math.min(v.y*(v.y<0?1:w),m.y*(m.y<0?1:w))},S={x:b?1:Math.max(v.x*(v.x>0?1:w),m.x*(m.x>0?1:w)),y:x?1:Math.max(v.y*(v.y>0?1:w),m.y*(m.y>0?1:w))},M={width:.5*(S.x-C.x),height:.5*(S.y-C.y)};u=Math.min(s/M.width,l/M.height),c={x:-.5*(S.x+C.x),y:-.5*(S.y+C.y)}}n.borderWidth=e.getMaxBorderWidth(d.data),n.outerRadius=Math.max((u-n.borderWidth)/2,0),n.innerRadius=Math.max(h?n.outerRadius/100*h:0,0),n.radiusLength=(n.outerRadius-n.innerRadius)/n.getVisibleDatasetCount(),n.offsetX=c.x*n.outerRadius,n.offsetY=c.y*n.outerRadius,d.total=e.calculateTotal(),e.outerRadius=n.outerRadius-n.radiusLength*e.getRingIndex(e.index),e.innerRadius=Math.max(e.outerRadius-n.radiusLength,0),i.each(d.data,(function(n,a){e.updateElement(n,a,t)}))},updateElement:function(t,e,n){var a=this,o=a.chart,r=o.chartArea,s=o.options,l=s.animation,u=(r.left+r.right)/2,c=(r.top+r.bottom)/2,d=s.rotation,h=s.rotation,f=a.getDataset(),p=n&&l.animateRotate||t.hidden?0:a.calculateCircumference(f.data[e])*(s.circumference/(2*Math.PI)),g=n&&l.animateScale?0:a.innerRadius,v=n&&l.animateScale?0:a.outerRadius,m=i.valueAtIndexOrDefault;i.extend(t,{_datasetIndex:a.index,_index:e,_model:{x:u+o.offsetX,y:c+o.offsetY,startAngle:d,endAngle:h,circumference:p,outerRadius:v,innerRadius:g,label:m(f.label,e,o.data.labels[e])}});var b=t._model;this.removeHoverStyle(t),n&&l.animateRotate||(b.startAngle=0===e?s.rotation:a.getMeta().data[e-1]._model.endAngle,b.endAngle=b.startAngle+b.circumference),t.pivot()},removeHoverStyle:function(e){t.DatasetController.prototype.removeHoverStyle.call(this,e,this.chart.options.elements.arc)},calculateTotal:function(){var t,e=this.getDataset(),n=this.getMeta(),a=0;return i.each(n.data,(function(n,o){t=e.data[o],isNaN(t)||n.hidden||(a+=Math.abs(t))})),a},calculateCircumference:function(t){var e=this.getMeta().total;return e>0&&!isNaN(t)?2*Math.PI*(t/e):0},getMaxBorderWidth:function(t){for(var e,n,a=0,o=this.index,i=t.length,r=0;r(a=e>a?e:a)?n:a;return a}})}},{25:25,40:40,45:45}],18:[function(t,e,n){"use strict";var a=t(25),o=t(40),i=t(45);a._set("line",{showLines:!0,spanGaps:!1,hover:{mode:"label"},scales:{xAxes:[{type:"category",id:"x-axis-0"}],yAxes:[{type:"linear",id:"y-axis-0"}]}}),e.exports=function(t){function e(t,e){return i.valueOrDefault(t.showLine,e.showLines)}t.controllers.line=t.DatasetController.extend({datasetElementType:o.Line,dataElementType:o.Point,update:function(t){var n,a,o,r=this,s=r.getMeta(),l=s.dataset,u=s.data||[],c=r.chart.options,d=c.elements.line,h=r.getScaleForId(s.yAxisID),f=r.getDataset(),p=e(f,c);for(p&&(o=l.custom||{},void 0!==f.tension&&void 0===f.lineTension&&(f.lineTension=f.tension),l._scale=h,l._datasetIndex=r.index,l._children=u,l._model={spanGaps:f.spanGaps?f.spanGaps:c.spanGaps,tension:o.tension?o.tension:i.valueOrDefault(f.lineTension,d.tension),backgroundColor:o.backgroundColor?o.backgroundColor:f.backgroundColor||d.backgroundColor,borderWidth:o.borderWidth?o.borderWidth:f.borderWidth||d.borderWidth,borderColor:o.borderColor?o.borderColor:f.borderColor||d.borderColor,borderCapStyle:o.borderCapStyle?o.borderCapStyle:f.borderCapStyle||d.borderCapStyle,borderDash:o.borderDash?o.borderDash:f.borderDash||d.borderDash,borderDashOffset:o.borderDashOffset?o.borderDashOffset:f.borderDashOffset||d.borderDashOffset,borderJoinStyle:o.borderJoinStyle?o.borderJoinStyle:f.borderJoinStyle||d.borderJoinStyle,fill:o.fill?o.fill:void 0!==f.fill?f.fill:d.fill,steppedLine:o.steppedLine?o.steppedLine:i.valueOrDefault(f.steppedLine,d.stepped),cubicInterpolationMode:o.cubicInterpolationMode?o.cubicInterpolationMode:i.valueOrDefault(f.cubicInterpolationMode,d.cubicInterpolationMode)},l.pivot()),n=0,a=u.length;n');var n=t.data,a=n.datasets,o=n.labels;if(a.length)for(var i=0;i'),o[i]&&e.push(o[i]),e.push("");return e.push(""),e.join("")},legend:{labels:{generateLabels:function(t){var e=t.data;return e.labels.length&&e.datasets.length?e.labels.map((function(n,a){var o=t.getDatasetMeta(0),r=e.datasets[0],s=o.data[a].custom||{},l=i.valueAtIndexOrDefault,u=t.options.elements.arc;return{text:n,fillStyle:s.backgroundColor?s.backgroundColor:l(r.backgroundColor,a,u.backgroundColor),strokeStyle:s.borderColor?s.borderColor:l(r.borderColor,a,u.borderColor),lineWidth:s.borderWidth?s.borderWidth:l(r.borderWidth,a,u.borderWidth),hidden:isNaN(r.data[a])||o.data[a].hidden,index:a}})):[]}},onClick:function(t,e){var n,a,o,i=e.index,r=this.chart;for(n=0,a=(r.data.datasets||[]).length;n0&&!isNaN(t)?2*Math.PI/e:0}})}},{25:25,40:40,45:45}],20:[function(t,e,n){"use strict";var a=t(25),o=t(40),i=t(45);a._set("radar",{scale:{type:"radialLinear"},elements:{line:{tension:0}}}),e.exports=function(t){t.controllers.radar=t.DatasetController.extend({datasetElementType:o.Line,dataElementType:o.Point,linkScales:i.noop,update:function(t){var e=this,n=e.getMeta(),a=n.dataset,o=n.data,r=a.custom||{},s=e.getDataset(),l=e.chart.options.elements.line,u=e.chart.scale;void 0!==s.tension&&void 0===s.lineTension&&(s.lineTension=s.tension),i.extend(n.dataset,{_datasetIndex:e.index,_scale:u,_children:o,_loop:!0,_model:{tension:r.tension?r.tension:i.valueOrDefault(s.lineTension,l.tension),backgroundColor:r.backgroundColor?r.backgroundColor:s.backgroundColor||l.backgroundColor,borderWidth:r.borderWidth?r.borderWidth:s.borderWidth||l.borderWidth,borderColor:r.borderColor?r.borderColor:s.borderColor||l.borderColor,fill:r.fill?r.fill:void 0!==s.fill?s.fill:l.fill,borderCapStyle:r.borderCapStyle?r.borderCapStyle:s.borderCapStyle||l.borderCapStyle,borderDash:r.borderDash?r.borderDash:s.borderDash||l.borderDash,borderDashOffset:r.borderDashOffset?r.borderDashOffset:s.borderDashOffset||l.borderDashOffset,borderJoinStyle:r.borderJoinStyle?r.borderJoinStyle:s.borderJoinStyle||l.borderJoinStyle}}),n.dataset.pivot(),i.each(o,(function(n,a){e.updateElement(n,a,t)}),e),e.updateBezierControlPoints()},updateElement:function(t,e,n){var a=this,o=t.custom||{},r=a.getDataset(),s=a.chart.scale,l=a.chart.options.elements.point,u=s.getPointPositionForValue(e,r.data[e]);void 0!==r.radius&&void 0===r.pointRadius&&(r.pointRadius=r.radius),void 0!==r.hitRadius&&void 0===r.pointHitRadius&&(r.pointHitRadius=r.hitRadius),i.extend(t,{_datasetIndex:a.index,_index:e,_scale:s,_model:{x:n?s.xCenter:u.x,y:n?s.yCenter:u.y,tension:o.tension?o.tension:i.valueOrDefault(r.lineTension,a.chart.options.elements.line.tension),radius:o.radius?o.radius:i.valueAtIndexOrDefault(r.pointRadius,e,l.radius),backgroundColor:o.backgroundColor?o.backgroundColor:i.valueAtIndexOrDefault(r.pointBackgroundColor,e,l.backgroundColor),borderColor:o.borderColor?o.borderColor:i.valueAtIndexOrDefault(r.pointBorderColor,e,l.borderColor),borderWidth:o.borderWidth?o.borderWidth:i.valueAtIndexOrDefault(r.pointBorderWidth,e,l.borderWidth),pointStyle:o.pointStyle?o.pointStyle:i.valueAtIndexOrDefault(r.pointStyle,e,l.pointStyle),hitRadius:o.hitRadius?o.hitRadius:i.valueAtIndexOrDefault(r.pointHitRadius,e,l.hitRadius)}}),t._model.skip=o.skip?o.skip:isNaN(t._model.x)||isNaN(t._model.y)},updateBezierControlPoints:function(){var t=this.chart.chartArea,e=this.getMeta();i.each(e.data,(function(n,a){var o=n._model,r=i.splineCurve(i.previousItem(e.data,a,!0)._model,o,i.nextItem(e.data,a,!0)._model,o.tension);o.controlPointPreviousX=Math.max(Math.min(r.previous.x,t.right),t.left),o.controlPointPreviousY=Math.max(Math.min(r.previous.y,t.bottom),t.top),o.controlPointNextX=Math.max(Math.min(r.next.x,t.right),t.left),o.controlPointNextY=Math.max(Math.min(r.next.y,t.bottom),t.top),n.pivot()}))},setHoverStyle:function(t){var e=this.chart.data.datasets[t._datasetIndex],n=t.custom||{},a=t._index,o=t._model;o.radius=n.hoverRadius?n.hoverRadius:i.valueAtIndexOrDefault(e.pointHoverRadius,a,this.chart.options.elements.point.hoverRadius),o.backgroundColor=n.hoverBackgroundColor?n.hoverBackgroundColor:i.valueAtIndexOrDefault(e.pointHoverBackgroundColor,a,i.getHoverColor(o.backgroundColor)),o.borderColor=n.hoverBorderColor?n.hoverBorderColor:i.valueAtIndexOrDefault(e.pointHoverBorderColor,a,i.getHoverColor(o.borderColor)),o.borderWidth=n.hoverBorderWidth?n.hoverBorderWidth:i.valueAtIndexOrDefault(e.pointHoverBorderWidth,a,o.borderWidth)},removeHoverStyle:function(t){var e=this.chart.data.datasets[t._datasetIndex],n=t.custom||{},a=t._index,o=t._model,r=this.chart.options.elements.point;o.radius=n.radius?n.radius:i.valueAtIndexOrDefault(e.pointRadius,a,r.radius),o.backgroundColor=n.backgroundColor?n.backgroundColor:i.valueAtIndexOrDefault(e.pointBackgroundColor,a,r.backgroundColor),o.borderColor=n.borderColor?n.borderColor:i.valueAtIndexOrDefault(e.pointBorderColor,a,r.borderColor),o.borderWidth=n.borderWidth?n.borderWidth:i.valueAtIndexOrDefault(e.pointBorderWidth,a,r.borderWidth)}})}},{25:25,40:40,45:45}],21:[function(t,e,n){"use strict";t(25)._set("scatter",{hover:{mode:"single"},scales:{xAxes:[{id:"x-axis-1",type:"linear",position:"bottom"}],yAxes:[{id:"y-axis-1",type:"linear",position:"left"}]},showLines:!1,tooltips:{callbacks:{title:function(){return""},label:function(t){return"("+t.xLabel+", "+t.yLabel+")"}}}}),e.exports=function(t){t.controllers.scatter=t.controllers.line}},{25:25}],22:[function(t,e,n){"use strict";var a=t(25),o=t(26),i=t(45);a._set("global",{animation:{duration:1e3,easing:"easeOutQuart",onProgress:i.noop,onComplete:i.noop}}),e.exports=function(t){t.Animation=o.extend({chart:null,currentStep:0,numSteps:60,easing:"",render:null,onAnimationProgress:null,onAnimationComplete:null}),t.animationService={frameDuration:17,animations:[],dropFrames:0,request:null,addAnimation:function(t,e,n,a){var o,i,r=this.animations;for(e.chart=t,a||(t.animating=!0),o=0,i=r.length;o1&&(n=Math.floor(t.dropFrames),t.dropFrames=t.dropFrames%1),t.advance(1+n);var a=Date.now();t.dropFrames+=(a-e)/t.frameDuration,t.animations.length>0&&t.requestAnimationFrame()},advance:function(t){for(var e,n,a=this.animations,o=0;o=e.numSteps?(i.callback(e.onAnimationComplete,[e],n),n.animating=!1,a.splice(o,1)):++o}},Object.defineProperty(t.Animation.prototype,"animationObject",{get:function(){return this}}),Object.defineProperty(t.Animation.prototype,"chartInstance",{get:function(){return this.chart},set:function(t){this.chart=t}})}},{25:25,26:26,45:45}],23:[function(t,e,n){"use strict";var a=t(25),o=t(45),i=t(28),r=t(48);e.exports=function(t){function e(t){var e=(t=t||{}).data=t.data||{};return e.datasets=e.datasets||[],e.labels=e.labels||[],t.options=o.configMerge(a.global,a[t.type],t.options||{}),t}function n(t){return"top"===t||"bottom"===t}var s=t.plugins;t.types={},t.instances={},t.controllers={},o.extend(t.prototype,{construct:function(n,a){var i=this;a=e(a);var s=r.acquireContext(n,a),l=s&&s.canvas,u=l&&l.height,c=l&&l.width;i.id=o.uid(),i.ctx=s,i.canvas=l,i.config=a,i.width=c,i.height=u,i.aspectRatio=u?c/u:null,i.options=a.options,i._bufferedRender=!1,i.chart=i,i.controller=i,t.instances[i.id]=i,Object.defineProperty(i,"data",{get:function(){return i.config.data},set:function(t){i.config.data=t}}),s&&l?(i.initialize(),i.update()):console.error("Failed to create chart: can't acquire context from the given item")},initialize:function(){var t=this;return s.notify(t,"beforeInit"),o.retinaScale(t,t.options.devicePixelRatio),t.bindEvents(),t.options.responsive&&t.resize(!0),t.ensureScalesHaveIDs(),t.buildScales(),t.initToolTip(),s.notify(t,"afterInit"),t},clear:function(){return o.canvas.clear(this),this},stop:function(){return t.animationService.cancelAnimation(this),this},resize:function(t){var e=this,n=e.options,a=e.canvas,i=n.maintainAspectRatio&&e.aspectRatio||null,r=Math.max(0,Math.floor(o.getMaximumWidth(a))),l=Math.max(0,Math.floor(i?r/i:o.getMaximumHeight(a)));if((e.width!==r||e.height!==l)&&(a.width=e.width=r,a.height=e.height=l,a.style.width=r+"px",a.style.height=l+"px",o.retinaScale(e,n.devicePixelRatio),!t)){var u={width:r,height:l};s.notify(e,"resize",[u]),e.options.onResize&&e.options.onResize(e,u),e.stop(),e.update(e.options.responsiveAnimationDuration)}},ensureScalesHaveIDs:function(){var t=this.options,e=t.scales||{},n=t.scale;o.each(e.xAxes,(function(t,e){t.id=t.id||"x-axis-"+e})),o.each(e.yAxes,(function(t,e){t.id=t.id||"y-axis-"+e})),n&&(n.id=n.id||"scale")},buildScales:function(){var e=this,a=e.options,i=e.scales={},r=[];a.scales&&(r=r.concat((a.scales.xAxes||[]).map((function(t){return{options:t,dtype:"category",dposition:"bottom"}})),(a.scales.yAxes||[]).map((function(t){return{options:t,dtype:"linear",dposition:"left"}})))),a.scale&&r.push({options:a.scale,dtype:"radialLinear",isDefault:!0,dposition:"chartArea"}),o.each(r,(function(a){var r=a.options,s=o.valueOrDefault(r.type,a.dtype),l=t.scaleService.getScaleConstructor(s);if(l){n(r.position)!==n(a.dposition)&&(r.position=a.dposition);var u=new l({id:r.id,options:r,ctx:e.ctx,chart:e});i[u.id]=u,u.mergeTicksOptions(),a.isDefault&&(e.scale=u)}})),t.scaleService.addScalesToLayout(this)},buildOrUpdateControllers:function(){var e=this,n=[],a=[];return o.each(e.data.datasets,(function(o,i){var r=e.getDatasetMeta(i),s=o.type||e.config.type;if(r.type&&r.type!==s&&(e.destroyDatasetMeta(i),r=e.getDatasetMeta(i)),r.type=s,n.push(r.type),r.controller)r.controller.updateIndex(i);else{var l=t.controllers[r.type];if(void 0===l)throw new Error('"'+r.type+'" is not a chart type.');r.controller=new l(e,i),a.push(r.controller)}}),e),a},resetElements:function(){var t=this;o.each(t.data.datasets,(function(e,n){t.getDatasetMeta(n).controller.reset()}),t)},reset:function(){this.resetElements(),this.tooltip.initialize()},update:function(t){var e=this;if(t&&"object"==l(t)||(t={duration:t,lazy:arguments[1]}),function(t){var e=t.options;e.scale?t.scale.options=e.scale:e.scales&&e.scales.xAxes.concat(e.scales.yAxes).forEach((function(e){t.scales[e.id].options=e})),t.tooltip._options=e.tooltips}(e),!1!==s.notify(e,"beforeUpdate")){e.tooltip._data=e.data;var n=e.buildOrUpdateControllers();o.each(e.data.datasets,(function(t,n){e.getDatasetMeta(n).controller.buildOrUpdateElements()}),e),e.updateLayout(),o.each(n,(function(t){t.reset()})),e.updateDatasets(),s.notify(e,"afterUpdate"),e._bufferedRender?e._bufferedRequest={duration:t.duration,easing:t.easing,lazy:t.lazy}:e.render(t)}},updateLayout:function(){var e=this;!1!==s.notify(e,"beforeLayout")&&(t.layoutService.update(this,this.width,this.height),s.notify(e,"afterScaleUpdate"),s.notify(e,"afterLayout"))},updateDatasets:function(){var t=this;if(!1!==s.notify(t,"beforeDatasetsUpdate")){for(var e=0,n=t.data.datasets.length;e=0;--n)e.isDatasetVisible(n)&&e.drawDataset(n,t);s.notify(e,"afterDatasetsDraw",[t])}},drawDataset:function(t,e){var n=this,a=n.getDatasetMeta(t),o={meta:a,index:t,easingValue:e};!1!==s.notify(n,"beforeDatasetDraw",[o])&&(a.controller.draw(e),s.notify(n,"afterDatasetDraw",[o]))},getElementAtEvent:function(t){return i.modes.single(this,t)},getElementsAtEvent:function(t){return i.modes.label(this,t,{intersect:!0})},getElementsAtXAxis:function(t){return i.modes["x-axis"](this,t,{intersect:!0})},getElementsAtEventForMode:function(t,e,n){var a=i.modes[e];return"function"==typeof a?a(this,t,n):[]},getDatasetAtEvent:function(t){return i.modes.dataset(this,t,{intersect:!0})},getDatasetMeta:function(t){var e=this,n=e.data.datasets[t];n._meta||(n._meta={});var a=n._meta[e.id];return a||(a=n._meta[e.id]={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null}),a},getVisibleDatasetCount:function(){for(var t=0,e=0,n=this.data.datasets.length;e0||(o.forEach((function(e){delete t[e]})),delete t._chartjs)}}var o=["push","pop","shift","splice","unshift"];t.DatasetController=function(t,e){this.initialize(t,e)},a.extend(t.DatasetController.prototype,{datasetElementType:null,dataElementType:null,initialize:function(t,e){var n=this;n.chart=t,n.index=e,n.linkScales(),n.addElements()},updateIndex:function(t){this.index=t},linkScales:function(){var t=this,e=t.getMeta(),n=t.getDataset();null===e.xAxisID&&(e.xAxisID=n.xAxisID||t.chart.options.scales.xAxes[0].id),null===e.yAxisID&&(e.yAxisID=n.yAxisID||t.chart.options.scales.yAxes[0].id)},getDataset:function(){return this.chart.data.datasets[this.index]},getMeta:function(){return this.chart.getDatasetMeta(this.index)},getScaleForId:function(t){return this.chart.scales[t]},reset:function(){this.update(!0)},destroy:function(){this._data&&n(this._data,this)},createMetaDataset:function(){var t=this,e=t.datasetElementType;return e&&new e({_chart:t.chart,_datasetIndex:t.index})},createMetaData:function(t){var e=this,n=e.dataElementType;return n&&new n({_chart:e.chart,_datasetIndex:e.index,_index:t})},addElements:function(){var t,e,n=this,a=n.getMeta(),o=n.getDataset().data||[],i=a.data;for(t=0,e=o.length;ta&&t.insertElements(a,o-a)},insertElements:function(t,e){for(var n=0;n=n[e].length&&n[e].push({}),!n[e][r].type||l.type&&l.type!==n[e][r].type?i.merge(n[e][r],[t.scaleService.getScaleDefaults(s),l]):i.merge(n[e][r],l)}else i._merger(e,n,a,o)}})},i.where=function(t,e){if(i.isArray(t)&&Array.prototype.filter)return t.filter(e);var n=[];return i.each(t,(function(t){e(t)&&n.push(t)})),n},i.findIndex=Array.prototype.findIndex?function(t,e,n){return t.findIndex(e,n)}:function(t,e,n){n=void 0===n?t:n;for(var a=0,o=t.length;a=0;a--){var o=t[a];if(e(o))return o}},i.inherits=function(t){var e=this,n=t&&t.hasOwnProperty("constructor")?t.constructor:function(){return e.apply(this,arguments)},a=function(){this.constructor=n};return a.prototype=e.prototype,n.prototype=new a,n.extend=i.inherits,t&&i.extend(n.prototype,t),n.__super__=e.prototype,n},i.isNumber=function(t){return!isNaN(parseFloat(t))&&isFinite(t)},i.almostEquals=function(t,e,n){return Math.abs(t-e)t},i.max=function(t){return t.reduce((function(t,e){return isNaN(e)?t:Math.max(t,e)}),Number.NEGATIVE_INFINITY)},i.min=function(t){return t.reduce((function(t,e){return isNaN(e)?t:Math.min(t,e)}),Number.POSITIVE_INFINITY)},i.sign=Math.sign?function(t){return Math.sign(t)}:function(t){return 0==(t=+t)||isNaN(t)?t:t>0?1:-1},i.log10=Math.log10?function(t){return Math.log10(t)}:function(t){return Math.log(t)/Math.LN10},i.toRadians=function(t){return t*(Math.PI/180)},i.toDegrees=function(t){return t*(180/Math.PI)},i.getAngleFromPoint=function(t,e){var n=e.x-t.x,a=e.y-t.y,o=Math.sqrt(n*n+a*a),i=Math.atan2(a,n);return i<-.5*Math.PI&&(i+=2*Math.PI),{angle:i,distance:o}},i.distanceBetweenPoints=function(t,e){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))},i.aliasPixel=function(t){return t%2==0?0:.5},i.splineCurve=function(t,e,n,a){var o=t.skip?e:t,i=e,r=n.skip?e:n,s=Math.sqrt(Math.pow(i.x-o.x,2)+Math.pow(i.y-o.y,2)),l=Math.sqrt(Math.pow(r.x-i.x,2)+Math.pow(r.y-i.y,2)),u=s/(s+l),c=l/(s+l),d=a*(u=isNaN(u)?0:u),h=a*(c=isNaN(c)?0:c);return{previous:{x:i.x-d*(r.x-o.x),y:i.y-d*(r.y-o.y)},next:{x:i.x+h*(r.x-o.x),y:i.y+h*(r.y-o.y)}}},i.EPSILON=Number.EPSILON||1e-14,i.splineCurveMonotone=function(t){var e,n,a,o,r,s,l,u,c,d=(t||[]).map((function(t){return{model:t._model,deltaK:0,mK:0}})),h=d.length;for(e=0;e0?d[e-1]:null,(o=e0?d[e-1]:null,o=e=t.length-1?t[0]:t[e+1]:e>=t.length-1?t[t.length-1]:t[e+1]},i.previousItem=function(t,e,n){return n?e<=0?t[t.length-1]:t[e-1]:e<=0?t[0]:t[e-1]},i.niceNum=function(t,e){var n=Math.floor(i.log10(t)),a=t/Math.pow(10,n);return(e?a<1.5?1:a<3?2:a<7?5:10:a<=1?1:a<=2?2:a<=5?5:10)*Math.pow(10,n)},i.requestAnimFrame="undefined"==typeof window?function(t){t()}:window.requestAnimationFrame||window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.oRequestAnimationFrame||window.msRequestAnimationFrame||function(t){return window.setTimeout(t,1e3/60)},i.getRelativePosition=function(t,e){var n,a,o=t.originalEvent||t,r=t.currentTarget||t.srcElement,s=r.getBoundingClientRect(),l=o.touches;l&&l.length>0?(n=l[0].clientX,a=l[0].clientY):(n=o.clientX,a=o.clientY);var u=parseFloat(i.getStyle(r,"padding-left")),c=parseFloat(i.getStyle(r,"padding-top")),d=parseFloat(i.getStyle(r,"padding-right")),h=parseFloat(i.getStyle(r,"padding-bottom")),f=s.right-s.left-u-d,p=s.bottom-s.top-c-h;return{x:n=Math.round((n-s.left-u)/f*r.width/e.currentDevicePixelRatio),y:a=Math.round((a-s.top-c)/p*r.height/e.currentDevicePixelRatio)}},i.getConstraintWidth=function(t){return r(t,"max-width","clientWidth")},i.getConstraintHeight=function(t){return r(t,"max-height","clientHeight")},i.getMaximumWidth=function(t){var e=t.parentNode;if(!e)return t.clientWidth;var n=parseInt(i.getStyle(e,"padding-left"),10),a=parseInt(i.getStyle(e,"padding-right"),10),o=e.clientWidth-n-a,r=i.getConstraintWidth(t);return isNaN(r)?o:Math.min(o,r)},i.getMaximumHeight=function(t){var e=t.parentNode;if(!e)return t.clientHeight;var n=parseInt(i.getStyle(e,"padding-top"),10),a=parseInt(i.getStyle(e,"padding-bottom"),10),o=e.clientHeight-n-a,r=i.getConstraintHeight(t);return isNaN(r)?o:Math.min(o,r)},i.getStyle=function(t,e){return t.currentStyle?t.currentStyle[e]:document.defaultView.getComputedStyle(t,null).getPropertyValue(e)},i.retinaScale=function(t,e){var n=t.currentDevicePixelRatio=e||window.devicePixelRatio||1;if(1!==n){var a=t.canvas,o=t.height,i=t.width;a.height=o*n,a.width=i*n,t.ctx.scale(n,n),a.style.height=o+"px",a.style.width=i+"px"}},i.fontString=function(t,e,n){return e+" "+t+"px "+n},i.longestText=function(t,e,n,a){var o=(a=a||{}).data=a.data||{},r=a.garbageCollect=a.garbageCollect||[];a.font!==e&&(o=a.data={},r=a.garbageCollect=[],a.font=e),t.font=e;var s=0;i.each(n,(function(e){null!=e&&!0!==i.isArray(e)?s=i.measureText(t,o,r,s,e):i.isArray(e)&&i.each(e,(function(e){null==e||i.isArray(e)||(s=i.measureText(t,o,r,s,e))}))}));var l=r.length/2;if(l>n.length){for(var u=0;ua&&(a=i),a},i.numberOfLabelLines=function(t){var e=1;return i.each(t,(function(t){i.isArray(t)&&t.length>e&&(e=t.length)})),e},i.color=a?function(t){return t instanceof CanvasGradient&&(t=o.global.defaultColor),a(t)}:function(t){return console.error("Color.js not found!"),t},i.getHoverColor=function(t){return t instanceof CanvasPattern?t:i.color(t).saturate(.5).darken(.1).rgbString()}}},{25:25,3:3,45:45}],28:[function(t,e,n){"use strict";function a(t,e){return t.native?{x:t.x,y:t.y}:u.getRelativePosition(t,e)}function o(t,e){var n,a,o,i,r;for(a=0,i=t.data.datasets.length;a0&&(u=t.getDatasetMeta(u[0]._datasetIndex).data),u},"x-axis":function(t,e){return l(t,e,{intersect:!0})},point:function(t,e){return i(t,a(e,t))},nearest:function(t,e,n){var o=a(e,t);n.axis=n.axis||"xy";var i=s(n.axis),l=r(t,o,n.intersect,i);return l.length>1&&l.sort((function(t,e){var n=t.getArea()-e.getArea();return 0===n&&(n=t._datasetIndex-e._datasetIndex),n})),l.slice(0,1)},x:function(t,e,n){var i=a(e,t),r=[],s=!1;return o(t,(function(t){t.inXRange(i.x)&&r.push(t),t.inRange(i.x,i.y)&&(s=!0)})),n.intersect&&!s&&(r=[]),r},y:function(t,e,n){var i=a(e,t),r=[],s=!1;return o(t,(function(t){t.inYRange(i.y)&&r.push(t),t.inRange(i.x,i.y)&&(s=!0)})),n.intersect&&!s&&(r=[]),r}}}},{45:45}],29:[function(t,e,n){"use strict";t(25)._set("global",{responsive:!0,responsiveAnimationDuration:0,maintainAspectRatio:!0,events:["mousemove","mouseout","click","touchstart","touchmove"],hover:{onHover:null,mode:"nearest",intersect:!0,animationDuration:400},onClick:null,defaultColor:"rgba(0,0,0,0.1)",defaultFontColor:"#666",defaultFontFamily:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",defaultFontSize:12,defaultFontStyle:"normal",showLines:!0,elements:{},layout:{padding:{top:0,right:0,bottom:0,left:0}}}),e.exports=function(){var t=function(t,e){return this.construct(t,e),this};return t.Chart=t,t}},{25:25}],30:[function(t,e,n){"use strict";var a=t(45);e.exports=function(t){function e(t,e){return a.where(t,(function(t){return t.position===e}))}function n(t,e){t.forEach((function(t,e){return t._tmpIndex_=e,t})),t.sort((function(t,n){var a=e?n:t,o=e?t:n;return a.weight===o.weight?a._tmpIndex_-o._tmpIndex_:a.weight-o.weight})),t.forEach((function(t){delete t._tmpIndex_}))}t.layoutService={defaults:{},addBox:function(t,e){t.boxes||(t.boxes=[]),e.fullWidth=e.fullWidth||!1,e.position=e.position||"top",e.weight=e.weight||0,t.boxes.push(e)},removeBox:function(t,e){var n=t.boxes?t.boxes.indexOf(e):-1;-1!==n&&t.boxes.splice(n,1)},configure:function(t,e,n){for(var a,o=["fullWidth","position","weight"],i=o.length,r=0;rh&&lt.maxHeight){l--;break}l++,d=u*c}t.labelRotation=l},afterCalculateTickRotation:function(){s.callback(this.options.afterCalculateTickRotation,[this])},beforeFit:function(){s.callback(this.options.beforeFit,[this])},fit:function(){var t=this,o=t.minSize={width:0,height:0},i=a(t._ticks),r=t.options,u=r.ticks,c=r.scaleLabel,d=r.gridLines,h=r.display,f=t.isHorizontal(),p=n(u),g=r.gridLines.tickMarkLength;if(o.width=f?t.isFullWidth()?t.maxWidth-t.margins.left-t.margins.right:t.maxWidth:h&&d.drawTicks?g:0,o.height=f?h&&d.drawTicks?g:0:t.maxHeight,c.display&&h){var v=l(c)+s.options.toPadding(c.padding).height;f?o.height+=v:o.width+=v}if(u.display&&h){var m=s.longestText(t.ctx,p.font,i,t.longestTextCache),b=s.numberOfLabelLines(i),x=.5*p.size,y=t.options.ticks.padding;if(f){t.longestLabelWidth=m;var k=s.toRadians(t.labelRotation),w=Math.cos(k),C=Math.sin(k)*m+p.size*b+x*(b-1)+x;o.height=Math.min(t.maxHeight,o.height+C+y),t.ctx.font=p.font;var S=e(t.ctx,i[0],p.font),M=e(t.ctx,i[i.length-1],p.font);0!==t.labelRotation?(t.paddingLeft="bottom"===r.position?w*S+3:w*x+3,t.paddingRight="bottom"===r.position?w*x+3:w*M+3):(t.paddingLeft=S/2+3,t.paddingRight=M/2+3)}else u.mirror?m=0:m+=y+x,o.width=Math.min(t.maxWidth,o.width+m),t.paddingTop=p.size/2,t.paddingBottom=p.size/2}t.handleMargins(),t.width=o.width,t.height=o.height},handleMargins:function(){var t=this;t.margins&&(t.paddingLeft=Math.max(t.paddingLeft-t.margins.left,0),t.paddingTop=Math.max(t.paddingTop-t.margins.top,0),t.paddingRight=Math.max(t.paddingRight-t.margins.right,0),t.paddingBottom=Math.max(t.paddingBottom-t.margins.bottom,0))},afterFit:function(){s.callback(this.options.afterFit,[this])},isHorizontal:function(){return"top"===this.options.position||"bottom"===this.options.position},isFullWidth:function(){return this.options.fullWidth},getRightValue:function(t){if(s.isNullOrUndef(t))return NaN;if("number"==typeof t&&!isFinite(t))return NaN;if(t)if(this.isHorizontal()){if(void 0!==t.x)return this.getRightValue(t.x)}else if(void 0!==t.y)return this.getRightValue(t.y);return t},getLabelForIndex:s.noop,getPixelForValue:s.noop,getValueForPixel:s.noop,getPixelForTick:function(t){var e=this,n=e.options.offset;if(e.isHorizontal()){var a=(e.width-(e.paddingLeft+e.paddingRight))/Math.max(e._ticks.length-(n?0:1),1),o=a*t+e.paddingLeft;return n&&(o+=a/2),e.left+Math.round(o)+(e.isFullWidth()?e.margins.left:0)}var i=e.height-(e.paddingTop+e.paddingBottom);return e.top+t*(i/(e._ticks.length-1))},getPixelForDecimal:function(t){var e=this;if(e.isHorizontal()){var n=(e.width-(e.paddingLeft+e.paddingRight))*t+e.paddingLeft;return e.left+Math.round(n)+(e.isFullWidth()?e.margins.left:0)}return e.top+t*e.height},getBasePixel:function(){return this.getPixelForValue(this.getBaseValue())},getBaseValue:function(){var t=this,e=t.min,n=t.max;return t.beginAtZero?0:e<0&&n<0?n:e>0&&n>0?e:0},_autoSkip:function(t){var e,n,a,o,i=this,r=i.isHorizontal(),l=i.options.ticks.minor,u=t.length,c=s.toRadians(i.labelRotation),d=Math.cos(c),h=i.longestLabelWidth*d,f=[];for(l.maxTicksLimit&&(o=l.maxTicksLimit),r&&(e=!1,(h+l.autoSkipPadding)*u>i.width-(i.paddingLeft+i.paddingRight)&&(e=1+Math.floor((h+l.autoSkipPadding)*u/(i.width-(i.paddingLeft+i.paddingRight)))),o&&u>o&&(e=Math.max(e,Math.floor(u/o)))),n=0;n1&&n%e>0||n%e==0&&n+e>=u)&&n!==u-1||s.isNullOrUndef(a.label))&&delete a.label,f.push(a);return f},draw:function(t){var e=this,a=e.options;if(a.display){var r=e.ctx,u=i.global,c=a.ticks.minor,d=a.ticks.major||c,h=a.gridLines,f=a.scaleLabel,p=0!==e.labelRotation,g=e.isHorizontal(),v=c.autoSkip?e._autoSkip(e.getTicks()):e.getTicks(),m=s.valueOrDefault(c.fontColor,u.defaultFontColor),b=n(c),x=s.valueOrDefault(d.fontColor,u.defaultFontColor),y=n(d),k=h.drawTicks?h.tickMarkLength:0,w=s.valueOrDefault(f.fontColor,u.defaultFontColor),C=n(f),S=s.options.toPadding(f.padding),M=s.toRadians(e.labelRotation),_=[],I="right"===a.position?e.left:e.right-k,D="right"===a.position?e.left+k:e.right,P="bottom"===a.position?e.top:e.bottom-k,A="bottom"===a.position?e.top+k:e.bottom;if(s.each(v,(function(n,i){if(void 0!==n.label){var r,l,d,f,m=n.label;i===e.zeroLineIndex&&a.offset===h.offsetGridLines?(r=h.zeroLineWidth,l=h.zeroLineColor,d=h.zeroLineBorderDash,f=h.zeroLineBorderDashOffset):(r=s.valueAtIndexOrDefault(h.lineWidth,i),l=s.valueAtIndexOrDefault(h.color,i),d=s.valueOrDefault(h.borderDash,u.borderDash),f=s.valueOrDefault(h.borderDashOffset,u.borderDashOffset));var b,x,y,w,C,S,T,L,F,$,O="middle",z="middle",R=c.padding;if(g){var j=k+R;"bottom"===a.position?(z=p?"middle":"top",O=p?"right":"center",$=e.top+j):(z=p?"middle":"bottom",O=p?"left":"center",$=e.bottom-j);var B=o(e,i,h.offsetGridLines&&v.length>1);B1);E0)n=t.stepSize;else{var i=a.niceNum(e.max-e.min,!1);n=a.niceNum(i/(t.maxTicks-1),!0)}var r=Math.floor(e.min/n)*n,s=Math.ceil(e.max/n)*n;t.min&&t.max&&t.stepSize&&a.almostWhole((t.max-t.min)/t.stepSize,n/1e3)&&(r=t.min,s=t.max);var l=(s-r)/n;l=a.almostEquals(l,Math.round(l),n/1e3)?Math.round(l):Math.ceil(l),o.push(void 0!==t.min?t.min:r);for(var u=1;u3?n[2]-n[1]:n[1]-n[0];Math.abs(o)>1&&t!==Math.floor(t)&&(o=t-Math.floor(t));var i=a.log10(Math.abs(o)),r="";if(0!==t){var s=-1*Math.floor(i);s=Math.max(Math.min(s,20),0),r=t.toFixed(s)}else r="0";return r},logarithmic:function(t,e,n){var o=t/Math.pow(10,Math.floor(a.log10(t)));return 0===t?"0":1===o||2===o||5===o||0===e||e===n.length-1?t.toExponential():""}}}},{45:45}],35:[function(t,e,n){"use strict";var a=t(25),o=t(26),i=t(45);a._set("global",{tooltips:{enabled:!0,custom:null,mode:"nearest",position:"average",intersect:!0,backgroundColor:"rgba(0,0,0,0.8)",titleFontStyle:"bold",titleSpacing:2,titleMarginBottom:6,titleFontColor:"#fff",titleAlign:"left",bodySpacing:2,bodyFontColor:"#fff",bodyAlign:"left",footerFontStyle:"bold",footerSpacing:2,footerMarginTop:6,footerFontColor:"#fff",footerAlign:"left",yPadding:6,xPadding:6,caretPadding:2,caretSize:5,cornerRadius:6,multiKeyBackground:"#fff",displayColors:!0,borderColor:"rgba(0,0,0,0)",borderWidth:0,callbacks:{beforeTitle:i.noop,title:function(t,e){var n="",a=e.labels,o=a?a.length:0;if(t.length>0){var i=t[0];i.xLabel?n=i.xLabel:o>0&&i.indexa.height-e.height&&(r="bottom");var s,l,u,c,d,h=(o.left+o.right)/2,f=(o.top+o.bottom)/2;"center"===r?(s=function(t){return t<=h},l=function(t){return t>h}):(s=function(t){return t<=e.width/2},l=function(t){return t>=a.width-e.width/2}),u=function(t){return t+e.width>a.width},c=function(t){return t-e.width<0},d=function(t){return t<=f?"top":"bottom"},s(n.x)?(i="left",u(n.x)&&(i="center",r=d(n.y))):l(n.x)&&(i="right",c(n.x)&&(i="center",r=d(n.y)));var p=t._options;return{xAlign:p.xAlign?p.xAlign:i,yAlign:p.yAlign?p.yAlign:r}}(this,g))}else c.opacity=0;return c.xAlign=f.xAlign,c.yAlign=f.yAlign,c.x=p.x,c.y=p.y,c.width=g.width,c.height=g.height,c.caretX=v.x,c.caretY=v.y,o._model=c,e&&l.custom&&l.custom.call(o,c),o},drawCaret:function(t,e){var n=this._chart.ctx,a=this._view,o=this.getCaretPosition(t,e,a);n.lineTo(o.x1,o.y1),n.lineTo(o.x2,o.y2),n.lineTo(o.x3,o.y3)},getCaretPosition:function(t,e,n){var a,o,i,r,s,l,u=n.caretSize,c=n.cornerRadius,d=n.xAlign,h=n.yAlign,f=t.x,p=t.y,g=e.width,v=e.height;if("center"===h)s=p+v/2,"left"===d?(o=(a=f)-u,i=a,r=s+u,l=s-u):(o=(a=f+g)+u,i=a,r=s-u,l=s+u);else if("left"===d?(a=(o=f+c+u)-u,i=o+u):"right"===d?(a=(o=f+g-c-u)-u,i=o+u):(a=(o=f+g/2)-u,i=o+u),"top"===h)s=(r=p)-u,l=r;else{s=(r=p+v)+u,l=r;var m=i;i=a,a=m}return{x1:a,x2:o,x3:i,y1:r,y2:s,y3:l}},drawTitle:function(t,n,a,o){var r=n.title;if(r.length){a.textAlign=n._titleAlign,a.textBaseline="top";var s,l,u=n.titleFontSize,c=n.titleSpacing;for(a.fillStyle=e(n.titleFontColor,o),a.font=i.fontString(u,n._titleFontStyle,n._titleFontFamily),s=0,l=r.length;s0&&a.stroke()},draw:function(){var t=this._chart.ctx,e=this._view;if(0!==e.opacity){var n={width:e.width,height:e.height},a={x:e.x,y:e.y},o=Math.abs(e.opacity<.001)?0:e.opacity,i=e.title.length||e.beforeBody.length||e.body.length||e.afterBody.length||e.footer.length;this._options.enabled&&i&&(this.drawBackground(a,e,t,n,o),a.x+=e.xPadding,a.y+=e.yPadding,this.drawTitle(a,e,t,o),this.drawBody(a,e,t,o),this.drawFooter(a,e,t,o))}},handleEvent:function(t){var e=this,n=e._options,a=!1;if(e._lastActive=e._lastActive||[],"mouseout"===t.type?e._active=[]:e._active=e._chart.getElementsAtEventForMode(t,n.mode,n),!(a=!i.arrayEquals(e._active,e._lastActive)))return!1;if(e._lastActive=e._active,n.enabled||n.custom){e._eventPosition={x:t.x,y:t.y};var o=e._model;e.update(!0),e.pivot(),a|=o.x!==e._model.x||o.y!==e._model.y}return a}}),t.Tooltip.positioners={average:function(t){if(!t.length)return!1;var e,n,a=0,o=0,i=0;for(e=0,n=t.length;el;)o-=2*Math.PI;for(;o=s&&o<=l,c=r>=n.innerRadius&&r<=n.outerRadius;return u&&c}return!1},getCenterPoint:function(){var t=this._view,e=(t.startAngle+t.endAngle)/2,n=(t.innerRadius+t.outerRadius)/2;return{x:t.x+Math.cos(e)*n,y:t.y+Math.sin(e)*n}},getArea:function(){var t=this._view;return Math.PI*((t.endAngle-t.startAngle)/(2*Math.PI))*(Math.pow(t.outerRadius,2)-Math.pow(t.innerRadius,2))},tooltipPosition:function(){var t=this._view,e=t.startAngle+(t.endAngle-t.startAngle)/2,n=(t.outerRadius-t.innerRadius)/2+t.innerRadius;return{x:t.x+Math.cos(e)*n,y:t.y+Math.sin(e)*n}},draw:function(){var t=this._chart.ctx,e=this._view,n=e.startAngle,a=e.endAngle;t.beginPath(),t.arc(e.x,e.y,e.outerRadius,n,a),t.arc(e.x,e.y,e.innerRadius,a,n,!0),t.closePath(),t.strokeStyle=e.borderColor,t.lineWidth=e.borderWidth,t.fillStyle=e.backgroundColor,t.fill(),t.lineJoin="bevel",e.borderWidth&&t.stroke()}})},{25:25,26:26,45:45}],37:[function(t,e,n){"use strict";var a=t(25),o=t(26),i=t(45),r=a.global;a._set("global",{elements:{line:{tension:.4,backgroundColor:r.defaultColor,borderWidth:3,borderColor:r.defaultColor,borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",capBezierPoints:!0,fill:!0}}}),e.exports=o.extend({draw:function(){var t,e,n,a,o=this,s=o._view,l=o._chart.ctx,u=s.spanGaps,c=o._children.slice(),d=r.elements.line,h=-1;for(o._loop&&c.length&&c.push(c[0]),l.save(),l.lineCap=s.borderCapStyle||d.borderCapStyle,l.setLineDash&&l.setLineDash(s.borderDash||d.borderDash),l.lineDashOffset=s.borderDashOffset||d.borderDashOffset,l.lineJoin=s.borderJoinStyle||d.borderJoinStyle,l.lineWidth=s.borderWidth||d.borderWidth,l.strokeStyle=s.borderColor||r.defaultColor,l.beginPath(),h=-1,t=0;te?1:-1,r=1,s=u.borderSkipped||"left"):(e=u.x-u.width/2,n=u.x+u.width/2,a=u.y,i=1,r=(o=u.base)>a?1:-1,s=u.borderSkipped||"bottom"),c){var d=Math.min(Math.abs(e-n),Math.abs(a-o)),h=(c=c>d?d:c)/2,f=e+("left"!==s?h*i:0),p=n+("right"!==s?-h*i:0),g=a+("top"!==s?h*r:0),v=o+("bottom"!==s?-h*r:0);f!==p&&(a=g,o=v),g!==v&&(e=f,n=p)}l.beginPath(),l.fillStyle=u.backgroundColor,l.strokeStyle=u.borderColor,l.lineWidth=c;var m=[[e,o],[e,a],[n,a],[n,o]],b=["bottom","left","top","right"].indexOf(s,0);-1===b&&(b=0);var x=t(0);l.moveTo(x[0],x[1]);for(var y=1;y<4;y++)x=t(y),l.lineTo(x[0],x[1]);l.fill(),c&&l.stroke()},height:function(){var t=this._view;return t.base-t.y},inRange:function(t,e){var n=!1;if(this._view){var a=o(this);n=t>=a.left&&t<=a.right&&e>=a.top&&e<=a.bottom}return n},inLabelRange:function(t,e){var n=this;if(!n._view)return!1;var i=o(n);return a(n)?t>=i.left&&t<=i.right:e>=i.top&&e<=i.bottom},inXRange:function(t){var e=o(this);return t>=e.left&&t<=e.right},inYRange:function(t){var e=o(this);return t>=e.top&&t<=e.bottom},getCenterPoint:function(){var t,e,n=this._view;return a(this)?(t=n.x,e=(n.y+n.base)/2):(t=(n.x+n.base)/2,e=n.y),{x:t,y:e}},getArea:function(){var t=this._view;return t.width*Math.abs(t.y-t.base)},tooltipPosition:function(){var t=this._view;return{x:t.x,y:t.y}}})},{25:25,26:26}],40:[function(t,e,n){"use strict";e.exports={},e.exports.Arc=t(36),e.exports.Line=t(37),e.exports.Point=t(38),e.exports.Rectangle=t(39)},{36:36,37:37,38:38,39:39}],41:[function(t,e,n){"use strict";var a=t(42);n=e.exports={clear:function(t){t.ctx.clearRect(0,0,t.width,t.height)},roundedRect:function(t,e,n,a,o,i){if(i){var r=Math.min(i,a/2),s=Math.min(i,o/2);t.moveTo(e+r,n),t.lineTo(e+a-r,n),t.quadraticCurveTo(e+a,n,e+a,n+s),t.lineTo(e+a,n+o-s),t.quadraticCurveTo(e+a,n+o,e+a-r,n+o),t.lineTo(e+r,n+o),t.quadraticCurveTo(e,n+o,e,n+o-s),t.lineTo(e,n+s),t.quadraticCurveTo(e,n,e+r,n)}else t.rect(e,n,a,o)},drawPoint:function(t,e,n,a,o){var i,r,s,u,c,d;if("object"!=l(e)||"[object HTMLImageElement]"!==(i=e.toString())&&"[object HTMLCanvasElement]"!==i){if(!(isNaN(n)||n<=0)){switch(e){default:t.beginPath(),t.arc(a,o,n,0,2*Math.PI),t.closePath(),t.fill();break;case"triangle":t.beginPath(),c=(r=3*n/Math.sqrt(3))*Math.sqrt(3)/2,t.moveTo(a-r/2,o+c/3),t.lineTo(a+r/2,o+c/3),t.lineTo(a,o-2*c/3),t.closePath(),t.fill();break;case"rect":d=1/Math.SQRT2*n,t.beginPath(),t.fillRect(a-d,o-d,2*d,2*d),t.strokeRect(a-d,o-d,2*d,2*d);break;case"rectRounded":var h=n/Math.SQRT2,f=a-h,p=o-h,g=Math.SQRT2*n;t.beginPath(),this.roundedRect(t,f,p,g,g,n/2),t.closePath(),t.fill();break;case"rectRot":d=1/Math.SQRT2*n,t.beginPath(),t.moveTo(a-d,o),t.lineTo(a,o+d),t.lineTo(a+d,o),t.lineTo(a,o-d),t.closePath(),t.fill();break;case"cross":t.beginPath(),t.moveTo(a,o+n),t.lineTo(a,o-n),t.moveTo(a-n,o),t.lineTo(a+n,o),t.closePath();break;case"crossRot":t.beginPath(),s=Math.cos(Math.PI/4)*n,u=Math.sin(Math.PI/4)*n,t.moveTo(a-s,o-u),t.lineTo(a+s,o+u),t.moveTo(a-s,o+u),t.lineTo(a+s,o-u),t.closePath();break;case"star":t.beginPath(),t.moveTo(a,o+n),t.lineTo(a,o-n),t.moveTo(a-n,o),t.lineTo(a+n,o),s=Math.cos(Math.PI/4)*n,u=Math.sin(Math.PI/4)*n,t.moveTo(a-s,o-u),t.lineTo(a+s,o+u),t.moveTo(a-s,o+u),t.lineTo(a+s,o-u),t.closePath();break;case"line":t.beginPath(),t.moveTo(a-n,o),t.lineTo(a+n,o),t.closePath();break;case"dash":t.beginPath(),t.moveTo(a,o),t.lineTo(a+n,o),t.closePath()}t.stroke()}}else t.drawImage(e,a-e.width/2,o-e.height/2,e.width,e.height)},clipArea:function(t,e){t.save(),t.beginPath(),t.rect(e.left,e.top,e.right-e.left,e.bottom-e.top),t.clip()},unclipArea:function(t){t.restore()},lineTo:function(t,e,n,a){if(n.steppedLine)return"after"===n.steppedLine&&!a||"after"!==n.steppedLine&&a?t.lineTo(e.x,n.y):t.lineTo(n.x,e.y),void t.lineTo(n.x,n.y);n.tension?t.bezierCurveTo(a?e.controlPointPreviousX:e.controlPointNextX,a?e.controlPointPreviousY:e.controlPointNextY,a?n.controlPointNextX:n.controlPointPreviousX,a?n.controlPointNextY:n.controlPointPreviousY,n.x,n.y):t.lineTo(n.x,n.y)}},a.clear=n.clear,a.drawRoundedRectangle=function(t){t.beginPath(),n.roundedRect.apply(n,arguments),t.closePath()}},{42:42}],42:[function(t,e,n){"use strict";var a={noop:function(){},uid:function(){var t=0;return function(){return t++}}(),isNullOrUndef:function(t){return null==t},isArray:Array.isArray?Array.isArray:function(t){return"[object Array]"===Object.prototype.toString.call(t)},isObject:function(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)},valueOrDefault:function(t,e){return void 0===t?e:t},valueAtIndexOrDefault:function(t,e,n){return a.valueOrDefault(a.isArray(t)?t[e]:t,n)},callback:function(t,e,n){if(t&&"function"==typeof t.call)return t.apply(n,e)},each:function(t,e,n,o){var i,r,s;if(a.isArray(t))if(r=t.length,o)for(i=r-1;i>=0;i--)e.call(n,t[i],i);else for(i=0;i=1?t:-(Math.sqrt(1-t*t)-1)},easeOutCirc:function(t){return Math.sqrt(1-(t-=1)*t)},easeInOutCirc:function(t){return(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},easeInElastic:function(t){var e=1.70158,n=0,a=1;return 0===t?0:1===t?1:(n||(n=.3),a<1?(a=1,e=n/4):e=n/(2*Math.PI)*Math.asin(1/a),-a*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/n))},easeOutElastic:function(t){var e=1.70158,n=0,a=1;return 0===t?0:1===t?1:(n||(n=.3),a<1?(a=1,e=n/4):e=n/(2*Math.PI)*Math.asin(1/a),a*Math.pow(2,-10*t)*Math.sin((t-e)*(2*Math.PI)/n)+1)},easeInOutElastic:function(t){var e=1.70158,n=0,a=1;return 0===t?0:2==(t/=.5)?1:(n||(n=.45),a<1?(a=1,e=n/4):e=n/(2*Math.PI)*Math.asin(1/a),t<1?a*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/n)*-.5:a*Math.pow(2,-10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/n)*.5+1)},easeInBack:function(t){var e=1.70158;return t*t*((e+1)*t-e)},easeOutBack:function(t){var e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack:function(t){var e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:function(t){return 1-o.easeOutBounce(1-t)},easeOutBounce:function(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},easeInOutBounce:function(t){return t<.5?.5*o.easeInBounce(2*t):.5*o.easeOutBounce(2*t-1)+.5}};e.exports={effects:o},a.easingEffects=o},{42:42}],44:[function(t,e,n){"use strict";var a=t(42);e.exports={toLineHeight:function(t,e){var n=(""+t).match(/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/);if(!n||"normal"===n[1])return 1.2*e;switch(t=+n[2],n[3]){case"px":return t;case"%":t/=100}return e*t},toPadding:function(t){var e,n,o,i;return a.isObject(t)?(e=+t.top||0,n=+t.right||0,o=+t.bottom||0,i=+t.left||0):e=n=o=i=+t||0,{top:e,right:n,bottom:o,left:i,height:e+o,width:i+n}},resolve:function(t,e,n){var o,i,r;for(o=0,i=t.length;o
';var i=e.childNodes[0],r=e.childNodes[1];e._reset=function(){i.scrollLeft=1e6,i.scrollTop=1e6,r.scrollLeft=1e6,r.scrollTop=1e6};var s=function(){e._reset(),t()};return o(i,"scroll",s.bind(i,"expand")),o(r,"scroll",s.bind(r,"shrink")),e}(function(t,e){var n=!1,a=[];return function(){a=Array.prototype.slice.call(arguments),e=e||this,n||(n=!0,u.requestAnimFrame.call(window,(function(){n=!1,t.apply(e,a)})))}}((function(){if(a.resizer)return e(r("resize",n))})));!function(t,e){var n=(t[c]||(t[c]={})).renderProxy=function(t){t.animationName===f&&e()};u.each(p,(function(e){o(t,e,n)})),t.classList.add(h)}(t,(function(){if(a.resizer){var e=t.parentNode;e&&e!==i.parentNode&&e.insertBefore(i,e.firstChild),i._reset()}}))}function l(t){var e=t[c]||{},n=e.resizer;delete e.resizer,function(t){var e=t[c]||{},n=e.renderProxy;n&&(u.each(p,(function(e){i(t,e,n)})),delete e.renderProxy),t.classList.remove(h)}(t),n&&n.parentNode&&n.parentNode.removeChild(n)}var u=t(45),c="$chartjs",d="chartjs-",h=d+"render-monitor",f=d+"render-animation",p=["animationstart","webkitAnimationStart"],g={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},v=!!function(){var t=!1;try{var e=Object.defineProperty({},"passive",{get:function(){t=!0}});window.addEventListener("e",null,e)}catch(t){}return t}()&&{passive:!0};e.exports={_enabled:"undefined"!=typeof window&&"undefined"!=typeof document,initialize:function(){var t="from{opacity:0.99}to{opacity:1}";!function(t,e){var n=t._style||document.createElement("style");t._style||(t._style=n,e="/* Chart.js */\n"+e,n.setAttribute("type","text/css"),document.getElementsByTagName("head")[0].appendChild(n)),n.appendChild(document.createTextNode(e))}(this,"@-webkit-keyframes "+f+"{"+t+"}@keyframes "+f+"{"+t+"}."+h+"{-webkit-animation:"+f+" 0.001s;animation:"+f+" 0.001s;}")},acquireContext:function(t,e){"string"==typeof t?t=document.getElementById(t):t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas);var n=t&&t.getContext&&t.getContext("2d");return n&&n.canvas===t?(function(t,e){var n=t.style,o=t.getAttribute("height"),i=t.getAttribute("width");if(t[c]={initial:{height:o,width:i,style:{display:n.display,height:n.height,width:n.width}}},n.display=n.display||"block",null===i||""===i){var r=a(t,"width");void 0!==r&&(t.width=r)}if(null===o||""===o)if(""===t.style.height)t.height=t.width/(e.options.aspectRatio||2);else{var s=a(t,"height");void 0!==r&&(t.height=s)}}(t,e),n):null},releaseContext:function(t){var e=t.canvas;if(e[c]){var n=e[c].initial;["height","width"].forEach((function(t){var a=n[t];u.isNullOrUndef(a)?e.removeAttribute(t):e.setAttribute(t,a)})),u.each(n.style||{},(function(t,n){e.style[n]=t})),e.width=e.width,delete e[c]}},addEventListener:function(t,e,n){var a=t.canvas;if("resize"!==e){var i=n[c]||(n[c]={});o(a,e,(i.proxies||(i.proxies={}))[t.id+"_"+e]=function(e){n(function(t,e){var n=g[t.type]||t.type,a=u.getRelativePosition(t,e);return r(n,e,a.x,a.y,t)}(e,t))})}else s(a,n,t)},removeEventListener:function(t,e,n){var a=t.canvas;if("resize"!==e){var o=((n[c]||{}).proxies||{})[t.id+"_"+e];o&&i(a,e,o)}else l(a)}},u.addEvent=o,u.removeEvent=i},{45:45}],48:[function(t,e,n){"use strict";var a=t(45),o=t(46),i=t(47),r=i._enabled?i:o;e.exports=a.extend({initialize:function(){},acquireContext:function(){},releaseContext:function(){},addEventListener:function(){},removeEventListener:function(){}},r)},{45:45,46:46,47:47}],49:[function(t,e,n){"use strict";var a=t(25),o=t(40),i=t(45);a._set("global",{plugins:{filler:{propagate:!0}}}),e.exports=function(){function t(t,e,n){var a,o=t._model||{},i=o.fill;if(void 0===i&&(i=!!o.backgroundColor),!1===i||null===i)return!1;if(!0===i)return"origin";if(a=parseFloat(i,10),isFinite(a)&&Math.floor(a)===a)return"-"!==i[0]&&"+"!==i[0]||(a=e+a),!(a===e||a<0||a>=n)&&a;switch(i){case"bottom":return"start";case"top":return"end";case"zero":return"origin";case"origin":case"start":case"end":return i;default:return!1}}function e(t){var e,n=t.el._model||{},a=t.el._scale||{},o=t.fill,i=null;if(isFinite(o))return null;if("start"===o?i=void 0===n.scaleBottom?a.bottom:n.scaleBottom:"end"===o?i=void 0===n.scaleTop?a.top:n.scaleTop:void 0!==n.scaleZero?i=n.scaleZero:a.getBasePosition?i=a.getBasePosition():a.getBasePixel&&(i=a.getBasePixel()),null!=i){if(void 0!==i.x&&void 0!==i.y)return i;if("number"==typeof i&&isFinite(i))return{x:(e=a.isHorizontal())?i:null,y:e?null:i}}return null}function n(t,e,n){var a,o=t[e].fill,i=[e];if(!n)return o;for(;!1!==o&&-1===i.indexOf(o);){if(!isFinite(o))return o;if(!(a=t[o]))return!1;if(a.visible)return o;i.push(o),o=a.fill}return!1}function r(t){var e=t.fill,n="dataset";return!1===e?null:(isFinite(e)||(n="boundary"),c[n](t))}function s(t){return t&&!t.skip}function l(t,e,n,a,o){var r;if(a&&o){for(t.moveTo(e[0].x,e[0].y),r=1;r0;--r)i.canvas.lineTo(t,n[r],n[r-1],!0)}}function u(t,e,n,a,o,i){var r,u,c,d,h,f,p,g=e.length,v=a.spanGaps,m=[],b=[],x=0,y=0;for(t.beginPath(),r=0,u=g+!!i;r');for(var n=0;n'),t.data.datasets[n].label&&e.push(t.data.datasets[n].label),e.push("");return e.push(""),e.join("")}}),e.exports=function(t){function e(t,e){return t.usePointStyle?e*Math.SQRT2:t.boxWidth}function n(e,n){var a=new t.Legend({ctx:e.ctx,options:n,chart:e});r.configure(e,a,n),r.addBox(e,a),e.legend=a}var r=t.layoutService,s=i.noop;return t.Legend=o.extend({initialize:function(t){i.extend(this,t),this.legendHitBoxes=[],this.doughnutMode=!1},beforeUpdate:s,update:function(t,e,n){var a=this;return a.beforeUpdate(),a.maxWidth=t,a.maxHeight=e,a.margins=n,a.beforeSetDimensions(),a.setDimensions(),a.afterSetDimensions(),a.beforeBuildLabels(),a.buildLabels(),a.afterBuildLabels(),a.beforeFit(),a.fit(),a.afterFit(),a.afterUpdate(),a.minSize},afterUpdate:s,beforeSetDimensions:s,setDimensions:function(){var t=this;t.isHorizontal()?(t.width=t.maxWidth,t.left=0,t.right=t.width):(t.height=t.maxHeight,t.top=0,t.bottom=t.height),t.paddingLeft=0,t.paddingTop=0,t.paddingRight=0,t.paddingBottom=0,t.minSize={width:0,height:0}},afterSetDimensions:s,beforeBuildLabels:s,buildLabels:function(){var t=this,e=t.options.labels||{},n=i.callback(e.generateLabels,[t.chart],t)||[];e.filter&&(n=n.filter((function(n){return e.filter(n,t.chart.data)}))),t.options.reverse&&n.reverse(),t.legendItems=n},afterBuildLabels:s,beforeFit:s,fit:function(){var t=this,n=t.options,o=n.labels,r=n.display,s=t.ctx,l=a.global,u=i.valueOrDefault,c=u(o.fontSize,l.defaultFontSize),d=u(o.fontStyle,l.defaultFontStyle),h=u(o.fontFamily,l.defaultFontFamily),f=i.fontString(c,d,h),p=t.legendHitBoxes=[],g=t.minSize,v=t.isHorizontal();if(v?(g.width=t.maxWidth,g.height=r?10:0):(g.width=r?10:0,g.height=t.maxHeight),r)if(s.font=f,v){var m=t.lineWidths=[0],b=t.legendItems.length?c+o.padding:0;s.textAlign="left",s.textBaseline="top",i.each(t.legendItems,(function(n,a){var i=e(o,c)+c/2+s.measureText(n.text).width;m[m.length-1]+i+o.padding>=t.width&&(b+=c+o.padding,m[m.length]=t.left),p[a]={left:0,top:0,width:i,height:c},m[m.length-1]+=i+o.padding})),g.height+=b}else{var x=o.padding,y=t.columnWidths=[],k=o.padding,w=0,C=0,S=c+x;i.each(t.legendItems,(function(t,n){var a=e(o,c)+c/2+s.measureText(t.text).width;C+S>g.height&&(k+=w+o.padding,y.push(w),w=0,C=0),w=Math.max(w,a),C+=S,p[n]={left:0,top:0,width:a,height:c}})),k+=w,y.push(w),g.width+=k}t.width=g.width,t.height=g.height},afterFit:s,isHorizontal:function(){return"top"===this.options.position||"bottom"===this.options.position},draw:function(){var t=this,n=t.options,o=n.labels,r=a.global,s=r.elements.line,l=t.width,u=t.lineWidths;if(n.display){var c,d=t.ctx,h=i.valueOrDefault,f=h(o.fontColor,r.defaultFontColor),p=h(o.fontSize,r.defaultFontSize),g=h(o.fontStyle,r.defaultFontStyle),v=h(o.fontFamily,r.defaultFontFamily),m=i.fontString(p,g,v);d.textAlign="left",d.textBaseline="middle",d.lineWidth=.5,d.strokeStyle=f,d.fillStyle=f,d.font=m;var b=e(o,p),x=t.legendHitBoxes,y=function(t,e,a){if(!(isNaN(b)||b<=0)){d.save(),d.fillStyle=h(a.fillStyle,r.defaultColor),d.lineCap=h(a.lineCap,s.borderCapStyle),d.lineDashOffset=h(a.lineDashOffset,s.borderDashOffset),d.lineJoin=h(a.lineJoin,s.borderJoinStyle),d.lineWidth=h(a.lineWidth,s.borderWidth),d.strokeStyle=h(a.strokeStyle,r.defaultColor);var o=0===h(a.lineWidth,s.borderWidth);if(d.setLineDash&&d.setLineDash(h(a.lineDash,s.borderDash)),n.labels&&n.labels.usePointStyle){var l=p*Math.SQRT2/2,u=l/Math.SQRT2,c=t+u,f=e+u;i.canvas.drawPoint(d,a.pointStyle,l,c,f)}else o||d.strokeRect(t,e,b,p),d.fillRect(t,e,b,p);d.restore()}},k=t.isHorizontal();c=k?{x:t.left+(l-u[0])/2,y:t.top+o.padding,line:0}:{x:t.left+o.padding,y:t.top+o.padding,line:0};var w=p+o.padding;i.each(t.legendItems,(function(e,n){var a=d.measureText(e.text).width,i=b+p/2+a,r=c.x,s=c.y;k?r+i>=l&&(s=c.y+=w,c.line++,r=c.x=t.left+(l-u[c.line])/2):s+w>t.bottom&&(r=c.x=r+t.columnWidths[c.line]+o.padding,s=c.y=t.top+o.padding,c.line++),y(r,s,e),x[n].left=r,x[n].top=s,function(t,e,n,a){var o=p/2,i=b+o+t,r=e+o;d.fillText(n.text,i,r),n.hidden&&(d.beginPath(),d.lineWidth=2,d.moveTo(i,r),d.lineTo(i+a,r),d.stroke())}(r,s,e,a),k?c.x+=i+o.padding:c.y+=w}))}},handleEvent:function(t){var e=this,n=e.options,a="mouseup"===t.type?"click":t.type,o=!1;if("mousemove"===a){if(!n.onHover)return}else{if("click"!==a)return;if(!n.onClick)return}var i=t.x,r=t.y;if(i>=e.left&&i<=e.right&&r>=e.top&&r<=e.bottom)for(var s=e.legendHitBoxes,l=0;l=u.left&&i<=u.left+u.width&&r>=u.top&&r<=u.top+u.height){if("click"===a){n.onClick.call(e,t.native,e.legendItems[l]),o=!0;break}if("mousemove"===a){n.onHover.call(e,t.native,e.legendItems[l]),o=!0;break}}}return o}}),{id:"legend",beforeInit:function(t){var e=t.options.legend;e&&n(t,e)},beforeUpdate:function(t){var e=t.options.legend,o=t.legend;e?(i.mergeIf(e,a.global.legend),o?(r.configure(t,o,e),o.options=e):n(t,e)):o&&(r.removeBox(t,o),delete t.legend)},afterEvent:function(t,e){var n=t.legend;n&&n.handleEvent(e)}}}},{25:25,26:26,45:45}],51:[function(t,e,n){"use strict";var a=t(25),o=t(26),i=t(45);a._set("global",{title:{display:!1,fontStyle:"bold",fullWidth:!0,lineHeight:1.2,padding:10,position:"top",text:"",weight:2e3}}),e.exports=function(t){function e(e,a){var o=new t.Title({ctx:e.ctx,options:a,chart:e});n.configure(e,o,a),n.addBox(e,o),e.titleBlock=o}var n=t.layoutService,r=i.noop;return t.Title=o.extend({initialize:function(t){i.extend(this,t),this.legendHitBoxes=[]},beforeUpdate:r,update:function(t,e,n){var a=this;return a.beforeUpdate(),a.maxWidth=t,a.maxHeight=e,a.margins=n,a.beforeSetDimensions(),a.setDimensions(),a.afterSetDimensions(),a.beforeBuildLabels(),a.buildLabels(),a.afterBuildLabels(),a.beforeFit(),a.fit(),a.afterFit(),a.afterUpdate(),a.minSize},afterUpdate:r,beforeSetDimensions:r,setDimensions:function(){var t=this;t.isHorizontal()?(t.width=t.maxWidth,t.left=0,t.right=t.width):(t.height=t.maxHeight,t.top=0,t.bottom=t.height),t.paddingLeft=0,t.paddingTop=0,t.paddingRight=0,t.paddingBottom=0,t.minSize={width:0,height:0}},afterSetDimensions:r,beforeBuildLabels:r,buildLabels:r,afterBuildLabels:r,beforeFit:r,fit:function(){var t=this,e=i.valueOrDefault,n=t.options,o=n.display,r=e(n.fontSize,a.global.defaultFontSize),s=t.minSize,l=i.isArray(n.text)?n.text.length:1,u=i.options.toLineHeight(n.lineHeight,r),c=o?l*u+2*n.padding:0;t.isHorizontal()?(s.width=t.maxWidth,s.height=c):(s.width=c,s.height=t.maxHeight),t.width=s.width,t.height=s.height},afterFit:r,isHorizontal:function(){var t=this.options.position;return"top"===t||"bottom"===t},draw:function(){var t=this,e=t.ctx,n=i.valueOrDefault,o=t.options,r=a.global;if(o.display){var s,l,u,c=n(o.fontSize,r.defaultFontSize),d=n(o.fontStyle,r.defaultFontStyle),h=n(o.fontFamily,r.defaultFontFamily),f=i.fontString(c,d,h),p=i.options.toLineHeight(o.lineHeight,c),g=p/2+o.padding,v=0,m=t.top,b=t.left,x=t.bottom,y=t.right;e.fillStyle=n(o.fontColor,r.defaultFontColor),e.font=f,t.isHorizontal()?(l=b+(y-b)/2,u=m+g,s=y-b):(l="left"===o.position?b+g:y-g,u=m+(x-m)/2,s=x-m,v=Math.PI*("left"===o.position?-.5:.5)),e.save(),e.translate(l,u),e.rotate(v),e.textAlign="center",e.textBaseline="middle";var k=o.text;if(i.isArray(k))for(var w=0,C=0;Ce.max)&&(e.max=a))}))}));e.min=isFinite(e.min)&&!isNaN(e.min)?e.min:0,e.max=isFinite(e.max)&&!isNaN(e.max)?e.max:1,this.handleTickRangeOptions()},getTickLimit:function(){var t,e=this,n=e.options.ticks;if(e.isHorizontal())t=Math.min(n.maxTicksLimit?n.maxTicksLimit:11,Math.ceil(e.width/50));else{var i=o.valueOrDefault(n.fontSize,a.global.defaultFontSize);t=Math.min(n.maxTicksLimit?n.maxTicksLimit:11,Math.ceil(e.height/(2*i)))}return t},handleDirectionalChanges:function(){this.isHorizontal()||this.ticks.reverse()},getLabelForIndex:function(t,e){return+this.getRightValue(this.chart.data.datasets[e].data[t])},getPixelForValue:function(t){var e,n=this,a=n.start,o=+n.getRightValue(t),i=n.end-a;return n.isHorizontal()?(e=n.left+n.width/i*(o-a),Math.round(e)):(e=n.bottom-n.height/i*(o-a),Math.round(e))},getValueForPixel:function(t){var e=this,n=e.isHorizontal(),a=n?e.width:e.height,o=(n?t-e.left:e.bottom-t)/a;return e.start+(e.end-e.start)*o},getPixelForTick:function(t){return this.getPixelForValue(this.ticksAsNumbers[t])}});t.scaleService.registerScaleType("linear",n,e)}},{25:25,34:34,45:45}],54:[function(t,e,n){"use strict";var a=t(45),o=t(34);e.exports=function(t){var e=a.noop;t.LinearScaleBase=t.Scale.extend({getRightValue:function(e){return"string"==typeof e?+e:t.Scale.prototype.getRightValue.call(this,e)},handleTickRangeOptions:function(){var t=this,e=t.options.ticks;if(e.beginAtZero){var n=a.sign(t.min),o=a.sign(t.max);n<0&&o<0?t.max=0:n>0&&o>0&&(t.min=0)}var i=void 0!==e.min||void 0!==e.suggestedMin,r=void 0!==e.max||void 0!==e.suggestedMax;void 0!==e.min?t.min=e.min:void 0!==e.suggestedMin&&(null===t.min?t.min=e.suggestedMin:t.min=Math.min(t.min,e.suggestedMin)),void 0!==e.max?t.max=e.max:void 0!==e.suggestedMax&&(null===t.max?t.max=e.suggestedMax:t.max=Math.max(t.max,e.suggestedMax)),i!==r&&t.min>=t.max&&(i?t.max=t.min+1:t.min=t.max-1),t.min===t.max&&(t.max++,e.beginAtZero||t.min--)},getTickLimit:e,handleDirectionalChanges:e,buildTicks:function(){var t=this,e=t.options.ticks,n=t.getTickLimit(),i={maxTicks:n=Math.max(2,n),min:e.min,max:e.max,stepSize:a.valueOrDefault(e.fixedStepSize,e.stepSize)},r=t.ticks=o.generators.linear(i,t);t.handleDirectionalChanges(),t.max=a.max(r),t.min=a.min(r),e.reverse?(r.reverse(),t.start=t.max,t.end=t.min):(t.start=t.min,t.end=t.max)},convertTicksToLabels:function(){var e=this;e.ticksAsNumbers=e.ticks.slice(),e.zeroLineIndex=e.ticks.indexOf(0),t.Scale.prototype.convertTicksToLabels.call(e)}})}},{34:34,45:45}],55:[function(t,e,n){"use strict";var a=t(45),o=t(34);e.exports=function(t){var e={position:"left",ticks:{callback:o.formatters.logarithmic}},n=t.Scale.extend({determineDataLimits:function(){function t(t){return l?t.xAxisID===e.id:t.yAxisID===e.id}var e=this,n=e.options,o=n.ticks,i=e.chart,r=i.data.datasets,s=a.valueOrDefault,l=e.isHorizontal();e.min=null,e.max=null,e.minNotZero=null;var u=n.stacked;if(void 0===u&&a.each(r,(function(e,n){if(!u){var a=i.getDatasetMeta(n);i.isDatasetVisible(n)&&t(a)&&void 0!==a.stack&&(u=!0)}})),n.stacked||u){var c={};a.each(r,(function(o,r){var s=i.getDatasetMeta(r),l=[s.type,void 0===n.stacked&&void 0===s.stack?r:"",s.stack].join(".");i.isDatasetVisible(r)&&t(s)&&(void 0===c[l]&&(c[l]=[]),a.each(o.data,(function(t,a){var o=c[l],i=+e.getRightValue(t);isNaN(i)||s.data[a].hidden||(o[a]=o[a]||0,n.relativePoints?o[a]=100:o[a]+=i)})))})),a.each(c,(function(t){var n=a.min(t),o=a.max(t);e.min=null===e.min?n:Math.min(e.min,n),e.max=null===e.max?o:Math.max(e.max,o)}))}else a.each(r,(function(n,o){var r=i.getDatasetMeta(o);i.isDatasetVisible(o)&&t(r)&&a.each(n.data,(function(t,n){var a=+e.getRightValue(t);isNaN(a)||r.data[n].hidden||((null===e.min||ae.max)&&(e.max=a),0!==a&&(null===e.minNotZero||ao?{start:e-n-5,end:e}:{start:e,end:e+n+5}}function l(t){return 0===t||180===t?"center":t<180?"left":"right"}function u(t,e,n,a){if(o.isArray(e))for(var i=n.y,r=1.5*a,s=0;s270||t<90)&&(n.y-=e.h)}function d(t){var a=t.ctx,i=o.valueOrDefault,r=t.options,s=r.angleLines,d=r.pointLabels;a.lineWidth=s.lineWidth,a.strokeStyle=s.color;var h=t.getDistanceFromCenterForValue(r.ticks.reverse?t.min:t.max),f=n(t);a.textBaseline="top";for(var g=e(t)-1;g>=0;g--){if(s.display){var v=t.getPointPosition(g,h);a.beginPath(),a.moveTo(t.xCenter,t.yCenter),a.lineTo(v.x,v.y),a.stroke(),a.closePath()}if(d.display){var m=t.getPointPosition(g,h+5),b=i(d.fontColor,p.defaultFontColor);a.font=f.font,a.fillStyle=b;var x=t.getIndexAngle(g),y=o.toDegrees(x);a.textAlign=l(y),c(y,t._pointLabelSizes[g],m),u(a,t.pointLabels[g]||"",m,f.size)}}}function h(t,n,a,i){var r=t.ctx;if(r.strokeStyle=o.valueAtIndexOrDefault(n.color,i-1),r.lineWidth=o.valueAtIndexOrDefault(n.lineWidth,i-1),t.options.gridLines.circular)r.beginPath(),r.arc(t.xCenter,t.yCenter,a,0,2*Math.PI),r.closePath(),r.stroke();else{var s=e(t);if(0===s)return;r.beginPath();var l=t.getPointPosition(0,a);r.moveTo(l.x,l.y);for(var u=1;ud.r&&(d.r=v.end,h.r=p),m.startd.b&&(d.b=m.end,h.b=p)}t.setReductions(c,d,h)}(this):function(t){var e=Math.min(t.height/2,t.width/2);t.drawingArea=Math.round(e),t.setCenterPoint(0,0,0,0)}(this)},setReductions:function(t,e,n){var a=this,o=e.l/Math.sin(n.l),i=Math.max(e.r-a.width,0)/Math.sin(n.r),r=-e.t/Math.cos(n.t),s=-Math.max(e.b-a.height,0)/Math.cos(n.b);o=f(o),i=f(i),r=f(r),s=f(s),a.drawingArea=Math.min(Math.round(t-(o+i)/2),Math.round(t-(r+s)/2)),a.setCenterPoint(o,i,r,s)},setCenterPoint:function(t,e,n,a){var o=this,i=o.width-e-o.drawingArea,r=t+o.drawingArea,s=n+o.drawingArea,l=o.height-a-o.drawingArea;o.xCenter=Math.round((r+i)/2+o.left),o.yCenter=Math.round((s+l)/2+o.top)},getIndexAngle:function(t){return t*(2*Math.PI/e(this))+(this.chart.options&&this.chart.options.startAngle?this.chart.options.startAngle:0)*Math.PI*2/360},getDistanceFromCenterForValue:function(t){var e=this;if(null===t)return 0;var n=e.drawingArea/(e.max-e.min);return e.options.ticks.reverse?(e.max-t)*n:(t-e.min)*n},getPointPosition:function(t,e){var n=this,a=n.getIndexAngle(t)-Math.PI/2;return{x:Math.round(Math.cos(a)*e)+n.xCenter,y:Math.round(Math.sin(a)*e)+n.yCenter}},getPointPositionForValue:function(t,e){return this.getPointPosition(t,this.getDistanceFromCenterForValue(e))},getBasePosition:function(){var t=this,e=t.min,n=t.max;return t.getPointPositionForValue(0,t.beginAtZero?0:e<0&&n<0?n:e>0&&n>0?e:0)},draw:function(){var t=this,e=t.options,n=e.gridLines,a=e.ticks,i=o.valueOrDefault;if(e.display){var r=t.ctx,s=this.getIndexAngle(0),l=i(a.fontSize,p.defaultFontSize),u=i(a.fontStyle,p.defaultFontStyle),c=i(a.fontFamily,p.defaultFontFamily),f=o.fontString(l,u,c);o.each(t.ticks,(function(e,o){if(o>0||a.reverse){var u=t.getDistanceFromCenterForValue(t.ticksAsNumbers[o]);if(n.display&&0!==o&&h(t,n,u,o),a.display){var c=i(a.fontColor,p.defaultFontColor);if(r.font=f,r.save(),r.translate(t.xCenter,t.yCenter),r.rotate(s),a.showLabelBackdrop){var d=r.measureText(e).width;r.fillStyle=a.backdropColor,r.fillRect(-d/2-a.backdropPaddingX,-u-l/2-a.backdropPaddingY,d+2*a.backdropPaddingX,l+2*a.backdropPaddingY)}r.textAlign="center",r.textBaseline="middle",r.fillStyle=c,r.fillText(e,0,-u),r.restore()}}})),(e.angleLines.display||e.pointLabels.display)&&d(t)}}});t.scaleService.registerScaleType("radialLinear",v,g)}},{25:25,34:34,45:45}],57:[function(t,e,n){"use strict";function a(t,e){return t-e}function o(t){var e,n,a,o={},i=[];for(e=0,n=t.length;e=0&&r<=s;){if(o=t[(a=r+s>>1)-1]||null,i=t[a],!o)return{lo:null,hi:i};if(i[e]n))return{lo:o,hi:i};s=a-1}}return{lo:i,hi:null}}(t,e,n),i=o.lo?o.hi?o.lo:t[t.length-2]:t[0],r=o.lo?o.hi?o.hi:t[t.length-1]:t[1],s=r[e]-i[e],l=s?(n-i[e])/s:0,u=(r[a]-i[a])*l;return i[a]+u}function r(t,e){var n=e.parser,a=e.parser||e.format;return"function"==typeof n?n(t):"string"==typeof t&&"string"==typeof a?h(t,a):(t instanceof h||(t=h(t)),t.isValid()?t:"function"==typeof a?a(t):t)}function s(t,e){if(p.isNullOrUndef(t))return null;var n=e.options.time,a=r(e.getRightValue(t),n);return a.isValid()?(n.round&&a.startOf(n.round),a.valueOf()):null}function l(t,e,n,a){var o,i,r,s=b.length;for(o=b.indexOf(t);o1?e[1]:a,s=e[0],l=(i(t,"time",r,"pos")-i(t,"time",s,"pos"))/2),o.time.max||(r=e[e.length-1],s=e.length>1?e[e.length-2]:n,u=(i(t,"time",r,"pos")-i(t,"time",s,"pos"))/2)),{left:l,right:u}}function d(t,e){var n,a,o,i,r=[];for(n=0,a=t.length;n=o&&n<=i&&y.push(n);return a.min=o,a.max=i,a._unit=g,a._majorUnit=v,a._minorFormat=f[g],a._majorFormat=f[v],a._table=function(t,e,n,a){if("linear"===a||!t.length)return[{time:e,pos:0},{time:n,pos:1}];var o,i,r,s,l,u=[],c=[e];for(o=0,i=t.length;oe&&s=0&&t{function a(t){return a="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},a(t)}n(8636),n(5086),n(8329),n(8772),n(4913),n(9693),n(115),n(7136),n(173),n(9073),n(6048),n(9581),n(3534),n(590),n(4216),n(8665),n(9979),n(4602),function(t){"use strict";var e=function(e,n){t.fn.typeahead.defaults;n.scrollBar&&(n.items=100,n.menu='');var a=this;if(a.$element=t(e),a.options=t.extend({},t.fn.typeahead.defaults,n),a.$menu=t(a.options.menu).insertAfter(a.$element),a.eventSupported=a.options.eventSupported||a.eventSupported,a.grepper=a.options.grepper||a.grepper,a.highlighter=a.options.highlighter||a.highlighter,a.lookup=a.options.lookup||a.lookup,a.matcher=a.options.matcher||a.matcher,a.render=a.options.render||a.render,a.onSelect=a.options.onSelect||null,a.sorter=a.options.sorter||a.sorter,a.source=a.options.source||a.source,a.displayField=a.options.displayField||a.displayField,a.valueField=a.options.valueField||a.valueField,a.options.ajax){var o=a.options.ajax;"string"==typeof o?a.ajax=t.extend({},t.fn.typeahead.defaults.ajax,{url:o}):("string"==typeof o.displayField&&(a.displayField=a.options.displayField=o.displayField),"string"==typeof o.valueField&&(a.valueField=a.options.valueField=o.valueField),a.ajax=t.extend({},t.fn.typeahead.defaults.ajax,o)),a.ajax.url||(a.ajax=null),a.query=""}else a.source=a.options.source,a.ajax=null;a.shown=!1,a.listen()};e.prototype={constructor:e,eventSupported:function(t){var e=t in this.$element;return e||(this.$element.setAttribute(t,"return;"),e="function"==typeof this.$element[t]),e},select:function(){var t=this.$menu.find(".active").attr("data-value"),e=this.$menu.find(".active a").text();return this.options.onSelect&&this.options.onSelect({value:t,text:e}),this.$element.val(this.updater(e)).change(),this.hide()},updater:function(t){return t},show:function(){var e=t.extend({},this.$element.position(),{height:this.$element[0].offsetHeight});if(this.$menu.css({top:e.top+e.height,left:e.left}),this.options.alignWidth){var n=t(this.$element[0]).outerWidth();this.$menu.css({width:n})}return this.$menu.show(),this.shown=!0,this},hide:function(){return this.$menu.hide(),this.shown=!1,this},ajaxLookup:function(){var e=t.trim(this.$element.val());if(e===this.query)return this;if(this.query=e,this.ajax.timerId&&(clearTimeout(this.ajax.timerId),this.ajax.timerId=null),!e||e.length"+e+""}))},render:function(e){var n,o=this,i="string"==typeof o.options.displayField;return(e=t(e).map((function(e,r){return"object"===a(r)?(n=i?r[o.options.displayField]:o.options.displayField(r),e=t(o.options.item).attr("data-value",r[o.options.valueField])):(n=r,e=t(o.options.item).attr("data-value",r)),e.find("a").html(o.highlighter(n)),e[0]}))).first().addClass("active"),this.$menu.html(e),this},grepper:function(e){var n,a,o=this,i="string"==typeof o.options.displayField;if(!(i&&e&&e.length))return null;if(e[0].hasOwnProperty(o.options.displayField))n=t.grep(e,(function(t){return a=i?t[o.options.displayField]:o.options.displayField(t),o.matcher(a)}));else{if("string"!=typeof e[0])return null;n=t.grep(e,(function(t){return o.matcher(t)}))}return this.sorter(n)},next:function(e){var n=this.$menu.find(".active").removeClass("active").next();if(n.length||(n=t(this.$menu.find("li")[0])),this.options.scrollBar){var a=this.$menu.children("li").index(n);a%8==0&&this.$menu.scrollTop(26*a)}n.addClass("active")},prev:function(t){var e=this.$menu.find(".active").removeClass("active").prev();if(e.length||(e=this.$menu.find("li").last()),this.options.scrollBar){var n=this.$menu.children("li"),a=n.length-1,o=n.index(e);(a-o)%8==0&&this.$menu.scrollTop(26*(o-7))}e.addClass("active")},listen:function(){this.$element.on("focus",t.proxy(this.focus,this)).on("blur",t.proxy(this.blur,this)).on("keypress",t.proxy(this.keypress,this)).on("keyup",t.proxy(this.keyup,this)),this.eventSupported("keydown")&&this.$element.on("keydown",t.proxy(this.keydown,this)),this.$menu.on("click",t.proxy(this.click,this)).on("mouseenter","li",t.proxy(this.mouseenter,this)).on("mouseleave","li",t.proxy(this.mouseleave,this))},move:function(t){if(this.shown){switch(t.keyCode){case 9:case 13:case 27:t.preventDefault();break;case 38:t.preventDefault(),this.prev();break;case 40:t.preventDefault(),this.next()}t.stopPropagation()}},keydown:function(e){this.suppressKeyPressRepeat=~t.inArray(e.keyCode,[40,38,9,13,27]),this.move(e)},keypress:function(t){this.suppressKeyPressRepeat||this.move(t)},keyup:function(t){switch(t.keyCode){case 40:case 38:case 16:case 17:case 18:break;case 9:case 13:if(!this.shown)return;this.select();break;case 27:if(!this.shown)return;this.hide();break;default:this.ajax?this.ajaxLookup():this.lookup()}t.stopPropagation(),t.preventDefault()},focus:function(t){this.focused=!0},blur:function(t){this.focused=!1,!this.mousedover&&this.shown&&this.hide()},click:function(t){t.stopPropagation(),t.preventDefault(),this.select(),this.$element.focus()},mouseenter:function(e){this.mousedover=!0,this.$menu.find(".active").removeClass("active"),t(e.currentTarget).addClass("active")},mouseleave:function(t){this.mousedover=!1,!this.focused&&this.shown&&this.hide()},destroy:function(){this.$element.off("focus",t.proxy(this.focus,this)).off("blur",t.proxy(this.blur,this)).off("keypress",t.proxy(this.keypress,this)).off("keyup",t.proxy(this.keyup,this)),this.eventSupported("keydown")&&this.$element.off("keydown",t.proxy(this.keydown,this)),this.$menu.off("click",t.proxy(this.click,this)).off("mouseenter","li",t.proxy(this.mouseenter,this)).off("mouseleave","li",t.proxy(this.mouseleave,this)),this.$element.removeData("typeahead")}},t.fn.typeahead=function(n){return this.each((function(){var o=t(this),i=o.data("typeahead"),r="object"===a(n)&&n;i||o.data("typeahead",i=new e(this,r)),"string"==typeof n&&i[n]()}))},t.fn.typeahead.defaults={source:[],items:10,scrollBar:!1,alignWidth:!0,menu:'',item:'
  • ',valueField:"id",displayField:"name",onSelect:function(){},ajax:{url:null,timeout:300,method:"get",triggerLength:1,loadingClass:null,preDispatch:null,preProcess:null}},t.fn.typeahead.Constructor=e,t((function(){t("body").on("focus.typeahead.data-api",'[data-provide="typeahead"]',(function(e){var n=t(this);n.data("typeahead")||(e.preventDefault(),n.typeahead(n.data()))}))}))}(window.jQuery)},2811:function(t,e,n){var a,o;function i(t){return i="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},i(t)}n(4913),n(475),n(115),n(9693),n(8636),n(5086),n(7136),n(173),n(2231),n(6255),n(9389),n(6048),n(9581),n(6088),n(9073),n(3534),n(590),n(4216),n(8665),n(9979),n(4602),function(t){"use strict";var e,n,a=Array.prototype.slice;(n=function(e){this.options=t.extend({},n.defaults,e),this.parser=this.options.parser,this.locale=this.options.locale,this.messageStore=this.options.messageStore,this.languages={},this.init()}).prototype={init:function(){var e=this;String.locale=e.locale,String.prototype.toLocaleString=function(){var n,a,o,i,r,s,l;for(o=this.valueOf(),i=e.locale,r=0;i;){a=(n=i.split("-")).length;do{if(s=n.slice(0,a).join("-"),l=e.messageStore.get(s,o))return l;a--}while(a);if("en"===i)break;i=t.i18n.fallbacks[e.locale]&&t.i18n.fallbacks[e.locale][r]||e.options.fallbackLocale,t.i18n.log("Trying fallback locale for "+e.locale+": "+i),r++}return""}},destroy:function(){t.removeData(document,"i18n")},load:function(e,n){var a,o,i,r={};if(e||n||(e="i18n/"+t.i18n().locale+".json",n=t.i18n().locale),"string"==typeof e&&"json"!==e.split(".").pop()){for(o in r[n]=e+"/"+n+".json",a=(t.i18n.fallbacks[n]||[]).concat(this.options.fallbackLocale))r[i=a[o]]=e+"/"+i+".json";return this.load(r)}return this.messageStore.load(e,n)},parse:function(e,n){var a=e.toLocaleString();return this.parser.language=t.i18n.languages[t.i18n().locale]||t.i18n.languages.default,""===a&&(a=e),this.parser.parse(a,n)}},t.i18n=function(e,o){var r,s=t.data(document,"i18n"),l="object"===i(e)&&e;return l&&l.locale&&s&&s.locale!==l.locale&&(String.locale=s.locale=l.locale),s||(s=new n(l),t.data(document,"i18n",s)),"string"==typeof e?(r=void 0!==o?a.call(arguments,1):[],s.parse(e,r)):s},t.fn.i18n=function(){var e=t.data(document,"i18n");return e||(e=new n,t.data(document,"i18n",e)),String.locale=e.locale,this.each((function(){var n,a,o,i,r=t(this),s=r.data("i18n");s?(n=s.indexOf("["),a=s.indexOf("]"),-1!==n&&-1!==a&&n1?["CONCAT"].concat(t):t[0]}function P(){var t=w([h,n,I]);return null===t?null:[t[0],t[2]]}function A(){var t=w([h,n,v]);return null===t?null:[t[0],t[2]]}function T(){var t=w([f,d,p]);return null===t?null:t[1]}if(e=S("|"),n=S(":"),a=S("\\"),o=M(/^./),i=S("$"),r=M(/^\d+/),s=M(/^[^{}\[\]$\\]/),l=M(/^[^{}\[\]$\\|]/),k([_,M(/^[^{}\[\]$\s]/)]),u=k([_,l]),c=k([_,s]),b=M(/^[ !"$&'()*,.\/0-9;=?@A-Z\^_`a-z~\x80-\xFF+\-]+/),x=function(t){return t.toString()},h=function(){var t=b();return null===t?null:x(t)},d=k([function(){var t=w([k([P,A]),C(0,D)]);return null===t?null:t[0].concat(t[1])},function(){var t=w([h,C(0,D)]);return null===t?null:[t[0]].concat(t[1])}]),f=S("{{"),p=S("}}"),g=k([T,I,function(){var t=C(1,c)();return null===t?null:t.join("")}]),v=k([T,I,function(){var t=C(1,u)();return null===t?null:t.join("")}]),null===(m=function(){var t=C(0,g)();return null===t?null:["CONCAT"].concat(t)}())||y!==t.length)throw new Error("Parse error at position "+y.toString()+" in input: "+t);return m}},t.extend(t.i18n.parser,new e)}(jQuery),function(t){"use strict";var e=function(){this.language=t.i18n.languages[String.locale]||t.i18n.languages.default};e.prototype={constructor:e,emit:function(e,n){var a,o,r,s=this;switch(i(e)){case"string":case"number":a=e;break;case"object":if(o=t.map(e.slice(1),(function(t){return s.emit(t,n)})),r=e[0].toLowerCase(),"function"!=typeof s[r])throw new Error('unknown operation "'+r+'"');a=s[r](o,n);break;case"undefined":a="";break;default:throw new Error("unexpected type in AST: "+i(e))}return a},concat:function(e){var n="";return t.each(e,(function(t,e){n+=e})),n},replace:function(t,e){var n=parseInt(t[0],10);return n=parseInt(t[0],10)&&e[0]{},1536:()=>{},2559:()=>{},2553:()=>{},5264:()=>{},6387:()=>{},5985:()=>{},63:()=>{},3888:()=>{},7278:()=>{},3704:()=>{}},t=>{var e=e=>t(t.s=e);t.O(0,[852],(()=>(e(2811),e(7852),e(6108),e(5779),e(6618),e(3441),e(1680),e(9654),e(5611),e(3600),e(514),e(9307),e(6730),e(1595),e(1223),e(9662),e(63),e(1536),e(2559),e(2553),e(5264),e(6387),e(5985),e(3888),e(3704),e(7278))));t.O()}]); \ No newline at end of file diff --git a/public/build/app.a7ec0e72.js.LICENSE.txt b/public/build/app.9cc563c1.js.LICENSE.txt similarity index 100% rename from public/build/app.a7ec0e72.js.LICENSE.txt rename to public/build/app.9cc563c1.js.LICENSE.txt diff --git a/public/build/app.a7ec0e72.js b/public/build/app.a7ec0e72.js deleted file mode 100644 index 048881495..000000000 --- a/public/build/app.a7ec0e72.js +++ /dev/null @@ -1,2 +0,0 @@ -/*! For license information please see app.a7ec0e72.js.LICENSE.txt */ -(self.webpackChunkxtools=self.webpackChunkxtools||[]).push([[524],{3441:()=>{xtools.adminstats={},$((function(){var t=$("#project_input"),e=t.val();0!==$("body.adminstats, body.patrollerstats, body.stewardstats").length&&(xtools.application.setupMultiSelectListeners(),$(".group-selector").on("change",(function(){$(".action-selector").addClass("hidden"),$(".action-selector--"+$(this).val()).removeClass("hidden"),$(".xt-page-title--title").text($.i18n("tool-"+$(this).val()+"stats")),$(".xt-page-title--desc").text($.i18n("tool-"+$(this).val()+"stats-desc"));var n=$.i18n("tool-"+$(this).val()+"stats")+" - "+$.i18n("xtools-title");document.title=n,history.replaceState({},n,"/"+$(this).val()+"stats"),"steward"===$(this).val()?(e=t.val(),t.val("meta.wikimedia.org")):t.val(e),xtools.application.setupMultiSelectListeners()})))}))},9654:(t,e,n)=>{n(8636),n(5086),$((function(){if($("body.authorship").length){var t=$("#show_selector");t.on("change",(function(t){$(".show-option").addClass("hidden").find("input").prop("disabled",!0),$(".show-option--".concat(t.target.value)).removeClass("hidden").find("input").prop("disabled",!1)})),window.onload=function(){return t.trigger("change")}}}))},5611:(t,e,n)=>{n(8476),n(5086),n(8379),n(7899),n(2231),n(115),xtools.autoedits={},$((function(){if($("body.autoedits").length){var t=$(".contributions-container"),e=$("#tool_selector");if(e.length)return xtools.autoedits.fetchTools=function(t){e.prop("disabled",!0),$.get("/api/project/automated_tools/"+t).done((function(t){t.error||(delete t.project,delete t.elapsed_time,e.html('"),Object.keys(t).forEach((function(n){e.append('")}))),e.prop("disabled",!1)}))},$(document).ready((function(){$("#project_input").on("change.autoedits",(function(){xtools.autoedits.fetchTools($("#project_input").val())}))})),void xtools.autoedits.fetchTools($("#project_input").val());if(xtools.application.setupToggleTable(window.countsByTool,window.toolsChart,"count",(function(t){var e=0;Object.keys(t).forEach((function(n){e+=parseInt(t[n].count,10)}));var n=Object.keys(t).length;$(".tools--tools").text(n.toLocaleString(i18nLang)+" "+$.i18n("num-tools",n)),$(".tools--count").text(e.toLocaleString(i18nLang))})),t.length){var n=$(".contributions-table").length?"setupContributionsNavListeners":"loadContributions";xtools.application[n]((function(t){return"".concat(t.target,"-contributions/").concat(t.project,"/").concat(t.username)+"/".concat(t.namespace,"/").concat(t.start,"/").concat(t.end)}),t.data("target"))}}}))},3600:(t,e,n)=>{n(7136),n(173),n(9073),n(6048),n(8636),n(5086),xtools.blame={},$((function(){if($("body.blame").length){$(".diff-empty").length===$(".diff tr").length-1&&$(".diff-empty").eq(0).text("(".concat($.i18n("diff-empty").toLowerCase(),")")).addClass("text-muted text-center").prop("width","20%"),$(".diff-addedline").each((function(){var t=xtools.blame.query.replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&"),e=function(e){var n=new RegExp("(".concat(t,")"),"gi");$(e).html($(e).html().replace(n,"$1"))};$(this).find(".diffchange-inline").length?$(".diffchange-inline").each((function(){e(this)})):e(this)}));var t=$("#show_selector");t.on("change",(function(t){$(".show-option").addClass("hidden").find("input").prop("disabled",!0),$(".show-option--".concat(t.target.value)).removeClass("hidden").find("input").prop("disabled",!1)})),window.onload=function(){return t.trigger("change")}}}))},514:(t,e,n)=>{function a(t,e){xtools.categoryedits.$select2Input.data("select2")&&(xtools.categoryedits.$select2Input.off("change"),xtools.categoryedits.$select2Input.select2("val",null),xtools.categoryedits.$select2Input.select2("data",null),xtools.categoryedits.$select2Input.select2("destroy"));var n=e||xtools.categoryedits.$select2Input.data("ns"),a={ajax:{url:t||xtools.categoryedits.$select2Input.data("api"),dataType:"jsonp",jsonpCallback:"categorySuggestionCallback",delay:200,data:function(t){return{action:"query",list:"prefixsearch",format:"json",pssearch:t.term||"",psnamespace:14,cirrusUseCompletionSuggester:"yes"}},processResults:function(t){var e=t?t.query:{},a=[];return e&&e.prefixsearch.length&&(a=e.prefixsearch.map((function(t){var e=t.title.replace(new RegExp("^"+n+":"),"");return{id:e.replace(/ /g,"_"),text:e}}))),{results:a}}},placeholder:$.i18n("category-search"),maximumSelectionLength:10,minimumInputLength:1};xtools.categoryedits.$select2Input.select2(a)}n(475),n(8476),n(5086),n(8379),n(7899),n(2231),n(9581),n(7136),n(173),n(9073),n(6048),xtools.categoryedits={},$((function(){$("body.categoryedits").length&&$(document).ready((function(){var t;xtools.categoryedits.$select2Input=$("#category_selector"),a(),$("#project_input").on("xtools.projectLoaded",(function(t,e){$.get(xtBaseUrl+"api/project/namespaces/"+e.project).done((function(t){a(t.api,t.namespaces[14])}))})),$("form").on("submit",(function(){$("#category_input").val(xtools.categoryedits.$select2Input.val().join("|"))})),xtools.application.setupToggleTable(window.countsByCategory,window.categoryChart,"editCount",(function(t){var e=0,n=0;Object.keys(t).forEach((function(a){e+=parseInt(t[a].editCount,10),n+=parseInt(t[a].pageCount,10)}));var a=Object.keys(t).length;$(".category--category").text(a.toLocaleString(i18nLang)+" "+$.i18n("num-categories",a)),$(".category--count").text(e.toLocaleString(i18nLang)),$(".category--percent-of-edit-count").text(100*(e/xtools.categoryedits.userEditCount).toLocaleString(i18nLang)+"%"),$(".category--pages").text(n.toLocaleString(i18nLang))})),$(".contributions-container").length&&(t=$(".contributions-table").length?"setupContributionsNavListeners":"loadContributions",xtools.application[t]((function(t){return"categoryedits-contributions/"+t.project+"/"+t.username+"/"+t.categories+"/"+t.start+"/"+t.end}),"Category"))}))}))},5779:(t,e,n)=>{function a(t){$("#project_input").val(xtools.application.vars.lastProject),$(".site-notice").append("")}function o(){var t=$("#page_input"),e=$("#user_input"),n=$("#namespace_select");if(t[0]||e[0]||$("#project_input")[0]){t.data("typeahead")&&t.data("typeahead").destroy(),e.data("typeahead")&&e.data("typeahead").destroy(),xtools.application.vars.apiPath||(xtools.application.vars.apiPath=$("#page_input").data("api")||$("#user_input").data("api"));var a={url:xtools.application.vars.apiPath,timeout:200,triggerLength:1,method:"get",preDispatch:null,preProcess:null};t[0]&&t.typeahead({ajax:Object.assign(a,{preDispatch:function(t){n[0]&&"0"!==n.val()&&(t=n.find("option:selected").text().trim()+":"+t);return{action:"query",list:"prefixsearch",format:"json",pssearch:t}},preProcess:function(t){var e="";return n[0]&&"0"!==n.val()&&(e=n.find("option:selected").text().trim()),t.query.prefixsearch.map((function(t){return t.title.replace(new RegExp("^"+e+":"),"")}))}})}),e[0]&&e.typeahead({ajax:Object.assign(a,{preDispatch:function(t){return{action:"query",list:"prefixsearch",format:"json",pssearch:"User:"+t}},preProcess:function(t){return t.query.prefixsearch.map((function(t){return t.title.split("/")[0].substr(t.title.indexOf(":")+1)})).filter((function(t,e,n){return n.indexOf(t)===e}))}})});var o=function(t){"&"==t.key&&$(t.target).blur().focus()};t.on("keydown",o),e.on("keydown",o)}}var i;function r(){var t=Date.now();return setInterval((function(){var e=Math.round((Date.now()-t)/1e3),n=Math.floor(e/60),a=("00"+(e-60*n)).slice(-2);$("#submit_timer").text(n+":"+a)}),1e3)}function s(t){t?($(".form-control").prop("readonly",!1),$(".form-submit").prop("disabled",!1),$(".form-submit").text($.i18n("submit")).prop("disabled",!1),i&&(clearInterval(i),i=null)):$("#content form").on("submit",(function(){document.activeElement.blur(),$(".form-control").prop("readonly",!0),$(".form-submit").prop("disabled",!0).html($.i18n("loading")+" "),i=r()}))}function l(){clearInterval(i),loaingTimerId=null;var t=$("#submit_timer").parent()[0];$(t).html(t.initialtext),$(t).removeClass("link-loading")}function u(t){t?l():$("a").filter((function(t,e){return""==e.className&&e.href.startsWith(document.location.origin)&&new URL(e.href).pathname.replaceAll(/[^\/]/g,"").length>1&&"_blank"!=e.target&&e.href.split("#")[0]!=document.location.href})).on("click",(function(t){var e=$(t.target);e.prop("initialtext",e.html()),e.html($.i18n("loading")+" "),e.addClass("link-loading"),i&&l(),i=r()}))}n(8665),n(5086),n(9979),n(4602),n(789),n(933),n(9218),n(2231),n(8636),n(5231),n(6088),n(8476),n(8379),n(7899),n(4189),n(8329),n(9581),n(7136),n(173),n(9073),n(6048),n(9693),n(17),n(9560),n(9389),n(8772),n(4913),n(4989),n(460),xtools={},xtools.application={},xtools.application.vars={sectionOffset:{}},xtools.application.chartGridColor="rgba(0, 0, 0, 0.1)",window.matchMedia("(prefers-color-scheme: dark)").matches&&(Chart.defaults.global.defaultFontColor="#AAA",xtools.application.chartGridColor="#333"),$.i18n({locale:i18nLang}).load(i18nPaths),$((function(){$(document).ready((function(){if($(".xt-hide").on("click",(function(){$(this).hide(),$(this).siblings(".xt-show").show(),$(this).parents(".panel-heading").length?$(this).parents(".panel-heading").siblings(".panel-body").hide():$(this).parents(".xt-show-hide--parent").next(".xt-show-hide--target").hide()})),$(".xt-show").on("click",(function(){$(this).hide(),$(this).siblings(".xt-hide").show(),$(this).parents(".panel-heading").length?$(this).parents(".panel-heading").siblings(".panel-body").show():$(this).parents(".xt-show-hide--parent").next(".xt-show-hide--target").show()})),function(){var t=$(window).width(),e=$(".tool-links").outerWidth(),n=$(".nav-buttons").outerWidth();if(t<768)return;e+n>t&&$(".tool-links--more").removeClass("hidden");var a=$(".tool-links--entry").length;for(;a>0&&e+n>t;){var o=$(".tool-links--nav > .tool-links--entry:not(.active)").last().remove();$(".tool-links--more .dropdown-menu").append(o),e=$(".tool-links").outerWidth(),a--}}(),xtools.application.setupColumnSorting(),function(){var t=$(".xt-toc");if(!t||!t[0])return;xtools.application.vars.tocHeight=t.height();var e=function(){$(".xt-toc").find("a").off("click").on("click",(function(t){document.activeElement.blur();var e=$("#"+$(t.target).data("section"));$(window).scrollTop(e.offset().top-xtools.application.vars.tocHeight),$(this).parents(".xt-toc").find("a").removeClass("bold"),n(),xtools.application.vars.$tocClone.addClass("bold")}))};xtools.application.setupTocListeners=e;var n=function(){xtools.application.vars.$tocClone||(xtools.application.vars.$tocClone=t.clone(),xtools.application.vars.$tocClone.addClass("fixed"),t.after(xtools.application.vars.$tocClone),e())};xtools.application.buildSectionOffsets=function(){$.each(t.find("a"),(function(t,e){var n=$(e).data("section");xtools.application.vars.sectionOffset[n]=$("#"+n).offset().top}))},$(".xt-show, .xt-hide").on("click",xtools.application.buildSectionOffsets),xtools.application.buildSectionOffsets(),e();var a=t.offset().top;$(window).on("scroll.toc",(function(t){var e,o=$(t.target).scrollTop(),i=o>a;i?(xtools.application.vars.$tocClone||n(),Object.keys(xtools.application.vars.sectionOffset).forEach((function(t){o>xtools.application.vars.sectionOffset[t]-xtools.application.vars.tocHeight-1&&(e=xtools.application.vars.$tocClone.find('a[data-section="'+t+'"]'))})),xtools.application.vars.$tocClone.find("a").removeClass("bold"),e&&e.addClass("bold")):!i&&xtools.application.vars.$tocClone&&(xtools.application.vars.$tocClone.remove(),xtools.application.vars.$tocClone=null)}))}(),function(){var t=$(".table-sticky-header");if(!t||!t[0])return;var e,n=t.find("thead tr").eq(0),a=function(){e||(e=n.clone(),n.addClass("sticky-heading"),n.before(e),n.find("th").each((function(t){$(this).css("width",e.find("th").eq(t).outerWidth())})),n.css("width",e.outerWidth()+1))},o=t.offset().top;$(window).on("scroll.stickyHeader",(function(i){var r=$(i.target).scrollTop()>o;r&&!e?a():!r&&e?(n.removeClass("sticky-heading"),e.remove(),e=null):e&&n.css("top",$(window).scrollTop()-t.offset().top)}))}(),function(){var t=$("#project_input");if(!t)return;t.length&&$("#namespace_select").length?(xtools.application.vars.lastProject=$("#project_input").val(),$("#project_input").off("change").on("change",(function(){$("#namespace_select").prop("disabled",!0);var t=this.value;$.get(xtBaseUrl+"api/project/namespaces/"+t).done((function(e){var n=$('#namespace_select option[value="all"]').eq(0).clone();for(var a in $("#namespace_select").html(n),xtools.application.vars.apiPath=e.api,e.namespaces)if(e.namespaces.hasOwnProperty(a)){var i=0===parseInt(a,10)?$.i18n("mainspace"):e.namespaces[a];$("#namespace_select").append("")}$("#namespace_select").val(0),xtools.application.vars.lastProject=t,o()})).fail(a.bind(this,t)).always((function(){$("#namespace_select").prop("disabled",!1)}))})),$("#namespace_select").on("change",o)):($("#user_input")[0]||$("#page_input")[0])&&(xtools.application.vars.lastProject=t.val(),t.on("change",(function(){var e=this.value;$.get(xtBaseUrl+"api/project/normalize/"+e).done((function(n){xtools.application.vars.apiPath=n.api,xtools.application.vars.lastProject=e,o(),t.trigger("xtools.projectLoaded",n)})).fail(a.bind(this,e))})))}(),o(),s(),u(),"function"==typeof URL){var t=new URL(window.location.href).searchParams.get("focus");t&&$("[name=".concat(t,"]")).focus()}})),window.onpageshow=function(t){t.persisted&&(s(!0),u(!0))}})),xtools.application.setupToggleTable=function(t,e,n,a){var o;$(".toggle-table").on("click",".toggle-table--toggle",(function(){o||(o=Object.assign({},t));var i=$(this).data("index"),r=$(this).data("key");"true"===$(this).attr("data-disabled")?(o[r]=t[r],e&&(e.data.datasets[0].data[i]=parseInt(n?o[r][n]:o[r],10)),$(this).attr("data-disabled","false")):(delete o[r],e&&(e.data.datasets[0].data[i]=null),$(this).attr("data-disabled","true")),$(this).parents("tr").toggleClass("excluded"),$(this).find(".glyphicon").toggleClass("glyphicon-remove").toggleClass("glyphicon-plus"),a(o,r,i),e&&e.update()}))},xtools.application.setupColumnSorting=function(){var t,e;$(".sort-link").on("click",(function(){t=e===$(this).data("column")?-t:1,$(".sort-link .glyphicon").removeClass("glyphicon-sort-by-alphabet-alt glyphicon-sort-by-alphabet").addClass("glyphicon-sort");var n=1===t?"glyphicon-sort-by-alphabet-alt":"glyphicon-sort-by-alphabet";$(this).find(".glyphicon").addClass(n).removeClass("glyphicon-sort"),e=$(this).data("column");var a=$(this).parents("table"),o=a.find(".sort-entry--"+e).parent();o.length&&(o.sort((function(n,a){var o=$(n).find(".sort-entry--"+e).data("value")||0,i=$(a).find(".sort-entry--"+e).data("value")||0;return isNaN(o)||(o=parseFloat(o)||0),isNaN(i)||(i=parseFloat(i)||0),oi?-t:0})),$(".sort-entry--rank").length>0&&$.each(o,(function(t,e){$(e).find(".sort-entry--rank").text(t+1)})),a.find("tbody").html(o))}))},xtools.application.setupMultiSelectListeners=function(){var t=$(".multi-select--body:not(.hidden) .multi-select--option");t.on("change",(function(){$(".multi-select--all").prop("checked",$(".multi-select--body:not(.hidden) .multi-select--option:checked").length===t.length)})),$(".multi-select--all").on("click",(function(){t.prop("checked",$(this).prop("checked"))}))}},6618:(t,e,n)=>{function a(){xtools.application.vars.offset||(xtools.application.vars.initialOffset=$(".contributions-container").data("offset"),xtools.application.vars.offset=xtools.application.vars.initialOffset)}n(9218),n(2231),n(8665),n(5086),n(9979),n(4602),n(933),n(7136),n(785),n(9389),n(6048),n(9073),n(173),n(4913),Object.assign(xtools.application.vars,{initialOffset:"",offset:"",prevOffsets:[],initialLoad:!1}),xtools.application.loadContributions=function(t,e){a();var n=$(".contributions-container"),o=$(".contributions-loading"),i=n.data(),r=t(i),s=parseInt(i.limit,10)||50,l=new URLSearchParams(window.location.search),u=xtBaseUrl+r+"/"+xtools.application.vars.offset,c=location.pathname.split("/")[1],d=u.split("/")[1];n.addClass("contributions-container--loading"),o.show(),l.set("limit",s.toString()),l.append("htmlonly","yes"),$.ajax({url:u+"?"+l.toString(),timeout:6e4}).always((function(){n.removeClass("contributions-container--loading"),o.hide()})).done((function(a){if(n.html(a).show(),xtools.application.setupContributionsNavListeners(t,e),xtools.application.vars.initialOffset||(xtools.application.vars.initialOffset=$(".contribs-row-date").first().data("value"),xtools.application.vars.initialLoad=!0),c!==d){var o=new RegExp("^/".concat(d,"/(.*)/"));u=u.replace(o,"/".concat(c,"/$1/"))}xtools.application.vars.initialLoad?xtools.application.vars.initialLoad=!1:(l.delete("htmlonly"),window.history.replaceState(null,document.title,u+"?"+l.toString()),n.parents(".panel")[0].scrollIntoView()),xtools.application.vars.offset"+i+"")).show()}))},xtools.application.setupContributionsNavListeners=function(t,e){a(),$(".contributions--prev").off("click").one("click",(function(n){n.preventDefault(),xtools.application.vars.offset=xtools.application.vars.prevOffsets.pop()||xtools.application.vars.initialOffset,xtools.application.loadContributions(t,e)})),$(".contributions--next").off("click").one("click",(function(n){n.preventDefault(),xtools.application.vars.offset&&xtools.application.vars.prevOffsets.push(xtools.application.vars.offset),xtools.application.vars.offset=$(".contribs-row-date").last().data("value"),xtools.application.loadContributions(t,e)})),$("#contributions_limit").on("change",(function(t){var e=parseInt(t.target.value,10);$(".contributions-container").data("limit",e);var n=function(t){return t[0].toUpperCase()+t.slice(1)};$(".contributions--prev-text").text(n($.i18n("pager-newer-n",e))),$(".contributions--next-text").text(n($.i18n("pager-older-n",e)))}))}},9307:(t,e,n)=>{function a(t,e){var n=0,a=[];Object.keys(t).forEach((function(e){var o=parseInt(t[e],10);a.push(o),n+=o}));var i=Object.keys(t).length;$(".namespaces--namespaces").text(i.toLocaleString(i18nLang)+" "+$.i18n("num-namespaces",i)),$(".namespaces--count").text(n.toLocaleString(i18nLang)),a.forEach((function(t){var e=r(t,n);$(".namespaces-table .sort-entry--count[data-value="+t+"]").text(t.toLocaleString(i18nLang)+" ("+e+")")})),["year","month"].forEach((function(t){var n=window[t+"countsChart"],a=window.namespaces[e]||$.i18n("mainspace");if(n){var i=0;n.data.datasets.forEach((function(t,e){t.label===a&&(i=e)}));var r=n.getDatasetMeta(i);r.hidden=null===r.hidden?!n.data.datasets[i].hidden:null,r.hidden?xtools.editcounter.excludedNamespaces.push(a):xtools.editcounter.excludedNamespaces=xtools.editcounter.excludedNamespaces.filter((function(t){return t!==a})),window[t+"countsChart"].config.data.labels=o(t,n.data.datasets),n.update()}}))}function o(t,e){var n=i(t,e);return Object.keys(n).map((function(e){var a=n[e].toString().length,o=2*(xtools.editcounter.maxDigits[t]-a);return e+Array(o+5).join("\t")+n[e].toLocaleString(i18nLang,{useGrouping:!1})}))}function i(t,e){var n={};return e.forEach((function(e){-1===xtools.editcounter.excludedNamespaces.indexOf(e.label)&&e.data.forEach((function(e,a){n[xtools.editcounter.chartLabels[t][a]]||(n[xtools.editcounter.chartLabels[t][a]]=0),n[xtools.editcounter.chartLabels[t][a]]+=e}))})),n}function r(t,e){return(t/e).toLocaleString(i18nLang,{style:"percent"})}n(8476),n(5086),n(8379),n(7899),n(2231),n(17),n(9581),n(9389),n(6048),n(475),n(9693),n(7136),n(173),n(5195),n(9979),n(2982),n(115),n(1128),n(5843),n(533),n(8825),n(6088),xtools.editcounter={},xtools.editcounter.excludedNamespaces=[],xtools.editcounter.chartLabels={},xtools.editcounter.maxDigits={},$((function(){0!==$("body.editcounter").length&&(xtools.application.setupMultiSelectListeners(),$(".chart-wrapper").each((function(){var t=$(this).data("chart-type");if(void 0===t)return!1;var e=$(this).data("chart-data"),n=$(this).data("chart-labels"),a=$("canvas",$(this));new Chart(a,{type:t,data:{labels:n,datasets:[{data:e}]}})})),xtools.application.setupToggleTable(window.namespaceTotals,window.namespaceChart,null,a))})),xtools.editcounter.setupMonthYearChart=function(t,e,n,a){var s=e.map((function(t){return t.label}));xtools.editcounter.maxDigits[t]=a.toString().length,xtools.editcounter.chartLabels[t]=n;var l=function(){var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"linear";return window[t+"countsChart"]=new Chart($("#"+t+"counts-canvas"),{type:"horizontalBar",data:{labels:o(t,e),datasets:e},options:{tooltips:{mode:"nearest",intersect:!0,callbacks:{label:function(n){var a=i(t,e),o=Object.keys(a).map((function(t){return a[t]})),s=o[n.index],l=r(n.xLabel,s);return n.xLabel.toLocaleString(i18nLang)+" ("+l+")"},title:function(t){return t[0].yLabel.replace(/\t.*/,"")+" - "+s[t[0].datasetIndex]}}},responsive:!0,maintainAspectRatio:!1,scales:{xAxes:[{type:n,stacked:!0,ticks:{beginAtZero:!0,min:"logarithmic"==n?1:0,reverse:"logarithmic"!=n&&i18nRTL,callback:function(t){if(Math.floor(t)===t)return t.toLocaleString(i18nLang)}},gridLines:{color:xtools.application.chartGridColor},afterBuildTicks:function(t){if("logarithmic"==n){var e=[];t.ticks.forEach((function(t,n){(0==n||1.5*e[e.length-1]"+u[11].toLocaleString(i18nLang)),window.sizeHistogramChart=new Chart($("#sizechart-canvas"),{type:"bar",data:{labels:c,datasets:[s,l,i]},options:{tooltips:{mode:"nearest",intersect:!0,callbacks:{label:function(t){return percentage=r(Math.abs(t.yLabel),o),Math.abs(t.yLabel).toLocaleString(i18nLang)+" ("+percentage+")"}}},responsive:!0,maintainAspectRatio:!1,legend:{position:"top"},scales:{yAxes:[{stacked:!0,gridLines:{color:xtools.application.chartGridColor},ticks:{callback:function(t){return Math.abs(t).toLocaleString(i18nLang)}}}],xAxes:[{stacked:!0,gridLines:{color:xtools.application.chartGridColor}}]}}})},xtools.editcounter.setupTimecard=function(t,e){var n=(new Date).getTimezoneOffset()/60;t=t.map((function(t){return t.backgroundColor=new Array(t.data.length).fill(t.backgroundColor),t})),window.chart=new Chart($("#timecard-bubble-chart"),{type:"bubble",data:{datasets:t},options:{responsive:!0,legend:{display:!1},layout:{padding:{right:0}},elements:{point:{radius:function(t){var e=t.dataIndex,n=t.dataset.data[e],a=(t.chart.height-20)/9/2;return n.scale/20*a},hitRadius:8}},scales:{yAxes:[{ticks:{min:0,max:8,stepSize:1,padding:25,callback:function(t,n){return e[n]}},position:i18nRTL?"right":"left",gridLines:{color:xtools.application.chartGridColor}},{ticks:{min:0,max:8,stepSize:1,padding:25,callback:function(e,n){return 0===n||n>7?"":(window.chart?window.chart.data.datasets:t).map((function(t){return t.data})).flat().filter((function(t){return t.y==8-n})).reduce((function(t,e){return t+parseInt(e.value,10)}),0).toLocaleString(i18nLang)}},position:i18nRTL?"left":"right"}],xAxes:[{ticks:{beginAtZero:!0,min:0,max:24,stepSize:1,reverse:i18nRTL,padding:0,callback:function(e,n,a,o){if(24===e)return"";var i=[];if($("#timecard-bubble-chart").attr("width")>=1e3){var r=(window.chart?window.chart.data.datasets:t).map((function(t){return t.data})).flat().filter((function(t){return t.x==e}));i.push(r.reduce((function(t,e){return t+parseInt(e.value,10)}),0).toLocaleString(i18nLang))}return e%2==0&&i.push(e+":00"),i}},gridLines:{color:xtools.application.chartGridColor},position:"bottom"}]},tooltips:{displayColors:!1,callbacks:{title:function(t){return e[7-t[0].yLabel+1]+" "+parseInt(t[0].xLabel)+":"+String(t[0].xLabel%1*60).padStart(2,"0")},label:function(e){var n=[t[e.datasetIndex].data[e.index].value];return"".concat(n.toLocaleString(i18nLang)," ").concat($.i18n("num-edits",[n]))}}}}}),$((function(){$(".use-local-time").prop("checked",!1).on("click",(function(){var t=$(this).is(":checked")?n:-n,e=new Array(7);chart.data.datasets.forEach((function(t){return e[t.data[0].day_of_week-1]=t.backgroundColor[0]})),chart.data.datasets=chart.data.datasets.map((function(n){var a=[];return n.data=n.data.map((function(n){var o=parseFloat(n.hour)-t,i=parseInt(n.day_of_week,10);return o<0?(o=24+o,(i-=1)<1&&(i=7+i)):o>=24&&(o-=24,(i+=1)>7&&(i-=7)),n.hour=o.toString(),n.x=o.toString(),n.day_of_week=i.toString(),n.y=(8-i).toString(),a.push(e[i-1]),n})),n.backgroundColor=a,n})),$(this).is(":checked"),chart.update()}))}))}},6730:(t,e,n)=>{n(115),xtools.globalcontribs={},$((function(){0!==$("body.globalcontribs").length&&xtools.application.setupContributionsNavListeners((function(t){return"globalcontribs/".concat(t.username,"/").concat(t.namespace,"/").concat(t.start,"/").concat(t.end)}),"globalcontribs")}))},1680:(t,e,n)=>{n(7136),n(173),xtools.pageinfo={},$((function(){if($("body.pageinfo").length){var t=function(){xtools.application.setupToggleTable(window.textshares,window.textsharesChart,"percentage",$.noop)},e=$(".textshares-container");if(e[0]){var n=xtBaseUrl+"authorship/"+e.data("project")+"/"+e.data("page")+"/"+(xtools.pageinfo.endDate?xtools.pageinfo.endDate+"/":"");n="".concat(n.replace(/\/$/,""),"?htmlonly=yes"),$.ajax({url:n,timeout:3e4}).done((function(n){e.replaceWith(n),xtools.application.buildSectionOffsets(),xtools.application.setupTocListeners(),xtools.application.setupColumnSorting(),t()})).fail((function(t,n,a){e.replaceWith($.i18n("api-error","Authorship API: "+a+""))}))}else $(".textshares-table").length&&t()}}))},1595:(t,e,n)=>{n(8476),n(5086),n(8379),n(7899),n(4867),n(9389),n(6048),n(8636),xtools.pages={},$((function(){if($("body.pages").length){var t={};xtools.application.setupToggleTable(window.countsByNamespace,window.pieChart,"count",(function(t){var e={count:0,deleted:0,redirects:0};Object.keys(t).forEach((function(n){e.count+=t[n].count,e.deleted+=t[n].deleted,e.redirects+=t[n].redirects})),$(".namespaces--namespaces").text(Object.keys(t).length.toLocaleString()+" "+$.i18n("num-namespaces",Object.keys(t).length)),$(".namespaces--pages").text(e.count.toLocaleString()),$(".namespaces--deleted").text(e.deleted.toLocaleString()+" ("+(e.deleted/e.count*100).toFixed(1)+"%)"),$(".namespaces--redirects").text(e.redirects.toLocaleString()+" ("+(e.redirects/e.count*100).toFixed(1)+"%)")})),$(".deleted-page").on("mouseenter",(function(e){var n=$(this).data("page-title"),a=$(this).data("namespace"),o=$(this).data("datetime").toString(),i=$(this).data("username"),r=function(t){$(e.target).find(".tooltip-body").html(t)};if(void 0!==t[a+"/"+n])return r(t[a+"/"+n]);var s=function(){r(""+$.i18n("api-error","Deletion Summary API")+"")};$.ajax({url:xtBaseUrl+"pages/deletion_summary/"+wikiDomain+"/"+i+"/"+a+"/"+n+"/"+o}).done((function(e){if(null===e.summary)return s();r(e.summary),t[a+"/"+n]=e.summary})).fail(s)}))}}))},1223:()=>{xtools.topedits={},$((function(){$("body.topedits").length&&$("#namespace_select").on("change",(function(){$("#page_input").prop("disabled","all"===$(this).val())}))}))},7852:(t,e,n)=>{var a,o,i,s;function l(t){return l="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},l(t)}n(7136),n(6255),n(2231),n(4913),n(6088),n(9389),n(5086),n(6048),n(8665),n(4602),n(115),n(8476),n(9693),n(475),n(9581),n(2982),n(4009),n(17),n(2157),n(8763),n(9560),n(5852),n(8379),n(7899),n(533),n(4538),n(1145),n(6943),n(8772),n(5231),n(4867),n(4895),n(4189),n(557),n(8844),n(2006),n(3534),n(590),n(4216),n(9979),s=function(){return function t(e,n,a){function o(r,s){if(!n[r]){if(!e[r]){if(i)return i(r,!0);var l=new Error("Cannot find module '"+r+"'");throw l.code="MODULE_NOT_FOUND",l}var u=n[r]={exports:{}};e[r][0].call(u.exports,(function(t){return o(e[r][1][t]||t)}),u,u.exports,t,e,n,a)}return n[r].exports}for(var i=void 0,r=0;rn?(e+.05)/(n+.05):(n+.05)/(e+.05)},level:function(t){var e=this.contrast(t);return e>=7.1?"AAA":e>=4.5?"AA":""},dark:function(){var t=this.values.rgb;return(299*t[0]+587*t[1]+114*t[2])/1e3<128},light:function(){return!this.dark()},negate:function(){for(var t=[],e=0;e<3;e++)t[e]=255-this.values.rgb[e];return this.setValues("rgb",t),this},lighten:function(t){var e=this.values.hsl;return e[2]+=e[2]*t,this.setValues("hsl",e),this},darken:function(t){var e=this.values.hsl;return e[2]-=e[2]*t,this.setValues("hsl",e),this},saturate:function(t){var e=this.values.hsl;return e[1]+=e[1]*t,this.setValues("hsl",e),this},desaturate:function(t){var e=this.values.hsl;return e[1]-=e[1]*t,this.setValues("hsl",e),this},whiten:function(t){var e=this.values.hwb;return e[1]+=e[1]*t,this.setValues("hwb",e),this},blacken:function(t){var e=this.values.hwb;return e[2]+=e[2]*t,this.setValues("hwb",e),this},greyscale:function(){var t=this.values.rgb,e=.3*t[0]+.59*t[1]+.11*t[2];return this.setValues("rgb",[e,e,e]),this},clearer:function(t){var e=this.values.alpha;return this.setValues("alpha",e-e*t),this},opaquer:function(t){var e=this.values.alpha;return this.setValues("alpha",e+e*t),this},rotate:function(t){var e=this.values.hsl,n=(e[0]+t)%360;return e[0]=n<0?360+n:n,this.setValues("hsl",e),this},mix:function(t,e){var n=this,a=t,o=void 0===e?.5:e,i=2*o-1,r=n.alpha()-a.alpha(),s=((i*r==-1?i:(i+r)/(1+i*r))+1)/2,l=1-s;return this.rgb(s*n.red()+l*a.red(),s*n.green()+l*a.green(),s*n.blue()+l*a.blue()).alpha(n.alpha()*o+a.alpha()*(1-o))},toJSON:function(){return this.rgb()},clone:function(){var t,e,n=new i,a=this.values,o=n.values;for(var r in a)a.hasOwnProperty(r)&&(t=a[r],"[object Array]"===(e={}.toString.call(t))?o[r]=t.slice(0):"[object Number]"===e?o[r]=t:console.error("unexpected color value:",t));return n}},i.prototype.spaces={rgb:["red","green","blue"],hsl:["hue","saturation","lightness"],hsv:["hue","saturation","value"],hwb:["hue","whiteness","blackness"],cmyk:["cyan","magenta","yellow","black"]},i.prototype.maxes={rgb:[255,255,255],hsl:[360,100,100],hsv:[360,100,100],hwb:[360,100,100],cmyk:[100,100,100,100]},i.prototype.getValues=function(t){for(var e=this.values,n={},a=0;a.04045?Math.pow((e+.055)/1.055,2.4):e/12.92)+.3576*(n=n>.04045?Math.pow((n+.055)/1.055,2.4):n/12.92)+.1805*(a=a>.04045?Math.pow((a+.055)/1.055,2.4):a/12.92)),100*(.2126*e+.7152*n+.0722*a),100*(.0193*e+.1192*n+.9505*a)]}function c(t){var e=u(t),n=e[0],a=e[1],o=e[2];return a/=100,o/=108.883,n=(n/=95.047)>.008856?Math.pow(n,1/3):7.787*n+16/116,[116*(a=a>.008856?Math.pow(a,1/3):7.787*a+16/116)-16,500*(n-a),200*(a-(o=o>.008856?Math.pow(o,1/3):7.787*o+16/116))]}function d(t){var e,n,a,o,i,r=t[0]/360,s=t[1]/100,l=t[2]/100;if(0==s)return[i=255*l,i,i];e=2*l-(n=l<.5?l*(1+s):l+s-l*s),o=[0,0,0];for(var u=0;u<3;u++)(a=r+1/3*-(u-1))<0&&a++,a>1&&a--,i=6*a<1?e+6*(n-e)*a:2*a<1?n:3*a<2?e+(n-e)*(2/3-a)*6:e,o[u]=255*i;return o}function h(t){var e=t[0]/60,n=t[1]/100,a=t[2]/100,o=Math.floor(e)%6,i=e-Math.floor(e),r=255*a*(1-n),s=255*a*(1-n*i),l=255*a*(1-n*(1-i));switch(a*=255,o){case 0:return[a,l,r];case 1:return[s,a,r];case 2:return[r,a,l];case 3:return[r,s,a];case 4:return[l,r,a];case 5:return[a,r,s]}}function f(t){var e,n,a,o,i=t[0]/360,s=t[1]/100,l=t[2]/100,u=s+l;switch(u>1&&(s/=u,l/=u),a=6*i-(e=Math.floor(6*i)),!!(1&e)&&(a=1-a),o=s+a*((n=1-l)-s),e){default:case 6:case 0:r=n,g=o,b=s;break;case 1:r=o,g=n,b=s;break;case 2:r=s,g=n,b=o;break;case 3:r=s,g=o,b=n;break;case 4:r=o,g=s,b=n;break;case 5:r=n,g=s,b=o}return[255*r,255*g,255*b]}function p(t){var e=t[0]/100,n=t[1]/100,a=t[2]/100,o=t[3]/100;return[255*(1-Math.min(1,e*(1-o)+o)),255*(1-Math.min(1,n*(1-o)+o)),255*(1-Math.min(1,a*(1-o)+o))]}function v(t){var e,n,a,o=t[0]/100,i=t[1]/100,r=t[2]/100;return n=-.9689*o+1.8758*i+.0415*r,a=.0557*o+-.204*i+1.057*r,e=(e=3.2406*o+-1.5372*i+-.4986*r)>.0031308?1.055*Math.pow(e,1/2.4)-.055:e*=12.92,n=n>.0031308?1.055*Math.pow(n,1/2.4)-.055:n*=12.92,a=a>.0031308?1.055*Math.pow(a,1/2.4)-.055:a*=12.92,[255*(e=Math.min(Math.max(0,e),1)),255*(n=Math.min(Math.max(0,n),1)),255*(a=Math.min(Math.max(0,a),1))]}function m(t){var e=t[0],n=t[1],a=t[2];return n/=100,a/=108.883,e=(e/=95.047)>.008856?Math.pow(e,1/3):7.787*e+16/116,[116*(n=n>.008856?Math.pow(n,1/3):7.787*n+16/116)-16,500*(e-n),200*(n-(a=a>.008856?Math.pow(a,1/3):7.787*a+16/116))]}function x(t){var e,n,a,o,i=t[0],r=t[1],s=t[2];return i<=8?o=(n=100*i/903.3)/100*7.787+16/116:(n=100*Math.pow((i+16)/116,3),o=Math.pow(n/100,1/3)),[e=e/95.047<=.008856?e=95.047*(r/500+o-16/116)/7.787:95.047*Math.pow(r/500+o,3),n,a=a/108.883<=.008859?a=108.883*(o-s/200-16/116)/7.787:108.883*Math.pow(o-s/200,3)]}function y(t){var e,n=t[0],a=t[1],o=t[2];return(e=360*Math.atan2(o,a)/2/Math.PI)<0&&(e+=360),[n,Math.sqrt(a*a+o*o),e]}function k(t){return v(x(t))}function w(t){var e,n=t[0],a=t[1];return e=t[2]/360*2*Math.PI,[n,a*Math.cos(e),a*Math.sin(e)]}function C(t){return S[t]}e.exports={rgb2hsl:a,rgb2hsv:o,rgb2hwb:i,rgb2cmyk:s,rgb2keyword:l,rgb2xyz:u,rgb2lab:c,rgb2lch:function(t){return y(c(t))},hsl2rgb:d,hsl2hsv:function(t){var e=t[0],n=t[1]/100,a=t[2]/100;return 0===a?[0,0,0]:[e,2*(n*=(a*=2)<=1?a:2-a)/(a+n)*100,(a+n)/2*100]},hsl2hwb:function(t){return i(d(t))},hsl2cmyk:function(t){return s(d(t))},hsl2keyword:function(t){return l(d(t))},hsv2rgb:h,hsv2hsl:function(t){var e,n,a=t[0],o=t[1]/100,i=t[2]/100;return e=o*i,[a,100*(e=(e/=(n=(2-o)*i)<=1?n:2-n)||0),100*(n/=2)]},hsv2hwb:function(t){return i(h(t))},hsv2cmyk:function(t){return s(h(t))},hsv2keyword:function(t){return l(h(t))},hwb2rgb:f,hwb2hsl:function(t){return a(f(t))},hwb2hsv:function(t){return o(f(t))},hwb2cmyk:function(t){return s(f(t))},hwb2keyword:function(t){return l(f(t))},cmyk2rgb:p,cmyk2hsl:function(t){return a(p(t))},cmyk2hsv:function(t){return o(p(t))},cmyk2hwb:function(t){return i(p(t))},cmyk2keyword:function(t){return l(p(t))},keyword2rgb:C,keyword2hsl:function(t){return a(C(t))},keyword2hsv:function(t){return o(C(t))},keyword2hwb:function(t){return i(C(t))},keyword2cmyk:function(t){return s(C(t))},keyword2lab:function(t){return c(C(t))},keyword2xyz:function(t){return u(C(t))},xyz2rgb:v,xyz2lab:m,xyz2lch:function(t){return y(m(t))},lab2xyz:x,lab2rgb:k,lab2lch:y,lch2lab:w,lch2xyz:function(t){return x(w(t))},lch2rgb:function(t){return k(w(t))}};var S={aliceblue:[240,248,255],antiquewhite:[250,235,215],aqua:[0,255,255],aquamarine:[127,255,212],azure:[240,255,255],beige:[245,245,220],bisque:[255,228,196],black:[0,0,0],blanchedalmond:[255,235,205],blue:[0,0,255],blueviolet:[138,43,226],brown:[165,42,42],burlywood:[222,184,135],cadetblue:[95,158,160],chartreuse:[127,255,0],chocolate:[210,105,30],coral:[255,127,80],cornflowerblue:[100,149,237],cornsilk:[255,248,220],crimson:[220,20,60],cyan:[0,255,255],darkblue:[0,0,139],darkcyan:[0,139,139],darkgoldenrod:[184,134,11],darkgray:[169,169,169],darkgreen:[0,100,0],darkgrey:[169,169,169],darkkhaki:[189,183,107],darkmagenta:[139,0,139],darkolivegreen:[85,107,47],darkorange:[255,140,0],darkorchid:[153,50,204],darkred:[139,0,0],darksalmon:[233,150,122],darkseagreen:[143,188,143],darkslateblue:[72,61,139],darkslategray:[47,79,79],darkslategrey:[47,79,79],darkturquoise:[0,206,209],darkviolet:[148,0,211],deeppink:[255,20,147],deepskyblue:[0,191,255],dimgray:[105,105,105],dimgrey:[105,105,105],dodgerblue:[30,144,255],firebrick:[178,34,34],floralwhite:[255,250,240],forestgreen:[34,139,34],fuchsia:[255,0,255],gainsboro:[220,220,220],ghostwhite:[248,248,255],gold:[255,215,0],goldenrod:[218,165,32],gray:[128,128,128],green:[0,128,0],greenyellow:[173,255,47],grey:[128,128,128],honeydew:[240,255,240],hotpink:[255,105,180],indianred:[205,92,92],indigo:[75,0,130],ivory:[255,255,240],khaki:[240,230,140],lavender:[230,230,250],lavenderblush:[255,240,245],lawngreen:[124,252,0],lemonchiffon:[255,250,205],lightblue:[173,216,230],lightcoral:[240,128,128],lightcyan:[224,255,255],lightgoldenrodyellow:[250,250,210],lightgray:[211,211,211],lightgreen:[144,238,144],lightgrey:[211,211,211],lightpink:[255,182,193],lightsalmon:[255,160,122],lightseagreen:[32,178,170],lightskyblue:[135,206,250],lightslategray:[119,136,153],lightslategrey:[119,136,153],lightsteelblue:[176,196,222],lightyellow:[255,255,224],lime:[0,255,0],limegreen:[50,205,50],linen:[250,240,230],magenta:[255,0,255],maroon:[128,0,0],mediumaquamarine:[102,205,170],mediumblue:[0,0,205],mediumorchid:[186,85,211],mediumpurple:[147,112,219],mediumseagreen:[60,179,113],mediumslateblue:[123,104,238],mediumspringgreen:[0,250,154],mediumturquoise:[72,209,204],mediumvioletred:[199,21,133],midnightblue:[25,25,112],mintcream:[245,255,250],mistyrose:[255,228,225],moccasin:[255,228,181],navajowhite:[255,222,173],navy:[0,0,128],oldlace:[253,245,230],olive:[128,128,0],olivedrab:[107,142,35],orange:[255,165,0],orangered:[255,69,0],orchid:[218,112,214],palegoldenrod:[238,232,170],palegreen:[152,251,152],paleturquoise:[175,238,238],palevioletred:[219,112,147],papayawhip:[255,239,213],peachpuff:[255,218,185],peru:[205,133,63],pink:[255,192,203],plum:[221,160,221],powderblue:[176,224,230],purple:[128,0,128],rebeccapurple:[102,51,153],red:[255,0,0],rosybrown:[188,143,143],royalblue:[65,105,225],saddlebrown:[139,69,19],salmon:[250,128,114],sandybrown:[244,164,96],seagreen:[46,139,87],seashell:[255,245,238],sienna:[160,82,45],silver:[192,192,192],skyblue:[135,206,235],slateblue:[106,90,205],slategray:[112,128,144],slategrey:[112,128,144],snow:[255,250,250],springgreen:[0,255,127],steelblue:[70,130,180],tan:[210,180,140],teal:[0,128,128],thistle:[216,191,216],tomato:[255,99,71],turquoise:[64,224,208],violet:[238,130,238],wheat:[245,222,179],white:[255,255,255],whitesmoke:[245,245,245],yellow:[255,255,0],yellowgreen:[154,205,50]},M={};for(var _ in S)M[JSON.stringify(S[_])]=_},{}],5:[function(t,e,n){var a=t(4),o=function(){return new u};for(var i in a){o[i+"Raw"]=function(t){return function(e){return"number"==typeof e&&(e=Array.prototype.slice.call(arguments)),a[t](e)}}(i);var r=/(\w+)2(\w+)/.exec(i),s=r[1],l=r[2];(o[s]=o[s]||{})[l]=o[i]=function(t){return function(e){"number"==typeof e&&(e=Array.prototype.slice.call(arguments));var n=a[t](e);if("string"==typeof n||void 0===n)return n;for(var o=0;o0&&(t[0].yLabel?n=t[0].yLabel:e.labels.length>0&&t[0].index=0&&o>0)&&(v+=o));return i=d.getPixelForValue(v),{size:s=((r=d.getPixelForValue(v+f))-i)/2,base:i,head:r,center:r+s/2}},calculateBarIndexPixels:function(t,e,n){var a,o,r,s,l,u=n.scale.options,c=this.getStackIndex(t),d=n.pixels,h=d[e],f=d.length,p=n.start,g=n.end;return 1===f?(a=h>p?h-p:g-h,o=h0&&(a=(h-d[e-1])/2,e===f-1&&(o=a)),e');var n=t.data,a=n.datasets,o=n.labels;if(a.length)for(var i=0;i'),o[i]&&e.push(o[i]),e.push("");return e.push(""),e.join("")},legend:{labels:{generateLabels:function(t){var e=t.data;return e.labels.length&&e.datasets.length?e.labels.map((function(n,a){var o=t.getDatasetMeta(0),r=e.datasets[0],s=o.data[a],l=s&&s.custom||{},u=i.valueAtIndexOrDefault,c=t.options.elements.arc;return{text:n,fillStyle:l.backgroundColor?l.backgroundColor:u(r.backgroundColor,a,c.backgroundColor),strokeStyle:l.borderColor?l.borderColor:u(r.borderColor,a,c.borderColor),lineWidth:l.borderWidth?l.borderWidth:u(r.borderWidth,a,c.borderWidth),hidden:isNaN(r.data[a])||o.data[a].hidden,index:a}})):[]}},onClick:function(t,e){var n,a,o,i=e.index,r=this.chart;for(n=0,a=(r.data.datasets||[]).length;n=Math.PI?-1:p<-Math.PI?1:0))+f,v={x:Math.cos(p),y:Math.sin(p)},m={x:Math.cos(g),y:Math.sin(g)},b=p<=0&&g>=0||p<=2*Math.PI&&2*Math.PI<=g,x=p<=.5*Math.PI&&.5*Math.PI<=g||p<=2.5*Math.PI&&2.5*Math.PI<=g,y=p<=-Math.PI&&-Math.PI<=g||p<=Math.PI&&Math.PI<=g,k=p<=.5*-Math.PI&&.5*-Math.PI<=g||p<=1.5*Math.PI&&1.5*Math.PI<=g,w=h/100,C={x:y?-1:Math.min(v.x*(v.x<0?1:w),m.x*(m.x<0?1:w)),y:k?-1:Math.min(v.y*(v.y<0?1:w),m.y*(m.y<0?1:w))},S={x:b?1:Math.max(v.x*(v.x>0?1:w),m.x*(m.x>0?1:w)),y:x?1:Math.max(v.y*(v.y>0?1:w),m.y*(m.y>0?1:w))},M={width:.5*(S.x-C.x),height:.5*(S.y-C.y)};u=Math.min(s/M.width,l/M.height),c={x:-.5*(S.x+C.x),y:-.5*(S.y+C.y)}}n.borderWidth=e.getMaxBorderWidth(d.data),n.outerRadius=Math.max((u-n.borderWidth)/2,0),n.innerRadius=Math.max(h?n.outerRadius/100*h:0,0),n.radiusLength=(n.outerRadius-n.innerRadius)/n.getVisibleDatasetCount(),n.offsetX=c.x*n.outerRadius,n.offsetY=c.y*n.outerRadius,d.total=e.calculateTotal(),e.outerRadius=n.outerRadius-n.radiusLength*e.getRingIndex(e.index),e.innerRadius=Math.max(e.outerRadius-n.radiusLength,0),i.each(d.data,(function(n,a){e.updateElement(n,a,t)}))},updateElement:function(t,e,n){var a=this,o=a.chart,r=o.chartArea,s=o.options,l=s.animation,u=(r.left+r.right)/2,c=(r.top+r.bottom)/2,d=s.rotation,h=s.rotation,f=a.getDataset(),p=n&&l.animateRotate||t.hidden?0:a.calculateCircumference(f.data[e])*(s.circumference/(2*Math.PI)),g=n&&l.animateScale?0:a.innerRadius,v=n&&l.animateScale?0:a.outerRadius,m=i.valueAtIndexOrDefault;i.extend(t,{_datasetIndex:a.index,_index:e,_model:{x:u+o.offsetX,y:c+o.offsetY,startAngle:d,endAngle:h,circumference:p,outerRadius:v,innerRadius:g,label:m(f.label,e,o.data.labels[e])}});var b=t._model;this.removeHoverStyle(t),n&&l.animateRotate||(b.startAngle=0===e?s.rotation:a.getMeta().data[e-1]._model.endAngle,b.endAngle=b.startAngle+b.circumference),t.pivot()},removeHoverStyle:function(e){t.DatasetController.prototype.removeHoverStyle.call(this,e,this.chart.options.elements.arc)},calculateTotal:function(){var t,e=this.getDataset(),n=this.getMeta(),a=0;return i.each(n.data,(function(n,o){t=e.data[o],isNaN(t)||n.hidden||(a+=Math.abs(t))})),a},calculateCircumference:function(t){var e=this.getMeta().total;return e>0&&!isNaN(t)?2*Math.PI*(t/e):0},getMaxBorderWidth:function(t){for(var e,n,a=0,o=this.index,i=t.length,r=0;r(a=e>a?e:a)?n:a;return a}})}},{25:25,40:40,45:45}],18:[function(t,e,n){"use strict";var a=t(25),o=t(40),i=t(45);a._set("line",{showLines:!0,spanGaps:!1,hover:{mode:"label"},scales:{xAxes:[{type:"category",id:"x-axis-0"}],yAxes:[{type:"linear",id:"y-axis-0"}]}}),e.exports=function(t){function e(t,e){return i.valueOrDefault(t.showLine,e.showLines)}t.controllers.line=t.DatasetController.extend({datasetElementType:o.Line,dataElementType:o.Point,update:function(t){var n,a,o,r=this,s=r.getMeta(),l=s.dataset,u=s.data||[],c=r.chart.options,d=c.elements.line,h=r.getScaleForId(s.yAxisID),f=r.getDataset(),p=e(f,c);for(p&&(o=l.custom||{},void 0!==f.tension&&void 0===f.lineTension&&(f.lineTension=f.tension),l._scale=h,l._datasetIndex=r.index,l._children=u,l._model={spanGaps:f.spanGaps?f.spanGaps:c.spanGaps,tension:o.tension?o.tension:i.valueOrDefault(f.lineTension,d.tension),backgroundColor:o.backgroundColor?o.backgroundColor:f.backgroundColor||d.backgroundColor,borderWidth:o.borderWidth?o.borderWidth:f.borderWidth||d.borderWidth,borderColor:o.borderColor?o.borderColor:f.borderColor||d.borderColor,borderCapStyle:o.borderCapStyle?o.borderCapStyle:f.borderCapStyle||d.borderCapStyle,borderDash:o.borderDash?o.borderDash:f.borderDash||d.borderDash,borderDashOffset:o.borderDashOffset?o.borderDashOffset:f.borderDashOffset||d.borderDashOffset,borderJoinStyle:o.borderJoinStyle?o.borderJoinStyle:f.borderJoinStyle||d.borderJoinStyle,fill:o.fill?o.fill:void 0!==f.fill?f.fill:d.fill,steppedLine:o.steppedLine?o.steppedLine:i.valueOrDefault(f.steppedLine,d.stepped),cubicInterpolationMode:o.cubicInterpolationMode?o.cubicInterpolationMode:i.valueOrDefault(f.cubicInterpolationMode,d.cubicInterpolationMode)},l.pivot()),n=0,a=u.length;n');var n=t.data,a=n.datasets,o=n.labels;if(a.length)for(var i=0;i'),o[i]&&e.push(o[i]),e.push("");return e.push(""),e.join("")},legend:{labels:{generateLabels:function(t){var e=t.data;return e.labels.length&&e.datasets.length?e.labels.map((function(n,a){var o=t.getDatasetMeta(0),r=e.datasets[0],s=o.data[a].custom||{},l=i.valueAtIndexOrDefault,u=t.options.elements.arc;return{text:n,fillStyle:s.backgroundColor?s.backgroundColor:l(r.backgroundColor,a,u.backgroundColor),strokeStyle:s.borderColor?s.borderColor:l(r.borderColor,a,u.borderColor),lineWidth:s.borderWidth?s.borderWidth:l(r.borderWidth,a,u.borderWidth),hidden:isNaN(r.data[a])||o.data[a].hidden,index:a}})):[]}},onClick:function(t,e){var n,a,o,i=e.index,r=this.chart;for(n=0,a=(r.data.datasets||[]).length;n0&&!isNaN(t)?2*Math.PI/e:0}})}},{25:25,40:40,45:45}],20:[function(t,e,n){"use strict";var a=t(25),o=t(40),i=t(45);a._set("radar",{scale:{type:"radialLinear"},elements:{line:{tension:0}}}),e.exports=function(t){t.controllers.radar=t.DatasetController.extend({datasetElementType:o.Line,dataElementType:o.Point,linkScales:i.noop,update:function(t){var e=this,n=e.getMeta(),a=n.dataset,o=n.data,r=a.custom||{},s=e.getDataset(),l=e.chart.options.elements.line,u=e.chart.scale;void 0!==s.tension&&void 0===s.lineTension&&(s.lineTension=s.tension),i.extend(n.dataset,{_datasetIndex:e.index,_scale:u,_children:o,_loop:!0,_model:{tension:r.tension?r.tension:i.valueOrDefault(s.lineTension,l.tension),backgroundColor:r.backgroundColor?r.backgroundColor:s.backgroundColor||l.backgroundColor,borderWidth:r.borderWidth?r.borderWidth:s.borderWidth||l.borderWidth,borderColor:r.borderColor?r.borderColor:s.borderColor||l.borderColor,fill:r.fill?r.fill:void 0!==s.fill?s.fill:l.fill,borderCapStyle:r.borderCapStyle?r.borderCapStyle:s.borderCapStyle||l.borderCapStyle,borderDash:r.borderDash?r.borderDash:s.borderDash||l.borderDash,borderDashOffset:r.borderDashOffset?r.borderDashOffset:s.borderDashOffset||l.borderDashOffset,borderJoinStyle:r.borderJoinStyle?r.borderJoinStyle:s.borderJoinStyle||l.borderJoinStyle}}),n.dataset.pivot(),i.each(o,(function(n,a){e.updateElement(n,a,t)}),e),e.updateBezierControlPoints()},updateElement:function(t,e,n){var a=this,o=t.custom||{},r=a.getDataset(),s=a.chart.scale,l=a.chart.options.elements.point,u=s.getPointPositionForValue(e,r.data[e]);void 0!==r.radius&&void 0===r.pointRadius&&(r.pointRadius=r.radius),void 0!==r.hitRadius&&void 0===r.pointHitRadius&&(r.pointHitRadius=r.hitRadius),i.extend(t,{_datasetIndex:a.index,_index:e,_scale:s,_model:{x:n?s.xCenter:u.x,y:n?s.yCenter:u.y,tension:o.tension?o.tension:i.valueOrDefault(r.lineTension,a.chart.options.elements.line.tension),radius:o.radius?o.radius:i.valueAtIndexOrDefault(r.pointRadius,e,l.radius),backgroundColor:o.backgroundColor?o.backgroundColor:i.valueAtIndexOrDefault(r.pointBackgroundColor,e,l.backgroundColor),borderColor:o.borderColor?o.borderColor:i.valueAtIndexOrDefault(r.pointBorderColor,e,l.borderColor),borderWidth:o.borderWidth?o.borderWidth:i.valueAtIndexOrDefault(r.pointBorderWidth,e,l.borderWidth),pointStyle:o.pointStyle?o.pointStyle:i.valueAtIndexOrDefault(r.pointStyle,e,l.pointStyle),hitRadius:o.hitRadius?o.hitRadius:i.valueAtIndexOrDefault(r.pointHitRadius,e,l.hitRadius)}}),t._model.skip=o.skip?o.skip:isNaN(t._model.x)||isNaN(t._model.y)},updateBezierControlPoints:function(){var t=this.chart.chartArea,e=this.getMeta();i.each(e.data,(function(n,a){var o=n._model,r=i.splineCurve(i.previousItem(e.data,a,!0)._model,o,i.nextItem(e.data,a,!0)._model,o.tension);o.controlPointPreviousX=Math.max(Math.min(r.previous.x,t.right),t.left),o.controlPointPreviousY=Math.max(Math.min(r.previous.y,t.bottom),t.top),o.controlPointNextX=Math.max(Math.min(r.next.x,t.right),t.left),o.controlPointNextY=Math.max(Math.min(r.next.y,t.bottom),t.top),n.pivot()}))},setHoverStyle:function(t){var e=this.chart.data.datasets[t._datasetIndex],n=t.custom||{},a=t._index,o=t._model;o.radius=n.hoverRadius?n.hoverRadius:i.valueAtIndexOrDefault(e.pointHoverRadius,a,this.chart.options.elements.point.hoverRadius),o.backgroundColor=n.hoverBackgroundColor?n.hoverBackgroundColor:i.valueAtIndexOrDefault(e.pointHoverBackgroundColor,a,i.getHoverColor(o.backgroundColor)),o.borderColor=n.hoverBorderColor?n.hoverBorderColor:i.valueAtIndexOrDefault(e.pointHoverBorderColor,a,i.getHoverColor(o.borderColor)),o.borderWidth=n.hoverBorderWidth?n.hoverBorderWidth:i.valueAtIndexOrDefault(e.pointHoverBorderWidth,a,o.borderWidth)},removeHoverStyle:function(t){var e=this.chart.data.datasets[t._datasetIndex],n=t.custom||{},a=t._index,o=t._model,r=this.chart.options.elements.point;o.radius=n.radius?n.radius:i.valueAtIndexOrDefault(e.pointRadius,a,r.radius),o.backgroundColor=n.backgroundColor?n.backgroundColor:i.valueAtIndexOrDefault(e.pointBackgroundColor,a,r.backgroundColor),o.borderColor=n.borderColor?n.borderColor:i.valueAtIndexOrDefault(e.pointBorderColor,a,r.borderColor),o.borderWidth=n.borderWidth?n.borderWidth:i.valueAtIndexOrDefault(e.pointBorderWidth,a,r.borderWidth)}})}},{25:25,40:40,45:45}],21:[function(t,e,n){"use strict";t(25)._set("scatter",{hover:{mode:"single"},scales:{xAxes:[{id:"x-axis-1",type:"linear",position:"bottom"}],yAxes:[{id:"y-axis-1",type:"linear",position:"left"}]},showLines:!1,tooltips:{callbacks:{title:function(){return""},label:function(t){return"("+t.xLabel+", "+t.yLabel+")"}}}}),e.exports=function(t){t.controllers.scatter=t.controllers.line}},{25:25}],22:[function(t,e,n){"use strict";var a=t(25),o=t(26),i=t(45);a._set("global",{animation:{duration:1e3,easing:"easeOutQuart",onProgress:i.noop,onComplete:i.noop}}),e.exports=function(t){t.Animation=o.extend({chart:null,currentStep:0,numSteps:60,easing:"",render:null,onAnimationProgress:null,onAnimationComplete:null}),t.animationService={frameDuration:17,animations:[],dropFrames:0,request:null,addAnimation:function(t,e,n,a){var o,i,r=this.animations;for(e.chart=t,a||(t.animating=!0),o=0,i=r.length;o1&&(n=Math.floor(t.dropFrames),t.dropFrames=t.dropFrames%1),t.advance(1+n);var a=Date.now();t.dropFrames+=(a-e)/t.frameDuration,t.animations.length>0&&t.requestAnimationFrame()},advance:function(t){for(var e,n,a=this.animations,o=0;o=e.numSteps?(i.callback(e.onAnimationComplete,[e],n),n.animating=!1,a.splice(o,1)):++o}},Object.defineProperty(t.Animation.prototype,"animationObject",{get:function(){return this}}),Object.defineProperty(t.Animation.prototype,"chartInstance",{get:function(){return this.chart},set:function(t){this.chart=t}})}},{25:25,26:26,45:45}],23:[function(t,e,n){"use strict";var a=t(25),o=t(45),i=t(28),r=t(48);e.exports=function(t){function e(t){var e=(t=t||{}).data=t.data||{};return e.datasets=e.datasets||[],e.labels=e.labels||[],t.options=o.configMerge(a.global,a[t.type],t.options||{}),t}function n(t){return"top"===t||"bottom"===t}var s=t.plugins;t.types={},t.instances={},t.controllers={},o.extend(t.prototype,{construct:function(n,a){var i=this;a=e(a);var s=r.acquireContext(n,a),l=s&&s.canvas,u=l&&l.height,c=l&&l.width;i.id=o.uid(),i.ctx=s,i.canvas=l,i.config=a,i.width=c,i.height=u,i.aspectRatio=u?c/u:null,i.options=a.options,i._bufferedRender=!1,i.chart=i,i.controller=i,t.instances[i.id]=i,Object.defineProperty(i,"data",{get:function(){return i.config.data},set:function(t){i.config.data=t}}),s&&l?(i.initialize(),i.update()):console.error("Failed to create chart: can't acquire context from the given item")},initialize:function(){var t=this;return s.notify(t,"beforeInit"),o.retinaScale(t,t.options.devicePixelRatio),t.bindEvents(),t.options.responsive&&t.resize(!0),t.ensureScalesHaveIDs(),t.buildScales(),t.initToolTip(),s.notify(t,"afterInit"),t},clear:function(){return o.canvas.clear(this),this},stop:function(){return t.animationService.cancelAnimation(this),this},resize:function(t){var e=this,n=e.options,a=e.canvas,i=n.maintainAspectRatio&&e.aspectRatio||null,r=Math.max(0,Math.floor(o.getMaximumWidth(a))),l=Math.max(0,Math.floor(i?r/i:o.getMaximumHeight(a)));if((e.width!==r||e.height!==l)&&(a.width=e.width=r,a.height=e.height=l,a.style.width=r+"px",a.style.height=l+"px",o.retinaScale(e,n.devicePixelRatio),!t)){var u={width:r,height:l};s.notify(e,"resize",[u]),e.options.onResize&&e.options.onResize(e,u),e.stop(),e.update(e.options.responsiveAnimationDuration)}},ensureScalesHaveIDs:function(){var t=this.options,e=t.scales||{},n=t.scale;o.each(e.xAxes,(function(t,e){t.id=t.id||"x-axis-"+e})),o.each(e.yAxes,(function(t,e){t.id=t.id||"y-axis-"+e})),n&&(n.id=n.id||"scale")},buildScales:function(){var e=this,a=e.options,i=e.scales={},r=[];a.scales&&(r=r.concat((a.scales.xAxes||[]).map((function(t){return{options:t,dtype:"category",dposition:"bottom"}})),(a.scales.yAxes||[]).map((function(t){return{options:t,dtype:"linear",dposition:"left"}})))),a.scale&&r.push({options:a.scale,dtype:"radialLinear",isDefault:!0,dposition:"chartArea"}),o.each(r,(function(a){var r=a.options,s=o.valueOrDefault(r.type,a.dtype),l=t.scaleService.getScaleConstructor(s);if(l){n(r.position)!==n(a.dposition)&&(r.position=a.dposition);var u=new l({id:r.id,options:r,ctx:e.ctx,chart:e});i[u.id]=u,u.mergeTicksOptions(),a.isDefault&&(e.scale=u)}})),t.scaleService.addScalesToLayout(this)},buildOrUpdateControllers:function(){var e=this,n=[],a=[];return o.each(e.data.datasets,(function(o,i){var r=e.getDatasetMeta(i),s=o.type||e.config.type;if(r.type&&r.type!==s&&(e.destroyDatasetMeta(i),r=e.getDatasetMeta(i)),r.type=s,n.push(r.type),r.controller)r.controller.updateIndex(i);else{var l=t.controllers[r.type];if(void 0===l)throw new Error('"'+r.type+'" is not a chart type.');r.controller=new l(e,i),a.push(r.controller)}}),e),a},resetElements:function(){var t=this;o.each(t.data.datasets,(function(e,n){t.getDatasetMeta(n).controller.reset()}),t)},reset:function(){this.resetElements(),this.tooltip.initialize()},update:function(t){var e=this;if(t&&"object"==l(t)||(t={duration:t,lazy:arguments[1]}),function(t){var e=t.options;e.scale?t.scale.options=e.scale:e.scales&&e.scales.xAxes.concat(e.scales.yAxes).forEach((function(e){t.scales[e.id].options=e})),t.tooltip._options=e.tooltips}(e),!1!==s.notify(e,"beforeUpdate")){e.tooltip._data=e.data;var n=e.buildOrUpdateControllers();o.each(e.data.datasets,(function(t,n){e.getDatasetMeta(n).controller.buildOrUpdateElements()}),e),e.updateLayout(),o.each(n,(function(t){t.reset()})),e.updateDatasets(),s.notify(e,"afterUpdate"),e._bufferedRender?e._bufferedRequest={duration:t.duration,easing:t.easing,lazy:t.lazy}:e.render(t)}},updateLayout:function(){var e=this;!1!==s.notify(e,"beforeLayout")&&(t.layoutService.update(this,this.width,this.height),s.notify(e,"afterScaleUpdate"),s.notify(e,"afterLayout"))},updateDatasets:function(){var t=this;if(!1!==s.notify(t,"beforeDatasetsUpdate")){for(var e=0,n=t.data.datasets.length;e=0;--n)e.isDatasetVisible(n)&&e.drawDataset(n,t);s.notify(e,"afterDatasetsDraw",[t])}},drawDataset:function(t,e){var n=this,a=n.getDatasetMeta(t),o={meta:a,index:t,easingValue:e};!1!==s.notify(n,"beforeDatasetDraw",[o])&&(a.controller.draw(e),s.notify(n,"afterDatasetDraw",[o]))},getElementAtEvent:function(t){return i.modes.single(this,t)},getElementsAtEvent:function(t){return i.modes.label(this,t,{intersect:!0})},getElementsAtXAxis:function(t){return i.modes["x-axis"](this,t,{intersect:!0})},getElementsAtEventForMode:function(t,e,n){var a=i.modes[e];return"function"==typeof a?a(this,t,n):[]},getDatasetAtEvent:function(t){return i.modes.dataset(this,t,{intersect:!0})},getDatasetMeta:function(t){var e=this,n=e.data.datasets[t];n._meta||(n._meta={});var a=n._meta[e.id];return a||(a=n._meta[e.id]={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null}),a},getVisibleDatasetCount:function(){for(var t=0,e=0,n=this.data.datasets.length;e0||(o.forEach((function(e){delete t[e]})),delete t._chartjs)}}var o=["push","pop","shift","splice","unshift"];t.DatasetController=function(t,e){this.initialize(t,e)},a.extend(t.DatasetController.prototype,{datasetElementType:null,dataElementType:null,initialize:function(t,e){var n=this;n.chart=t,n.index=e,n.linkScales(),n.addElements()},updateIndex:function(t){this.index=t},linkScales:function(){var t=this,e=t.getMeta(),n=t.getDataset();null===e.xAxisID&&(e.xAxisID=n.xAxisID||t.chart.options.scales.xAxes[0].id),null===e.yAxisID&&(e.yAxisID=n.yAxisID||t.chart.options.scales.yAxes[0].id)},getDataset:function(){return this.chart.data.datasets[this.index]},getMeta:function(){return this.chart.getDatasetMeta(this.index)},getScaleForId:function(t){return this.chart.scales[t]},reset:function(){this.update(!0)},destroy:function(){this._data&&n(this._data,this)},createMetaDataset:function(){var t=this,e=t.datasetElementType;return e&&new e({_chart:t.chart,_datasetIndex:t.index})},createMetaData:function(t){var e=this,n=e.dataElementType;return n&&new n({_chart:e.chart,_datasetIndex:e.index,_index:t})},addElements:function(){var t,e,n=this,a=n.getMeta(),o=n.getDataset().data||[],i=a.data;for(t=0,e=o.length;ta&&t.insertElements(a,o-a)},insertElements:function(t,e){for(var n=0;n=n[e].length&&n[e].push({}),!n[e][r].type||l.type&&l.type!==n[e][r].type?i.merge(n[e][r],[t.scaleService.getScaleDefaults(s),l]):i.merge(n[e][r],l)}else i._merger(e,n,a,o)}})},i.where=function(t,e){if(i.isArray(t)&&Array.prototype.filter)return t.filter(e);var n=[];return i.each(t,(function(t){e(t)&&n.push(t)})),n},i.findIndex=Array.prototype.findIndex?function(t,e,n){return t.findIndex(e,n)}:function(t,e,n){n=void 0===n?t:n;for(var a=0,o=t.length;a=0;a--){var o=t[a];if(e(o))return o}},i.inherits=function(t){var e=this,n=t&&t.hasOwnProperty("constructor")?t.constructor:function(){return e.apply(this,arguments)},a=function(){this.constructor=n};return a.prototype=e.prototype,n.prototype=new a,n.extend=i.inherits,t&&i.extend(n.prototype,t),n.__super__=e.prototype,n},i.isNumber=function(t){return!isNaN(parseFloat(t))&&isFinite(t)},i.almostEquals=function(t,e,n){return Math.abs(t-e)t},i.max=function(t){return t.reduce((function(t,e){return isNaN(e)?t:Math.max(t,e)}),Number.NEGATIVE_INFINITY)},i.min=function(t){return t.reduce((function(t,e){return isNaN(e)?t:Math.min(t,e)}),Number.POSITIVE_INFINITY)},i.sign=Math.sign?function(t){return Math.sign(t)}:function(t){return 0==(t=+t)||isNaN(t)?t:t>0?1:-1},i.log10=Math.log10?function(t){return Math.log10(t)}:function(t){return Math.log(t)/Math.LN10},i.toRadians=function(t){return t*(Math.PI/180)},i.toDegrees=function(t){return t*(180/Math.PI)},i.getAngleFromPoint=function(t,e){var n=e.x-t.x,a=e.y-t.y,o=Math.sqrt(n*n+a*a),i=Math.atan2(a,n);return i<-.5*Math.PI&&(i+=2*Math.PI),{angle:i,distance:o}},i.distanceBetweenPoints=function(t,e){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))},i.aliasPixel=function(t){return t%2==0?0:.5},i.splineCurve=function(t,e,n,a){var o=t.skip?e:t,i=e,r=n.skip?e:n,s=Math.sqrt(Math.pow(i.x-o.x,2)+Math.pow(i.y-o.y,2)),l=Math.sqrt(Math.pow(r.x-i.x,2)+Math.pow(r.y-i.y,2)),u=s/(s+l),c=l/(s+l),d=a*(u=isNaN(u)?0:u),h=a*(c=isNaN(c)?0:c);return{previous:{x:i.x-d*(r.x-o.x),y:i.y-d*(r.y-o.y)},next:{x:i.x+h*(r.x-o.x),y:i.y+h*(r.y-o.y)}}},i.EPSILON=Number.EPSILON||1e-14,i.splineCurveMonotone=function(t){var e,n,a,o,r,s,l,u,c,d=(t||[]).map((function(t){return{model:t._model,deltaK:0,mK:0}})),h=d.length;for(e=0;e0?d[e-1]:null,(o=e0?d[e-1]:null,o=e=t.length-1?t[0]:t[e+1]:e>=t.length-1?t[t.length-1]:t[e+1]},i.previousItem=function(t,e,n){return n?e<=0?t[t.length-1]:t[e-1]:e<=0?t[0]:t[e-1]},i.niceNum=function(t,e){var n=Math.floor(i.log10(t)),a=t/Math.pow(10,n);return(e?a<1.5?1:a<3?2:a<7?5:10:a<=1?1:a<=2?2:a<=5?5:10)*Math.pow(10,n)},i.requestAnimFrame="undefined"==typeof window?function(t){t()}:window.requestAnimationFrame||window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.oRequestAnimationFrame||window.msRequestAnimationFrame||function(t){return window.setTimeout(t,1e3/60)},i.getRelativePosition=function(t,e){var n,a,o=t.originalEvent||t,r=t.currentTarget||t.srcElement,s=r.getBoundingClientRect(),l=o.touches;l&&l.length>0?(n=l[0].clientX,a=l[0].clientY):(n=o.clientX,a=o.clientY);var u=parseFloat(i.getStyle(r,"padding-left")),c=parseFloat(i.getStyle(r,"padding-top")),d=parseFloat(i.getStyle(r,"padding-right")),h=parseFloat(i.getStyle(r,"padding-bottom")),f=s.right-s.left-u-d,p=s.bottom-s.top-c-h;return{x:n=Math.round((n-s.left-u)/f*r.width/e.currentDevicePixelRatio),y:a=Math.round((a-s.top-c)/p*r.height/e.currentDevicePixelRatio)}},i.getConstraintWidth=function(t){return r(t,"max-width","clientWidth")},i.getConstraintHeight=function(t){return r(t,"max-height","clientHeight")},i.getMaximumWidth=function(t){var e=t.parentNode;if(!e)return t.clientWidth;var n=parseInt(i.getStyle(e,"padding-left"),10),a=parseInt(i.getStyle(e,"padding-right"),10),o=e.clientWidth-n-a,r=i.getConstraintWidth(t);return isNaN(r)?o:Math.min(o,r)},i.getMaximumHeight=function(t){var e=t.parentNode;if(!e)return t.clientHeight;var n=parseInt(i.getStyle(e,"padding-top"),10),a=parseInt(i.getStyle(e,"padding-bottom"),10),o=e.clientHeight-n-a,r=i.getConstraintHeight(t);return isNaN(r)?o:Math.min(o,r)},i.getStyle=function(t,e){return t.currentStyle?t.currentStyle[e]:document.defaultView.getComputedStyle(t,null).getPropertyValue(e)},i.retinaScale=function(t,e){var n=t.currentDevicePixelRatio=e||window.devicePixelRatio||1;if(1!==n){var a=t.canvas,o=t.height,i=t.width;a.height=o*n,a.width=i*n,t.ctx.scale(n,n),a.style.height=o+"px",a.style.width=i+"px"}},i.fontString=function(t,e,n){return e+" "+t+"px "+n},i.longestText=function(t,e,n,a){var o=(a=a||{}).data=a.data||{},r=a.garbageCollect=a.garbageCollect||[];a.font!==e&&(o=a.data={},r=a.garbageCollect=[],a.font=e),t.font=e;var s=0;i.each(n,(function(e){null!=e&&!0!==i.isArray(e)?s=i.measureText(t,o,r,s,e):i.isArray(e)&&i.each(e,(function(e){null==e||i.isArray(e)||(s=i.measureText(t,o,r,s,e))}))}));var l=r.length/2;if(l>n.length){for(var u=0;ua&&(a=i),a},i.numberOfLabelLines=function(t){var e=1;return i.each(t,(function(t){i.isArray(t)&&t.length>e&&(e=t.length)})),e},i.color=a?function(t){return t instanceof CanvasGradient&&(t=o.global.defaultColor),a(t)}:function(t){return console.error("Color.js not found!"),t},i.getHoverColor=function(t){return t instanceof CanvasPattern?t:i.color(t).saturate(.5).darken(.1).rgbString()}}},{25:25,3:3,45:45}],28:[function(t,e,n){"use strict";function a(t,e){return t.native?{x:t.x,y:t.y}:u.getRelativePosition(t,e)}function o(t,e){var n,a,o,i,r;for(a=0,i=t.data.datasets.length;a0&&(u=t.getDatasetMeta(u[0]._datasetIndex).data),u},"x-axis":function(t,e){return l(t,e,{intersect:!0})},point:function(t,e){return i(t,a(e,t))},nearest:function(t,e,n){var o=a(e,t);n.axis=n.axis||"xy";var i=s(n.axis),l=r(t,o,n.intersect,i);return l.length>1&&l.sort((function(t,e){var n=t.getArea()-e.getArea();return 0===n&&(n=t._datasetIndex-e._datasetIndex),n})),l.slice(0,1)},x:function(t,e,n){var i=a(e,t),r=[],s=!1;return o(t,(function(t){t.inXRange(i.x)&&r.push(t),t.inRange(i.x,i.y)&&(s=!0)})),n.intersect&&!s&&(r=[]),r},y:function(t,e,n){var i=a(e,t),r=[],s=!1;return o(t,(function(t){t.inYRange(i.y)&&r.push(t),t.inRange(i.x,i.y)&&(s=!0)})),n.intersect&&!s&&(r=[]),r}}}},{45:45}],29:[function(t,e,n){"use strict";t(25)._set("global",{responsive:!0,responsiveAnimationDuration:0,maintainAspectRatio:!0,events:["mousemove","mouseout","click","touchstart","touchmove"],hover:{onHover:null,mode:"nearest",intersect:!0,animationDuration:400},onClick:null,defaultColor:"rgba(0,0,0,0.1)",defaultFontColor:"#666",defaultFontFamily:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",defaultFontSize:12,defaultFontStyle:"normal",showLines:!0,elements:{},layout:{padding:{top:0,right:0,bottom:0,left:0}}}),e.exports=function(){var t=function(t,e){return this.construct(t,e),this};return t.Chart=t,t}},{25:25}],30:[function(t,e,n){"use strict";var a=t(45);e.exports=function(t){function e(t,e){return a.where(t,(function(t){return t.position===e}))}function n(t,e){t.forEach((function(t,e){return t._tmpIndex_=e,t})),t.sort((function(t,n){var a=e?n:t,o=e?t:n;return a.weight===o.weight?a._tmpIndex_-o._tmpIndex_:a.weight-o.weight})),t.forEach((function(t){delete t._tmpIndex_}))}t.layoutService={defaults:{},addBox:function(t,e){t.boxes||(t.boxes=[]),e.fullWidth=e.fullWidth||!1,e.position=e.position||"top",e.weight=e.weight||0,t.boxes.push(e)},removeBox:function(t,e){var n=t.boxes?t.boxes.indexOf(e):-1;-1!==n&&t.boxes.splice(n,1)},configure:function(t,e,n){for(var a,o=["fullWidth","position","weight"],i=o.length,r=0;rh&&lt.maxHeight){l--;break}l++,d=u*c}t.labelRotation=l},afterCalculateTickRotation:function(){s.callback(this.options.afterCalculateTickRotation,[this])},beforeFit:function(){s.callback(this.options.beforeFit,[this])},fit:function(){var t=this,o=t.minSize={width:0,height:0},i=a(t._ticks),r=t.options,u=r.ticks,c=r.scaleLabel,d=r.gridLines,h=r.display,f=t.isHorizontal(),p=n(u),g=r.gridLines.tickMarkLength;if(o.width=f?t.isFullWidth()?t.maxWidth-t.margins.left-t.margins.right:t.maxWidth:h&&d.drawTicks?g:0,o.height=f?h&&d.drawTicks?g:0:t.maxHeight,c.display&&h){var v=l(c)+s.options.toPadding(c.padding).height;f?o.height+=v:o.width+=v}if(u.display&&h){var m=s.longestText(t.ctx,p.font,i,t.longestTextCache),b=s.numberOfLabelLines(i),x=.5*p.size,y=t.options.ticks.padding;if(f){t.longestLabelWidth=m;var k=s.toRadians(t.labelRotation),w=Math.cos(k),C=Math.sin(k)*m+p.size*b+x*(b-1)+x;o.height=Math.min(t.maxHeight,o.height+C+y),t.ctx.font=p.font;var S=e(t.ctx,i[0],p.font),M=e(t.ctx,i[i.length-1],p.font);0!==t.labelRotation?(t.paddingLeft="bottom"===r.position?w*S+3:w*x+3,t.paddingRight="bottom"===r.position?w*x+3:w*M+3):(t.paddingLeft=S/2+3,t.paddingRight=M/2+3)}else u.mirror?m=0:m+=y+x,o.width=Math.min(t.maxWidth,o.width+m),t.paddingTop=p.size/2,t.paddingBottom=p.size/2}t.handleMargins(),t.width=o.width,t.height=o.height},handleMargins:function(){var t=this;t.margins&&(t.paddingLeft=Math.max(t.paddingLeft-t.margins.left,0),t.paddingTop=Math.max(t.paddingTop-t.margins.top,0),t.paddingRight=Math.max(t.paddingRight-t.margins.right,0),t.paddingBottom=Math.max(t.paddingBottom-t.margins.bottom,0))},afterFit:function(){s.callback(this.options.afterFit,[this])},isHorizontal:function(){return"top"===this.options.position||"bottom"===this.options.position},isFullWidth:function(){return this.options.fullWidth},getRightValue:function(t){if(s.isNullOrUndef(t))return NaN;if("number"==typeof t&&!isFinite(t))return NaN;if(t)if(this.isHorizontal()){if(void 0!==t.x)return this.getRightValue(t.x)}else if(void 0!==t.y)return this.getRightValue(t.y);return t},getLabelForIndex:s.noop,getPixelForValue:s.noop,getValueForPixel:s.noop,getPixelForTick:function(t){var e=this,n=e.options.offset;if(e.isHorizontal()){var a=(e.width-(e.paddingLeft+e.paddingRight))/Math.max(e._ticks.length-(n?0:1),1),o=a*t+e.paddingLeft;return n&&(o+=a/2),e.left+Math.round(o)+(e.isFullWidth()?e.margins.left:0)}var i=e.height-(e.paddingTop+e.paddingBottom);return e.top+t*(i/(e._ticks.length-1))},getPixelForDecimal:function(t){var e=this;if(e.isHorizontal()){var n=(e.width-(e.paddingLeft+e.paddingRight))*t+e.paddingLeft;return e.left+Math.round(n)+(e.isFullWidth()?e.margins.left:0)}return e.top+t*e.height},getBasePixel:function(){return this.getPixelForValue(this.getBaseValue())},getBaseValue:function(){var t=this,e=t.min,n=t.max;return t.beginAtZero?0:e<0&&n<0?n:e>0&&n>0?e:0},_autoSkip:function(t){var e,n,a,o,i=this,r=i.isHorizontal(),l=i.options.ticks.minor,u=t.length,c=s.toRadians(i.labelRotation),d=Math.cos(c),h=i.longestLabelWidth*d,f=[];for(l.maxTicksLimit&&(o=l.maxTicksLimit),r&&(e=!1,(h+l.autoSkipPadding)*u>i.width-(i.paddingLeft+i.paddingRight)&&(e=1+Math.floor((h+l.autoSkipPadding)*u/(i.width-(i.paddingLeft+i.paddingRight)))),o&&u>o&&(e=Math.max(e,Math.floor(u/o)))),n=0;n1&&n%e>0||n%e==0&&n+e>=u)&&n!==u-1||s.isNullOrUndef(a.label))&&delete a.label,f.push(a);return f},draw:function(t){var e=this,a=e.options;if(a.display){var r=e.ctx,u=i.global,c=a.ticks.minor,d=a.ticks.major||c,h=a.gridLines,f=a.scaleLabel,p=0!==e.labelRotation,g=e.isHorizontal(),v=c.autoSkip?e._autoSkip(e.getTicks()):e.getTicks(),m=s.valueOrDefault(c.fontColor,u.defaultFontColor),b=n(c),x=s.valueOrDefault(d.fontColor,u.defaultFontColor),y=n(d),k=h.drawTicks?h.tickMarkLength:0,w=s.valueOrDefault(f.fontColor,u.defaultFontColor),C=n(f),S=s.options.toPadding(f.padding),M=s.toRadians(e.labelRotation),_=[],I="right"===a.position?e.left:e.right-k,D="right"===a.position?e.left+k:e.right,P="bottom"===a.position?e.top:e.bottom-k,A="bottom"===a.position?e.top+k:e.bottom;if(s.each(v,(function(n,i){if(void 0!==n.label){var r,l,d,f,m=n.label;i===e.zeroLineIndex&&a.offset===h.offsetGridLines?(r=h.zeroLineWidth,l=h.zeroLineColor,d=h.zeroLineBorderDash,f=h.zeroLineBorderDashOffset):(r=s.valueAtIndexOrDefault(h.lineWidth,i),l=s.valueAtIndexOrDefault(h.color,i),d=s.valueOrDefault(h.borderDash,u.borderDash),f=s.valueOrDefault(h.borderDashOffset,u.borderDashOffset));var b,x,y,w,C,S,T,L,F,$,O="middle",z="middle",R=c.padding;if(g){var j=k+R;"bottom"===a.position?(z=p?"middle":"top",O=p?"right":"center",$=e.top+j):(z=p?"middle":"bottom",O=p?"left":"center",$=e.bottom-j);var B=o(e,i,h.offsetGridLines&&v.length>1);B1);E0)n=t.stepSize;else{var i=a.niceNum(e.max-e.min,!1);n=a.niceNum(i/(t.maxTicks-1),!0)}var r=Math.floor(e.min/n)*n,s=Math.ceil(e.max/n)*n;t.min&&t.max&&t.stepSize&&a.almostWhole((t.max-t.min)/t.stepSize,n/1e3)&&(r=t.min,s=t.max);var l=(s-r)/n;l=a.almostEquals(l,Math.round(l),n/1e3)?Math.round(l):Math.ceil(l),o.push(void 0!==t.min?t.min:r);for(var u=1;u3?n[2]-n[1]:n[1]-n[0];Math.abs(o)>1&&t!==Math.floor(t)&&(o=t-Math.floor(t));var i=a.log10(Math.abs(o)),r="";if(0!==t){var s=-1*Math.floor(i);s=Math.max(Math.min(s,20),0),r=t.toFixed(s)}else r="0";return r},logarithmic:function(t,e,n){var o=t/Math.pow(10,Math.floor(a.log10(t)));return 0===t?"0":1===o||2===o||5===o||0===e||e===n.length-1?t.toExponential():""}}}},{45:45}],35:[function(t,e,n){"use strict";var a=t(25),o=t(26),i=t(45);a._set("global",{tooltips:{enabled:!0,custom:null,mode:"nearest",position:"average",intersect:!0,backgroundColor:"rgba(0,0,0,0.8)",titleFontStyle:"bold",titleSpacing:2,titleMarginBottom:6,titleFontColor:"#fff",titleAlign:"left",bodySpacing:2,bodyFontColor:"#fff",bodyAlign:"left",footerFontStyle:"bold",footerSpacing:2,footerMarginTop:6,footerFontColor:"#fff",footerAlign:"left",yPadding:6,xPadding:6,caretPadding:2,caretSize:5,cornerRadius:6,multiKeyBackground:"#fff",displayColors:!0,borderColor:"rgba(0,0,0,0)",borderWidth:0,callbacks:{beforeTitle:i.noop,title:function(t,e){var n="",a=e.labels,o=a?a.length:0;if(t.length>0){var i=t[0];i.xLabel?n=i.xLabel:o>0&&i.indexa.height-e.height&&(r="bottom");var s,l,u,c,d,h=(o.left+o.right)/2,f=(o.top+o.bottom)/2;"center"===r?(s=function(t){return t<=h},l=function(t){return t>h}):(s=function(t){return t<=e.width/2},l=function(t){return t>=a.width-e.width/2}),u=function(t){return t+e.width>a.width},c=function(t){return t-e.width<0},d=function(t){return t<=f?"top":"bottom"},s(n.x)?(i="left",u(n.x)&&(i="center",r=d(n.y))):l(n.x)&&(i="right",c(n.x)&&(i="center",r=d(n.y)));var p=t._options;return{xAlign:p.xAlign?p.xAlign:i,yAlign:p.yAlign?p.yAlign:r}}(this,g))}else c.opacity=0;return c.xAlign=f.xAlign,c.yAlign=f.yAlign,c.x=p.x,c.y=p.y,c.width=g.width,c.height=g.height,c.caretX=v.x,c.caretY=v.y,o._model=c,e&&l.custom&&l.custom.call(o,c),o},drawCaret:function(t,e){var n=this._chart.ctx,a=this._view,o=this.getCaretPosition(t,e,a);n.lineTo(o.x1,o.y1),n.lineTo(o.x2,o.y2),n.lineTo(o.x3,o.y3)},getCaretPosition:function(t,e,n){var a,o,i,r,s,l,u=n.caretSize,c=n.cornerRadius,d=n.xAlign,h=n.yAlign,f=t.x,p=t.y,g=e.width,v=e.height;if("center"===h)s=p+v/2,"left"===d?(o=(a=f)-u,i=a,r=s+u,l=s-u):(o=(a=f+g)+u,i=a,r=s-u,l=s+u);else if("left"===d?(a=(o=f+c+u)-u,i=o+u):"right"===d?(a=(o=f+g-c-u)-u,i=o+u):(a=(o=f+g/2)-u,i=o+u),"top"===h)s=(r=p)-u,l=r;else{s=(r=p+v)+u,l=r;var m=i;i=a,a=m}return{x1:a,x2:o,x3:i,y1:r,y2:s,y3:l}},drawTitle:function(t,n,a,o){var r=n.title;if(r.length){a.textAlign=n._titleAlign,a.textBaseline="top";var s,l,u=n.titleFontSize,c=n.titleSpacing;for(a.fillStyle=e(n.titleFontColor,o),a.font=i.fontString(u,n._titleFontStyle,n._titleFontFamily),s=0,l=r.length;s0&&a.stroke()},draw:function(){var t=this._chart.ctx,e=this._view;if(0!==e.opacity){var n={width:e.width,height:e.height},a={x:e.x,y:e.y},o=Math.abs(e.opacity<.001)?0:e.opacity,i=e.title.length||e.beforeBody.length||e.body.length||e.afterBody.length||e.footer.length;this._options.enabled&&i&&(this.drawBackground(a,e,t,n,o),a.x+=e.xPadding,a.y+=e.yPadding,this.drawTitle(a,e,t,o),this.drawBody(a,e,t,o),this.drawFooter(a,e,t,o))}},handleEvent:function(t){var e=this,n=e._options,a=!1;if(e._lastActive=e._lastActive||[],"mouseout"===t.type?e._active=[]:e._active=e._chart.getElementsAtEventForMode(t,n.mode,n),!(a=!i.arrayEquals(e._active,e._lastActive)))return!1;if(e._lastActive=e._active,n.enabled||n.custom){e._eventPosition={x:t.x,y:t.y};var o=e._model;e.update(!0),e.pivot(),a|=o.x!==e._model.x||o.y!==e._model.y}return a}}),t.Tooltip.positioners={average:function(t){if(!t.length)return!1;var e,n,a=0,o=0,i=0;for(e=0,n=t.length;el;)o-=2*Math.PI;for(;o=s&&o<=l,c=r>=n.innerRadius&&r<=n.outerRadius;return u&&c}return!1},getCenterPoint:function(){var t=this._view,e=(t.startAngle+t.endAngle)/2,n=(t.innerRadius+t.outerRadius)/2;return{x:t.x+Math.cos(e)*n,y:t.y+Math.sin(e)*n}},getArea:function(){var t=this._view;return Math.PI*((t.endAngle-t.startAngle)/(2*Math.PI))*(Math.pow(t.outerRadius,2)-Math.pow(t.innerRadius,2))},tooltipPosition:function(){var t=this._view,e=t.startAngle+(t.endAngle-t.startAngle)/2,n=(t.outerRadius-t.innerRadius)/2+t.innerRadius;return{x:t.x+Math.cos(e)*n,y:t.y+Math.sin(e)*n}},draw:function(){var t=this._chart.ctx,e=this._view,n=e.startAngle,a=e.endAngle;t.beginPath(),t.arc(e.x,e.y,e.outerRadius,n,a),t.arc(e.x,e.y,e.innerRadius,a,n,!0),t.closePath(),t.strokeStyle=e.borderColor,t.lineWidth=e.borderWidth,t.fillStyle=e.backgroundColor,t.fill(),t.lineJoin="bevel",e.borderWidth&&t.stroke()}})},{25:25,26:26,45:45}],37:[function(t,e,n){"use strict";var a=t(25),o=t(26),i=t(45),r=a.global;a._set("global",{elements:{line:{tension:.4,backgroundColor:r.defaultColor,borderWidth:3,borderColor:r.defaultColor,borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",capBezierPoints:!0,fill:!0}}}),e.exports=o.extend({draw:function(){var t,e,n,a,o=this,s=o._view,l=o._chart.ctx,u=s.spanGaps,c=o._children.slice(),d=r.elements.line,h=-1;for(o._loop&&c.length&&c.push(c[0]),l.save(),l.lineCap=s.borderCapStyle||d.borderCapStyle,l.setLineDash&&l.setLineDash(s.borderDash||d.borderDash),l.lineDashOffset=s.borderDashOffset||d.borderDashOffset,l.lineJoin=s.borderJoinStyle||d.borderJoinStyle,l.lineWidth=s.borderWidth||d.borderWidth,l.strokeStyle=s.borderColor||r.defaultColor,l.beginPath(),h=-1,t=0;te?1:-1,r=1,s=u.borderSkipped||"left"):(e=u.x-u.width/2,n=u.x+u.width/2,a=u.y,i=1,r=(o=u.base)>a?1:-1,s=u.borderSkipped||"bottom"),c){var d=Math.min(Math.abs(e-n),Math.abs(a-o)),h=(c=c>d?d:c)/2,f=e+("left"!==s?h*i:0),p=n+("right"!==s?-h*i:0),g=a+("top"!==s?h*r:0),v=o+("bottom"!==s?-h*r:0);f!==p&&(a=g,o=v),g!==v&&(e=f,n=p)}l.beginPath(),l.fillStyle=u.backgroundColor,l.strokeStyle=u.borderColor,l.lineWidth=c;var m=[[e,o],[e,a],[n,a],[n,o]],b=["bottom","left","top","right"].indexOf(s,0);-1===b&&(b=0);var x=t(0);l.moveTo(x[0],x[1]);for(var y=1;y<4;y++)x=t(y),l.lineTo(x[0],x[1]);l.fill(),c&&l.stroke()},height:function(){var t=this._view;return t.base-t.y},inRange:function(t,e){var n=!1;if(this._view){var a=o(this);n=t>=a.left&&t<=a.right&&e>=a.top&&e<=a.bottom}return n},inLabelRange:function(t,e){var n=this;if(!n._view)return!1;var i=o(n);return a(n)?t>=i.left&&t<=i.right:e>=i.top&&e<=i.bottom},inXRange:function(t){var e=o(this);return t>=e.left&&t<=e.right},inYRange:function(t){var e=o(this);return t>=e.top&&t<=e.bottom},getCenterPoint:function(){var t,e,n=this._view;return a(this)?(t=n.x,e=(n.y+n.base)/2):(t=(n.x+n.base)/2,e=n.y),{x:t,y:e}},getArea:function(){var t=this._view;return t.width*Math.abs(t.y-t.base)},tooltipPosition:function(){var t=this._view;return{x:t.x,y:t.y}}})},{25:25,26:26}],40:[function(t,e,n){"use strict";e.exports={},e.exports.Arc=t(36),e.exports.Line=t(37),e.exports.Point=t(38),e.exports.Rectangle=t(39)},{36:36,37:37,38:38,39:39}],41:[function(t,e,n){"use strict";var a=t(42);n=e.exports={clear:function(t){t.ctx.clearRect(0,0,t.width,t.height)},roundedRect:function(t,e,n,a,o,i){if(i){var r=Math.min(i,a/2),s=Math.min(i,o/2);t.moveTo(e+r,n),t.lineTo(e+a-r,n),t.quadraticCurveTo(e+a,n,e+a,n+s),t.lineTo(e+a,n+o-s),t.quadraticCurveTo(e+a,n+o,e+a-r,n+o),t.lineTo(e+r,n+o),t.quadraticCurveTo(e,n+o,e,n+o-s),t.lineTo(e,n+s),t.quadraticCurveTo(e,n,e+r,n)}else t.rect(e,n,a,o)},drawPoint:function(t,e,n,a,o){var i,r,s,u,c,d;if("object"!=l(e)||"[object HTMLImageElement]"!==(i=e.toString())&&"[object HTMLCanvasElement]"!==i){if(!(isNaN(n)||n<=0)){switch(e){default:t.beginPath(),t.arc(a,o,n,0,2*Math.PI),t.closePath(),t.fill();break;case"triangle":t.beginPath(),c=(r=3*n/Math.sqrt(3))*Math.sqrt(3)/2,t.moveTo(a-r/2,o+c/3),t.lineTo(a+r/2,o+c/3),t.lineTo(a,o-2*c/3),t.closePath(),t.fill();break;case"rect":d=1/Math.SQRT2*n,t.beginPath(),t.fillRect(a-d,o-d,2*d,2*d),t.strokeRect(a-d,o-d,2*d,2*d);break;case"rectRounded":var h=n/Math.SQRT2,f=a-h,p=o-h,g=Math.SQRT2*n;t.beginPath(),this.roundedRect(t,f,p,g,g,n/2),t.closePath(),t.fill();break;case"rectRot":d=1/Math.SQRT2*n,t.beginPath(),t.moveTo(a-d,o),t.lineTo(a,o+d),t.lineTo(a+d,o),t.lineTo(a,o-d),t.closePath(),t.fill();break;case"cross":t.beginPath(),t.moveTo(a,o+n),t.lineTo(a,o-n),t.moveTo(a-n,o),t.lineTo(a+n,o),t.closePath();break;case"crossRot":t.beginPath(),s=Math.cos(Math.PI/4)*n,u=Math.sin(Math.PI/4)*n,t.moveTo(a-s,o-u),t.lineTo(a+s,o+u),t.moveTo(a-s,o+u),t.lineTo(a+s,o-u),t.closePath();break;case"star":t.beginPath(),t.moveTo(a,o+n),t.lineTo(a,o-n),t.moveTo(a-n,o),t.lineTo(a+n,o),s=Math.cos(Math.PI/4)*n,u=Math.sin(Math.PI/4)*n,t.moveTo(a-s,o-u),t.lineTo(a+s,o+u),t.moveTo(a-s,o+u),t.lineTo(a+s,o-u),t.closePath();break;case"line":t.beginPath(),t.moveTo(a-n,o),t.lineTo(a+n,o),t.closePath();break;case"dash":t.beginPath(),t.moveTo(a,o),t.lineTo(a+n,o),t.closePath()}t.stroke()}}else t.drawImage(e,a-e.width/2,o-e.height/2,e.width,e.height)},clipArea:function(t,e){t.save(),t.beginPath(),t.rect(e.left,e.top,e.right-e.left,e.bottom-e.top),t.clip()},unclipArea:function(t){t.restore()},lineTo:function(t,e,n,a){if(n.steppedLine)return"after"===n.steppedLine&&!a||"after"!==n.steppedLine&&a?t.lineTo(e.x,n.y):t.lineTo(n.x,e.y),void t.lineTo(n.x,n.y);n.tension?t.bezierCurveTo(a?e.controlPointPreviousX:e.controlPointNextX,a?e.controlPointPreviousY:e.controlPointNextY,a?n.controlPointNextX:n.controlPointPreviousX,a?n.controlPointNextY:n.controlPointPreviousY,n.x,n.y):t.lineTo(n.x,n.y)}},a.clear=n.clear,a.drawRoundedRectangle=function(t){t.beginPath(),n.roundedRect.apply(n,arguments),t.closePath()}},{42:42}],42:[function(t,e,n){"use strict";var a={noop:function(){},uid:function(){var t=0;return function(){return t++}}(),isNullOrUndef:function(t){return null==t},isArray:Array.isArray?Array.isArray:function(t){return"[object Array]"===Object.prototype.toString.call(t)},isObject:function(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)},valueOrDefault:function(t,e){return void 0===t?e:t},valueAtIndexOrDefault:function(t,e,n){return a.valueOrDefault(a.isArray(t)?t[e]:t,n)},callback:function(t,e,n){if(t&&"function"==typeof t.call)return t.apply(n,e)},each:function(t,e,n,o){var i,r,s;if(a.isArray(t))if(r=t.length,o)for(i=r-1;i>=0;i--)e.call(n,t[i],i);else for(i=0;i=1?t:-(Math.sqrt(1-t*t)-1)},easeOutCirc:function(t){return Math.sqrt(1-(t-=1)*t)},easeInOutCirc:function(t){return(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1)},easeInElastic:function(t){var e=1.70158,n=0,a=1;return 0===t?0:1===t?1:(n||(n=.3),a<1?(a=1,e=n/4):e=n/(2*Math.PI)*Math.asin(1/a),-a*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/n))},easeOutElastic:function(t){var e=1.70158,n=0,a=1;return 0===t?0:1===t?1:(n||(n=.3),a<1?(a=1,e=n/4):e=n/(2*Math.PI)*Math.asin(1/a),a*Math.pow(2,-10*t)*Math.sin((t-e)*(2*Math.PI)/n)+1)},easeInOutElastic:function(t){var e=1.70158,n=0,a=1;return 0===t?0:2==(t/=.5)?1:(n||(n=.45),a<1?(a=1,e=n/4):e=n/(2*Math.PI)*Math.asin(1/a),t<1?a*Math.pow(2,10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/n)*-.5:a*Math.pow(2,-10*(t-=1))*Math.sin((t-e)*(2*Math.PI)/n)*.5+1)},easeInBack:function(t){var e=1.70158;return t*t*((e+1)*t-e)},easeOutBack:function(t){var e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack:function(t){var e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:function(t){return 1-o.easeOutBounce(1-t)},easeOutBounce:function(t){return t<1/2.75?7.5625*t*t:t<2/2.75?7.5625*(t-=1.5/2.75)*t+.75:t<2.5/2.75?7.5625*(t-=2.25/2.75)*t+.9375:7.5625*(t-=2.625/2.75)*t+.984375},easeInOutBounce:function(t){return t<.5?.5*o.easeInBounce(2*t):.5*o.easeOutBounce(2*t-1)+.5}};e.exports={effects:o},a.easingEffects=o},{42:42}],44:[function(t,e,n){"use strict";var a=t(42);e.exports={toLineHeight:function(t,e){var n=(""+t).match(/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/);if(!n||"normal"===n[1])return 1.2*e;switch(t=+n[2],n[3]){case"px":return t;case"%":t/=100}return e*t},toPadding:function(t){var e,n,o,i;return a.isObject(t)?(e=+t.top||0,n=+t.right||0,o=+t.bottom||0,i=+t.left||0):e=n=o=i=+t||0,{top:e,right:n,bottom:o,left:i,height:e+o,width:i+n}},resolve:function(t,e,n){var o,i,r;for(o=0,i=t.length;o
    ';var i=e.childNodes[0],r=e.childNodes[1];e._reset=function(){i.scrollLeft=1e6,i.scrollTop=1e6,r.scrollLeft=1e6,r.scrollTop=1e6};var s=function(){e._reset(),t()};return o(i,"scroll",s.bind(i,"expand")),o(r,"scroll",s.bind(r,"shrink")),e}(function(t,e){var n=!1,a=[];return function(){a=Array.prototype.slice.call(arguments),e=e||this,n||(n=!0,u.requestAnimFrame.call(window,(function(){n=!1,t.apply(e,a)})))}}((function(){if(a.resizer)return e(r("resize",n))})));!function(t,e){var n=(t[c]||(t[c]={})).renderProxy=function(t){t.animationName===f&&e()};u.each(p,(function(e){o(t,e,n)})),t.classList.add(h)}(t,(function(){if(a.resizer){var e=t.parentNode;e&&e!==i.parentNode&&e.insertBefore(i,e.firstChild),i._reset()}}))}function l(t){var e=t[c]||{},n=e.resizer;delete e.resizer,function(t){var e=t[c]||{},n=e.renderProxy;n&&(u.each(p,(function(e){i(t,e,n)})),delete e.renderProxy),t.classList.remove(h)}(t),n&&n.parentNode&&n.parentNode.removeChild(n)}var u=t(45),c="$chartjs",d="chartjs-",h=d+"render-monitor",f=d+"render-animation",p=["animationstart","webkitAnimationStart"],g={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},v=!!function(){var t=!1;try{var e=Object.defineProperty({},"passive",{get:function(){t=!0}});window.addEventListener("e",null,e)}catch(t){}return t}()&&{passive:!0};e.exports={_enabled:"undefined"!=typeof window&&"undefined"!=typeof document,initialize:function(){var t="from{opacity:0.99}to{opacity:1}";!function(t,e){var n=t._style||document.createElement("style");t._style||(t._style=n,e="/* Chart.js */\n"+e,n.setAttribute("type","text/css"),document.getElementsByTagName("head")[0].appendChild(n)),n.appendChild(document.createTextNode(e))}(this,"@-webkit-keyframes "+f+"{"+t+"}@keyframes "+f+"{"+t+"}."+h+"{-webkit-animation:"+f+" 0.001s;animation:"+f+" 0.001s;}")},acquireContext:function(t,e){"string"==typeof t?t=document.getElementById(t):t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas);var n=t&&t.getContext&&t.getContext("2d");return n&&n.canvas===t?(function(t,e){var n=t.style,o=t.getAttribute("height"),i=t.getAttribute("width");if(t[c]={initial:{height:o,width:i,style:{display:n.display,height:n.height,width:n.width}}},n.display=n.display||"block",null===i||""===i){var r=a(t,"width");void 0!==r&&(t.width=r)}if(null===o||""===o)if(""===t.style.height)t.height=t.width/(e.options.aspectRatio||2);else{var s=a(t,"height");void 0!==r&&(t.height=s)}}(t,e),n):null},releaseContext:function(t){var e=t.canvas;if(e[c]){var n=e[c].initial;["height","width"].forEach((function(t){var a=n[t];u.isNullOrUndef(a)?e.removeAttribute(t):e.setAttribute(t,a)})),u.each(n.style||{},(function(t,n){e.style[n]=t})),e.width=e.width,delete e[c]}},addEventListener:function(t,e,n){var a=t.canvas;if("resize"!==e){var i=n[c]||(n[c]={});o(a,e,(i.proxies||(i.proxies={}))[t.id+"_"+e]=function(e){n(function(t,e){var n=g[t.type]||t.type,a=u.getRelativePosition(t,e);return r(n,e,a.x,a.y,t)}(e,t))})}else s(a,n,t)},removeEventListener:function(t,e,n){var a=t.canvas;if("resize"!==e){var o=((n[c]||{}).proxies||{})[t.id+"_"+e];o&&i(a,e,o)}else l(a)}},u.addEvent=o,u.removeEvent=i},{45:45}],48:[function(t,e,n){"use strict";var a=t(45),o=t(46),i=t(47),r=i._enabled?i:o;e.exports=a.extend({initialize:function(){},acquireContext:function(){},releaseContext:function(){},addEventListener:function(){},removeEventListener:function(){}},r)},{45:45,46:46,47:47}],49:[function(t,e,n){"use strict";var a=t(25),o=t(40),i=t(45);a._set("global",{plugins:{filler:{propagate:!0}}}),e.exports=function(){function t(t,e,n){var a,o=t._model||{},i=o.fill;if(void 0===i&&(i=!!o.backgroundColor),!1===i||null===i)return!1;if(!0===i)return"origin";if(a=parseFloat(i,10),isFinite(a)&&Math.floor(a)===a)return"-"!==i[0]&&"+"!==i[0]||(a=e+a),!(a===e||a<0||a>=n)&&a;switch(i){case"bottom":return"start";case"top":return"end";case"zero":return"origin";case"origin":case"start":case"end":return i;default:return!1}}function e(t){var e,n=t.el._model||{},a=t.el._scale||{},o=t.fill,i=null;if(isFinite(o))return null;if("start"===o?i=void 0===n.scaleBottom?a.bottom:n.scaleBottom:"end"===o?i=void 0===n.scaleTop?a.top:n.scaleTop:void 0!==n.scaleZero?i=n.scaleZero:a.getBasePosition?i=a.getBasePosition():a.getBasePixel&&(i=a.getBasePixel()),null!=i){if(void 0!==i.x&&void 0!==i.y)return i;if("number"==typeof i&&isFinite(i))return{x:(e=a.isHorizontal())?i:null,y:e?null:i}}return null}function n(t,e,n){var a,o=t[e].fill,i=[e];if(!n)return o;for(;!1!==o&&-1===i.indexOf(o);){if(!isFinite(o))return o;if(!(a=t[o]))return!1;if(a.visible)return o;i.push(o),o=a.fill}return!1}function r(t){var e=t.fill,n="dataset";return!1===e?null:(isFinite(e)||(n="boundary"),c[n](t))}function s(t){return t&&!t.skip}function l(t,e,n,a,o){var r;if(a&&o){for(t.moveTo(e[0].x,e[0].y),r=1;r0;--r)i.canvas.lineTo(t,n[r],n[r-1],!0)}}function u(t,e,n,a,o,i){var r,u,c,d,h,f,p,g=e.length,v=a.spanGaps,m=[],b=[],x=0,y=0;for(t.beginPath(),r=0,u=g+!!i;r');for(var n=0;n'),t.data.datasets[n].label&&e.push(t.data.datasets[n].label),e.push("");return e.push(""),e.join("")}}),e.exports=function(t){function e(t,e){return t.usePointStyle?e*Math.SQRT2:t.boxWidth}function n(e,n){var a=new t.Legend({ctx:e.ctx,options:n,chart:e});r.configure(e,a,n),r.addBox(e,a),e.legend=a}var r=t.layoutService,s=i.noop;return t.Legend=o.extend({initialize:function(t){i.extend(this,t),this.legendHitBoxes=[],this.doughnutMode=!1},beforeUpdate:s,update:function(t,e,n){var a=this;return a.beforeUpdate(),a.maxWidth=t,a.maxHeight=e,a.margins=n,a.beforeSetDimensions(),a.setDimensions(),a.afterSetDimensions(),a.beforeBuildLabels(),a.buildLabels(),a.afterBuildLabels(),a.beforeFit(),a.fit(),a.afterFit(),a.afterUpdate(),a.minSize},afterUpdate:s,beforeSetDimensions:s,setDimensions:function(){var t=this;t.isHorizontal()?(t.width=t.maxWidth,t.left=0,t.right=t.width):(t.height=t.maxHeight,t.top=0,t.bottom=t.height),t.paddingLeft=0,t.paddingTop=0,t.paddingRight=0,t.paddingBottom=0,t.minSize={width:0,height:0}},afterSetDimensions:s,beforeBuildLabels:s,buildLabels:function(){var t=this,e=t.options.labels||{},n=i.callback(e.generateLabels,[t.chart],t)||[];e.filter&&(n=n.filter((function(n){return e.filter(n,t.chart.data)}))),t.options.reverse&&n.reverse(),t.legendItems=n},afterBuildLabels:s,beforeFit:s,fit:function(){var t=this,n=t.options,o=n.labels,r=n.display,s=t.ctx,l=a.global,u=i.valueOrDefault,c=u(o.fontSize,l.defaultFontSize),d=u(o.fontStyle,l.defaultFontStyle),h=u(o.fontFamily,l.defaultFontFamily),f=i.fontString(c,d,h),p=t.legendHitBoxes=[],g=t.minSize,v=t.isHorizontal();if(v?(g.width=t.maxWidth,g.height=r?10:0):(g.width=r?10:0,g.height=t.maxHeight),r)if(s.font=f,v){var m=t.lineWidths=[0],b=t.legendItems.length?c+o.padding:0;s.textAlign="left",s.textBaseline="top",i.each(t.legendItems,(function(n,a){var i=e(o,c)+c/2+s.measureText(n.text).width;m[m.length-1]+i+o.padding>=t.width&&(b+=c+o.padding,m[m.length]=t.left),p[a]={left:0,top:0,width:i,height:c},m[m.length-1]+=i+o.padding})),g.height+=b}else{var x=o.padding,y=t.columnWidths=[],k=o.padding,w=0,C=0,S=c+x;i.each(t.legendItems,(function(t,n){var a=e(o,c)+c/2+s.measureText(t.text).width;C+S>g.height&&(k+=w+o.padding,y.push(w),w=0,C=0),w=Math.max(w,a),C+=S,p[n]={left:0,top:0,width:a,height:c}})),k+=w,y.push(w),g.width+=k}t.width=g.width,t.height=g.height},afterFit:s,isHorizontal:function(){return"top"===this.options.position||"bottom"===this.options.position},draw:function(){var t=this,n=t.options,o=n.labels,r=a.global,s=r.elements.line,l=t.width,u=t.lineWidths;if(n.display){var c,d=t.ctx,h=i.valueOrDefault,f=h(o.fontColor,r.defaultFontColor),p=h(o.fontSize,r.defaultFontSize),g=h(o.fontStyle,r.defaultFontStyle),v=h(o.fontFamily,r.defaultFontFamily),m=i.fontString(p,g,v);d.textAlign="left",d.textBaseline="middle",d.lineWidth=.5,d.strokeStyle=f,d.fillStyle=f,d.font=m;var b=e(o,p),x=t.legendHitBoxes,y=function(t,e,a){if(!(isNaN(b)||b<=0)){d.save(),d.fillStyle=h(a.fillStyle,r.defaultColor),d.lineCap=h(a.lineCap,s.borderCapStyle),d.lineDashOffset=h(a.lineDashOffset,s.borderDashOffset),d.lineJoin=h(a.lineJoin,s.borderJoinStyle),d.lineWidth=h(a.lineWidth,s.borderWidth),d.strokeStyle=h(a.strokeStyle,r.defaultColor);var o=0===h(a.lineWidth,s.borderWidth);if(d.setLineDash&&d.setLineDash(h(a.lineDash,s.borderDash)),n.labels&&n.labels.usePointStyle){var l=p*Math.SQRT2/2,u=l/Math.SQRT2,c=t+u,f=e+u;i.canvas.drawPoint(d,a.pointStyle,l,c,f)}else o||d.strokeRect(t,e,b,p),d.fillRect(t,e,b,p);d.restore()}},k=t.isHorizontal();c=k?{x:t.left+(l-u[0])/2,y:t.top+o.padding,line:0}:{x:t.left+o.padding,y:t.top+o.padding,line:0};var w=p+o.padding;i.each(t.legendItems,(function(e,n){var a=d.measureText(e.text).width,i=b+p/2+a,r=c.x,s=c.y;k?r+i>=l&&(s=c.y+=w,c.line++,r=c.x=t.left+(l-u[c.line])/2):s+w>t.bottom&&(r=c.x=r+t.columnWidths[c.line]+o.padding,s=c.y=t.top+o.padding,c.line++),y(r,s,e),x[n].left=r,x[n].top=s,function(t,e,n,a){var o=p/2,i=b+o+t,r=e+o;d.fillText(n.text,i,r),n.hidden&&(d.beginPath(),d.lineWidth=2,d.moveTo(i,r),d.lineTo(i+a,r),d.stroke())}(r,s,e,a),k?c.x+=i+o.padding:c.y+=w}))}},handleEvent:function(t){var e=this,n=e.options,a="mouseup"===t.type?"click":t.type,o=!1;if("mousemove"===a){if(!n.onHover)return}else{if("click"!==a)return;if(!n.onClick)return}var i=t.x,r=t.y;if(i>=e.left&&i<=e.right&&r>=e.top&&r<=e.bottom)for(var s=e.legendHitBoxes,l=0;l=u.left&&i<=u.left+u.width&&r>=u.top&&r<=u.top+u.height){if("click"===a){n.onClick.call(e,t.native,e.legendItems[l]),o=!0;break}if("mousemove"===a){n.onHover.call(e,t.native,e.legendItems[l]),o=!0;break}}}return o}}),{id:"legend",beforeInit:function(t){var e=t.options.legend;e&&n(t,e)},beforeUpdate:function(t){var e=t.options.legend,o=t.legend;e?(i.mergeIf(e,a.global.legend),o?(r.configure(t,o,e),o.options=e):n(t,e)):o&&(r.removeBox(t,o),delete t.legend)},afterEvent:function(t,e){var n=t.legend;n&&n.handleEvent(e)}}}},{25:25,26:26,45:45}],51:[function(t,e,n){"use strict";var a=t(25),o=t(26),i=t(45);a._set("global",{title:{display:!1,fontStyle:"bold",fullWidth:!0,lineHeight:1.2,padding:10,position:"top",text:"",weight:2e3}}),e.exports=function(t){function e(e,a){var o=new t.Title({ctx:e.ctx,options:a,chart:e});n.configure(e,o,a),n.addBox(e,o),e.titleBlock=o}var n=t.layoutService,r=i.noop;return t.Title=o.extend({initialize:function(t){i.extend(this,t),this.legendHitBoxes=[]},beforeUpdate:r,update:function(t,e,n){var a=this;return a.beforeUpdate(),a.maxWidth=t,a.maxHeight=e,a.margins=n,a.beforeSetDimensions(),a.setDimensions(),a.afterSetDimensions(),a.beforeBuildLabels(),a.buildLabels(),a.afterBuildLabels(),a.beforeFit(),a.fit(),a.afterFit(),a.afterUpdate(),a.minSize},afterUpdate:r,beforeSetDimensions:r,setDimensions:function(){var t=this;t.isHorizontal()?(t.width=t.maxWidth,t.left=0,t.right=t.width):(t.height=t.maxHeight,t.top=0,t.bottom=t.height),t.paddingLeft=0,t.paddingTop=0,t.paddingRight=0,t.paddingBottom=0,t.minSize={width:0,height:0}},afterSetDimensions:r,beforeBuildLabels:r,buildLabels:r,afterBuildLabels:r,beforeFit:r,fit:function(){var t=this,e=i.valueOrDefault,n=t.options,o=n.display,r=e(n.fontSize,a.global.defaultFontSize),s=t.minSize,l=i.isArray(n.text)?n.text.length:1,u=i.options.toLineHeight(n.lineHeight,r),c=o?l*u+2*n.padding:0;t.isHorizontal()?(s.width=t.maxWidth,s.height=c):(s.width=c,s.height=t.maxHeight),t.width=s.width,t.height=s.height},afterFit:r,isHorizontal:function(){var t=this.options.position;return"top"===t||"bottom"===t},draw:function(){var t=this,e=t.ctx,n=i.valueOrDefault,o=t.options,r=a.global;if(o.display){var s,l,u,c=n(o.fontSize,r.defaultFontSize),d=n(o.fontStyle,r.defaultFontStyle),h=n(o.fontFamily,r.defaultFontFamily),f=i.fontString(c,d,h),p=i.options.toLineHeight(o.lineHeight,c),g=p/2+o.padding,v=0,m=t.top,b=t.left,x=t.bottom,y=t.right;e.fillStyle=n(o.fontColor,r.defaultFontColor),e.font=f,t.isHorizontal()?(l=b+(y-b)/2,u=m+g,s=y-b):(l="left"===o.position?b+g:y-g,u=m+(x-m)/2,s=x-m,v=Math.PI*("left"===o.position?-.5:.5)),e.save(),e.translate(l,u),e.rotate(v),e.textAlign="center",e.textBaseline="middle";var k=o.text;if(i.isArray(k))for(var w=0,C=0;Ce.max)&&(e.max=a))}))}));e.min=isFinite(e.min)&&!isNaN(e.min)?e.min:0,e.max=isFinite(e.max)&&!isNaN(e.max)?e.max:1,this.handleTickRangeOptions()},getTickLimit:function(){var t,e=this,n=e.options.ticks;if(e.isHorizontal())t=Math.min(n.maxTicksLimit?n.maxTicksLimit:11,Math.ceil(e.width/50));else{var i=o.valueOrDefault(n.fontSize,a.global.defaultFontSize);t=Math.min(n.maxTicksLimit?n.maxTicksLimit:11,Math.ceil(e.height/(2*i)))}return t},handleDirectionalChanges:function(){this.isHorizontal()||this.ticks.reverse()},getLabelForIndex:function(t,e){return+this.getRightValue(this.chart.data.datasets[e].data[t])},getPixelForValue:function(t){var e,n=this,a=n.start,o=+n.getRightValue(t),i=n.end-a;return n.isHorizontal()?(e=n.left+n.width/i*(o-a),Math.round(e)):(e=n.bottom-n.height/i*(o-a),Math.round(e))},getValueForPixel:function(t){var e=this,n=e.isHorizontal(),a=n?e.width:e.height,o=(n?t-e.left:e.bottom-t)/a;return e.start+(e.end-e.start)*o},getPixelForTick:function(t){return this.getPixelForValue(this.ticksAsNumbers[t])}});t.scaleService.registerScaleType("linear",n,e)}},{25:25,34:34,45:45}],54:[function(t,e,n){"use strict";var a=t(45),o=t(34);e.exports=function(t){var e=a.noop;t.LinearScaleBase=t.Scale.extend({getRightValue:function(e){return"string"==typeof e?+e:t.Scale.prototype.getRightValue.call(this,e)},handleTickRangeOptions:function(){var t=this,e=t.options.ticks;if(e.beginAtZero){var n=a.sign(t.min),o=a.sign(t.max);n<0&&o<0?t.max=0:n>0&&o>0&&(t.min=0)}var i=void 0!==e.min||void 0!==e.suggestedMin,r=void 0!==e.max||void 0!==e.suggestedMax;void 0!==e.min?t.min=e.min:void 0!==e.suggestedMin&&(null===t.min?t.min=e.suggestedMin:t.min=Math.min(t.min,e.suggestedMin)),void 0!==e.max?t.max=e.max:void 0!==e.suggestedMax&&(null===t.max?t.max=e.suggestedMax:t.max=Math.max(t.max,e.suggestedMax)),i!==r&&t.min>=t.max&&(i?t.max=t.min+1:t.min=t.max-1),t.min===t.max&&(t.max++,e.beginAtZero||t.min--)},getTickLimit:e,handleDirectionalChanges:e,buildTicks:function(){var t=this,e=t.options.ticks,n=t.getTickLimit(),i={maxTicks:n=Math.max(2,n),min:e.min,max:e.max,stepSize:a.valueOrDefault(e.fixedStepSize,e.stepSize)},r=t.ticks=o.generators.linear(i,t);t.handleDirectionalChanges(),t.max=a.max(r),t.min=a.min(r),e.reverse?(r.reverse(),t.start=t.max,t.end=t.min):(t.start=t.min,t.end=t.max)},convertTicksToLabels:function(){var e=this;e.ticksAsNumbers=e.ticks.slice(),e.zeroLineIndex=e.ticks.indexOf(0),t.Scale.prototype.convertTicksToLabels.call(e)}})}},{34:34,45:45}],55:[function(t,e,n){"use strict";var a=t(45),o=t(34);e.exports=function(t){var e={position:"left",ticks:{callback:o.formatters.logarithmic}},n=t.Scale.extend({determineDataLimits:function(){function t(t){return l?t.xAxisID===e.id:t.yAxisID===e.id}var e=this,n=e.options,o=n.ticks,i=e.chart,r=i.data.datasets,s=a.valueOrDefault,l=e.isHorizontal();e.min=null,e.max=null,e.minNotZero=null;var u=n.stacked;if(void 0===u&&a.each(r,(function(e,n){if(!u){var a=i.getDatasetMeta(n);i.isDatasetVisible(n)&&t(a)&&void 0!==a.stack&&(u=!0)}})),n.stacked||u){var c={};a.each(r,(function(o,r){var s=i.getDatasetMeta(r),l=[s.type,void 0===n.stacked&&void 0===s.stack?r:"",s.stack].join(".");i.isDatasetVisible(r)&&t(s)&&(void 0===c[l]&&(c[l]=[]),a.each(o.data,(function(t,a){var o=c[l],i=+e.getRightValue(t);isNaN(i)||s.data[a].hidden||(o[a]=o[a]||0,n.relativePoints?o[a]=100:o[a]+=i)})))})),a.each(c,(function(t){var n=a.min(t),o=a.max(t);e.min=null===e.min?n:Math.min(e.min,n),e.max=null===e.max?o:Math.max(e.max,o)}))}else a.each(r,(function(n,o){var r=i.getDatasetMeta(o);i.isDatasetVisible(o)&&t(r)&&a.each(n.data,(function(t,n){var a=+e.getRightValue(t);isNaN(a)||r.data[n].hidden||((null===e.min||ae.max)&&(e.max=a),0!==a&&(null===e.minNotZero||ao?{start:e-n-5,end:e}:{start:e,end:e+n+5}}function l(t){return 0===t||180===t?"center":t<180?"left":"right"}function u(t,e,n,a){if(o.isArray(e))for(var i=n.y,r=1.5*a,s=0;s270||t<90)&&(n.y-=e.h)}function d(t){var a=t.ctx,i=o.valueOrDefault,r=t.options,s=r.angleLines,d=r.pointLabels;a.lineWidth=s.lineWidth,a.strokeStyle=s.color;var h=t.getDistanceFromCenterForValue(r.ticks.reverse?t.min:t.max),f=n(t);a.textBaseline="top";for(var g=e(t)-1;g>=0;g--){if(s.display){var v=t.getPointPosition(g,h);a.beginPath(),a.moveTo(t.xCenter,t.yCenter),a.lineTo(v.x,v.y),a.stroke(),a.closePath()}if(d.display){var m=t.getPointPosition(g,h+5),b=i(d.fontColor,p.defaultFontColor);a.font=f.font,a.fillStyle=b;var x=t.getIndexAngle(g),y=o.toDegrees(x);a.textAlign=l(y),c(y,t._pointLabelSizes[g],m),u(a,t.pointLabels[g]||"",m,f.size)}}}function h(t,n,a,i){var r=t.ctx;if(r.strokeStyle=o.valueAtIndexOrDefault(n.color,i-1),r.lineWidth=o.valueAtIndexOrDefault(n.lineWidth,i-1),t.options.gridLines.circular)r.beginPath(),r.arc(t.xCenter,t.yCenter,a,0,2*Math.PI),r.closePath(),r.stroke();else{var s=e(t);if(0===s)return;r.beginPath();var l=t.getPointPosition(0,a);r.moveTo(l.x,l.y);for(var u=1;ud.r&&(d.r=v.end,h.r=p),m.startd.b&&(d.b=m.end,h.b=p)}t.setReductions(c,d,h)}(this):function(t){var e=Math.min(t.height/2,t.width/2);t.drawingArea=Math.round(e),t.setCenterPoint(0,0,0,0)}(this)},setReductions:function(t,e,n){var a=this,o=e.l/Math.sin(n.l),i=Math.max(e.r-a.width,0)/Math.sin(n.r),r=-e.t/Math.cos(n.t),s=-Math.max(e.b-a.height,0)/Math.cos(n.b);o=f(o),i=f(i),r=f(r),s=f(s),a.drawingArea=Math.min(Math.round(t-(o+i)/2),Math.round(t-(r+s)/2)),a.setCenterPoint(o,i,r,s)},setCenterPoint:function(t,e,n,a){var o=this,i=o.width-e-o.drawingArea,r=t+o.drawingArea,s=n+o.drawingArea,l=o.height-a-o.drawingArea;o.xCenter=Math.round((r+i)/2+o.left),o.yCenter=Math.round((s+l)/2+o.top)},getIndexAngle:function(t){return t*(2*Math.PI/e(this))+(this.chart.options&&this.chart.options.startAngle?this.chart.options.startAngle:0)*Math.PI*2/360},getDistanceFromCenterForValue:function(t){var e=this;if(null===t)return 0;var n=e.drawingArea/(e.max-e.min);return e.options.ticks.reverse?(e.max-t)*n:(t-e.min)*n},getPointPosition:function(t,e){var n=this,a=n.getIndexAngle(t)-Math.PI/2;return{x:Math.round(Math.cos(a)*e)+n.xCenter,y:Math.round(Math.sin(a)*e)+n.yCenter}},getPointPositionForValue:function(t,e){return this.getPointPosition(t,this.getDistanceFromCenterForValue(e))},getBasePosition:function(){var t=this,e=t.min,n=t.max;return t.getPointPositionForValue(0,t.beginAtZero?0:e<0&&n<0?n:e>0&&n>0?e:0)},draw:function(){var t=this,e=t.options,n=e.gridLines,a=e.ticks,i=o.valueOrDefault;if(e.display){var r=t.ctx,s=this.getIndexAngle(0),l=i(a.fontSize,p.defaultFontSize),u=i(a.fontStyle,p.defaultFontStyle),c=i(a.fontFamily,p.defaultFontFamily),f=o.fontString(l,u,c);o.each(t.ticks,(function(e,o){if(o>0||a.reverse){var u=t.getDistanceFromCenterForValue(t.ticksAsNumbers[o]);if(n.display&&0!==o&&h(t,n,u,o),a.display){var c=i(a.fontColor,p.defaultFontColor);if(r.font=f,r.save(),r.translate(t.xCenter,t.yCenter),r.rotate(s),a.showLabelBackdrop){var d=r.measureText(e).width;r.fillStyle=a.backdropColor,r.fillRect(-d/2-a.backdropPaddingX,-u-l/2-a.backdropPaddingY,d+2*a.backdropPaddingX,l+2*a.backdropPaddingY)}r.textAlign="center",r.textBaseline="middle",r.fillStyle=c,r.fillText(e,0,-u),r.restore()}}})),(e.angleLines.display||e.pointLabels.display)&&d(t)}}});t.scaleService.registerScaleType("radialLinear",v,g)}},{25:25,34:34,45:45}],57:[function(t,e,n){"use strict";function a(t,e){return t-e}function o(t){var e,n,a,o={},i=[];for(e=0,n=t.length;e=0&&r<=s;){if(o=t[(a=r+s>>1)-1]||null,i=t[a],!o)return{lo:null,hi:i};if(i[e]n))return{lo:o,hi:i};s=a-1}}return{lo:i,hi:null}}(t,e,n),i=o.lo?o.hi?o.lo:t[t.length-2]:t[0],r=o.lo?o.hi?o.hi:t[t.length-1]:t[1],s=r[e]-i[e],l=s?(n-i[e])/s:0,u=(r[a]-i[a])*l;return i[a]+u}function r(t,e){var n=e.parser,a=e.parser||e.format;return"function"==typeof n?n(t):"string"==typeof t&&"string"==typeof a?h(t,a):(t instanceof h||(t=h(t)),t.isValid()?t:"function"==typeof a?a(t):t)}function s(t,e){if(p.isNullOrUndef(t))return null;var n=e.options.time,a=r(e.getRightValue(t),n);return a.isValid()?(n.round&&a.startOf(n.round),a.valueOf()):null}function l(t,e,n,a){var o,i,r,s=b.length;for(o=b.indexOf(t);o1?e[1]:a,s=e[0],l=(i(t,"time",r,"pos")-i(t,"time",s,"pos"))/2),o.time.max||(r=e[e.length-1],s=e.length>1?e[e.length-2]:n,u=(i(t,"time",r,"pos")-i(t,"time",s,"pos"))/2)),{left:l,right:u}}function d(t,e){var n,a,o,i,r=[];for(n=0,a=t.length;n=o&&n<=i&&y.push(n);return a.min=o,a.max=i,a._unit=g,a._majorUnit=v,a._minorFormat=f[g],a._majorFormat=f[v],a._table=function(t,e,n,a){if("linear"===a||!t.length)return[{time:e,pos:0},{time:n,pos:1}];var o,i,r,s,l,u=[],c=[e];for(o=0,i=t.length;oe&&s=0&&t{function a(t){return a="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},a(t)}n(8636),n(5086),n(8329),n(8772),n(4913),n(9693),n(115),n(7136),n(173),n(9073),n(6048),n(9581),n(3534),n(590),n(4216),n(8665),n(9979),n(4602),function(t){"use strict";var e=function(e,n){t.fn.typeahead.defaults;n.scrollBar&&(n.items=100,n.menu='');var a=this;if(a.$element=t(e),a.options=t.extend({},t.fn.typeahead.defaults,n),a.$menu=t(a.options.menu).insertAfter(a.$element),a.eventSupported=a.options.eventSupported||a.eventSupported,a.grepper=a.options.grepper||a.grepper,a.highlighter=a.options.highlighter||a.highlighter,a.lookup=a.options.lookup||a.lookup,a.matcher=a.options.matcher||a.matcher,a.render=a.options.render||a.render,a.onSelect=a.options.onSelect||null,a.sorter=a.options.sorter||a.sorter,a.source=a.options.source||a.source,a.displayField=a.options.displayField||a.displayField,a.valueField=a.options.valueField||a.valueField,a.options.ajax){var o=a.options.ajax;"string"==typeof o?a.ajax=t.extend({},t.fn.typeahead.defaults.ajax,{url:o}):("string"==typeof o.displayField&&(a.displayField=a.options.displayField=o.displayField),"string"==typeof o.valueField&&(a.valueField=a.options.valueField=o.valueField),a.ajax=t.extend({},t.fn.typeahead.defaults.ajax,o)),a.ajax.url||(a.ajax=null),a.query=""}else a.source=a.options.source,a.ajax=null;a.shown=!1,a.listen()};e.prototype={constructor:e,eventSupported:function(t){var e=t in this.$element;return e||(this.$element.setAttribute(t,"return;"),e="function"==typeof this.$element[t]),e},select:function(){var t=this.$menu.find(".active").attr("data-value"),e=this.$menu.find(".active a").text();return this.options.onSelect&&this.options.onSelect({value:t,text:e}),this.$element.val(this.updater(e)).change(),this.hide()},updater:function(t){return t},show:function(){var e=t.extend({},this.$element.position(),{height:this.$element[0].offsetHeight});if(this.$menu.css({top:e.top+e.height,left:e.left}),this.options.alignWidth){var n=t(this.$element[0]).outerWidth();this.$menu.css({width:n})}return this.$menu.show(),this.shown=!0,this},hide:function(){return this.$menu.hide(),this.shown=!1,this},ajaxLookup:function(){var e=t.trim(this.$element.val());if(e===this.query)return this;if(this.query=e,this.ajax.timerId&&(clearTimeout(this.ajax.timerId),this.ajax.timerId=null),!e||e.length"+e+""}))},render:function(e){var n,o=this,i="string"==typeof o.options.displayField;return(e=t(e).map((function(e,r){return"object"===a(r)?(n=i?r[o.options.displayField]:o.options.displayField(r),e=t(o.options.item).attr("data-value",r[o.options.valueField])):(n=r,e=t(o.options.item).attr("data-value",r)),e.find("a").html(o.highlighter(n)),e[0]}))).first().addClass("active"),this.$menu.html(e),this},grepper:function(e){var n,a,o=this,i="string"==typeof o.options.displayField;if(!(i&&e&&e.length))return null;if(e[0].hasOwnProperty(o.options.displayField))n=t.grep(e,(function(t){return a=i?t[o.options.displayField]:o.options.displayField(t),o.matcher(a)}));else{if("string"!=typeof e[0])return null;n=t.grep(e,(function(t){return o.matcher(t)}))}return this.sorter(n)},next:function(e){var n=this.$menu.find(".active").removeClass("active").next();if(n.length||(n=t(this.$menu.find("li")[0])),this.options.scrollBar){var a=this.$menu.children("li").index(n);a%8==0&&this.$menu.scrollTop(26*a)}n.addClass("active")},prev:function(t){var e=this.$menu.find(".active").removeClass("active").prev();if(e.length||(e=this.$menu.find("li").last()),this.options.scrollBar){var n=this.$menu.children("li"),a=n.length-1,o=n.index(e);(a-o)%8==0&&this.$menu.scrollTop(26*(o-7))}e.addClass("active")},listen:function(){this.$element.on("focus",t.proxy(this.focus,this)).on("blur",t.proxy(this.blur,this)).on("keypress",t.proxy(this.keypress,this)).on("keyup",t.proxy(this.keyup,this)),this.eventSupported("keydown")&&this.$element.on("keydown",t.proxy(this.keydown,this)),this.$menu.on("click",t.proxy(this.click,this)).on("mouseenter","li",t.proxy(this.mouseenter,this)).on("mouseleave","li",t.proxy(this.mouseleave,this))},move:function(t){if(this.shown){switch(t.keyCode){case 9:case 13:case 27:t.preventDefault();break;case 38:t.preventDefault(),this.prev();break;case 40:t.preventDefault(),this.next()}t.stopPropagation()}},keydown:function(e){this.suppressKeyPressRepeat=~t.inArray(e.keyCode,[40,38,9,13,27]),this.move(e)},keypress:function(t){this.suppressKeyPressRepeat||this.move(t)},keyup:function(t){switch(t.keyCode){case 40:case 38:case 16:case 17:case 18:break;case 9:case 13:if(!this.shown)return;this.select();break;case 27:if(!this.shown)return;this.hide();break;default:this.ajax?this.ajaxLookup():this.lookup()}t.stopPropagation(),t.preventDefault()},focus:function(t){this.focused=!0},blur:function(t){this.focused=!1,!this.mousedover&&this.shown&&this.hide()},click:function(t){t.stopPropagation(),t.preventDefault(),this.select(),this.$element.focus()},mouseenter:function(e){this.mousedover=!0,this.$menu.find(".active").removeClass("active"),t(e.currentTarget).addClass("active")},mouseleave:function(t){this.mousedover=!1,!this.focused&&this.shown&&this.hide()},destroy:function(){this.$element.off("focus",t.proxy(this.focus,this)).off("blur",t.proxy(this.blur,this)).off("keypress",t.proxy(this.keypress,this)).off("keyup",t.proxy(this.keyup,this)),this.eventSupported("keydown")&&this.$element.off("keydown",t.proxy(this.keydown,this)),this.$menu.off("click",t.proxy(this.click,this)).off("mouseenter","li",t.proxy(this.mouseenter,this)).off("mouseleave","li",t.proxy(this.mouseleave,this)),this.$element.removeData("typeahead")}},t.fn.typeahead=function(n){return this.each((function(){var o=t(this),i=o.data("typeahead"),r="object"===a(n)&&n;i||o.data("typeahead",i=new e(this,r)),"string"==typeof n&&i[n]()}))},t.fn.typeahead.defaults={source:[],items:10,scrollBar:!1,alignWidth:!0,menu:'',item:'
  • ',valueField:"id",displayField:"name",onSelect:function(){},ajax:{url:null,timeout:300,method:"get",triggerLength:1,loadingClass:null,preDispatch:null,preProcess:null}},t.fn.typeahead.Constructor=e,t((function(){t("body").on("focus.typeahead.data-api",'[data-provide="typeahead"]',(function(e){var n=t(this);n.data("typeahead")||(e.preventDefault(),n.typeahead(n.data()))}))}))}(window.jQuery)},2811:function(t,e,n){var a,o;function i(t){return i="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},i(t)}n(4913),n(475),n(115),n(9693),n(8636),n(5086),n(7136),n(173),n(2231),n(6255),n(9389),n(6048),n(9581),n(6088),n(9073),n(3534),n(590),n(4216),n(8665),n(9979),n(4602),function(t){"use strict";var e,n,a=Array.prototype.slice;(n=function(e){this.options=t.extend({},n.defaults,e),this.parser=this.options.parser,this.locale=this.options.locale,this.messageStore=this.options.messageStore,this.languages={},this.init()}).prototype={init:function(){var e=this;String.locale=e.locale,String.prototype.toLocaleString=function(){var n,a,o,i,r,s,l;for(o=this.valueOf(),i=e.locale,r=0;i;){a=(n=i.split("-")).length;do{if(s=n.slice(0,a).join("-"),l=e.messageStore.get(s,o))return l;a--}while(a);if("en"===i)break;i=t.i18n.fallbacks[e.locale]&&t.i18n.fallbacks[e.locale][r]||e.options.fallbackLocale,t.i18n.log("Trying fallback locale for "+e.locale+": "+i),r++}return""}},destroy:function(){t.removeData(document,"i18n")},load:function(e,n){var a,o,i,r={};if(e||n||(e="i18n/"+t.i18n().locale+".json",n=t.i18n().locale),"string"==typeof e&&"json"!==e.split(".").pop()){for(o in r[n]=e+"/"+n+".json",a=(t.i18n.fallbacks[n]||[]).concat(this.options.fallbackLocale))r[i=a[o]]=e+"/"+i+".json";return this.load(r)}return this.messageStore.load(e,n)},parse:function(e,n){var a=e.toLocaleString();return this.parser.language=t.i18n.languages[t.i18n().locale]||t.i18n.languages.default,""===a&&(a=e),this.parser.parse(a,n)}},t.i18n=function(e,o){var r,s=t.data(document,"i18n"),l="object"===i(e)&&e;return l&&l.locale&&s&&s.locale!==l.locale&&(String.locale=s.locale=l.locale),s||(s=new n(l),t.data(document,"i18n",s)),"string"==typeof e?(r=void 0!==o?a.call(arguments,1):[],s.parse(e,r)):s},t.fn.i18n=function(){var e=t.data(document,"i18n");return e||(e=new n,t.data(document,"i18n",e)),String.locale=e.locale,this.each((function(){var n,a,o,i,r=t(this),s=r.data("i18n");s?(n=s.indexOf("["),a=s.indexOf("]"),-1!==n&&-1!==a&&n1?["CONCAT"].concat(t):t[0]}function P(){var t=w([h,n,I]);return null===t?null:[t[0],t[2]]}function A(){var t=w([h,n,v]);return null===t?null:[t[0],t[2]]}function T(){var t=w([f,d,p]);return null===t?null:t[1]}if(e=S("|"),n=S(":"),a=S("\\"),o=M(/^./),i=S("$"),r=M(/^\d+/),s=M(/^[^{}\[\]$\\]/),l=M(/^[^{}\[\]$\\|]/),k([_,M(/^[^{}\[\]$\s]/)]),u=k([_,l]),c=k([_,s]),b=M(/^[ !"$&'()*,.\/0-9;=?@A-Z\^_`a-z~\x80-\xFF+\-]+/),x=function(t){return t.toString()},h=function(){var t=b();return null===t?null:x(t)},d=k([function(){var t=w([k([P,A]),C(0,D)]);return null===t?null:t[0].concat(t[1])},function(){var t=w([h,C(0,D)]);return null===t?null:[t[0]].concat(t[1])}]),f=S("{{"),p=S("}}"),g=k([T,I,function(){var t=C(1,c)();return null===t?null:t.join("")}]),v=k([T,I,function(){var t=C(1,u)();return null===t?null:t.join("")}]),null===(m=function(){var t=C(0,g)();return null===t?null:["CONCAT"].concat(t)}())||y!==t.length)throw new Error("Parse error at position "+y.toString()+" in input: "+t);return m}},t.extend(t.i18n.parser,new e)}(jQuery),function(t){"use strict";var e=function(){this.language=t.i18n.languages[String.locale]||t.i18n.languages.default};e.prototype={constructor:e,emit:function(e,n){var a,o,r,s=this;switch(i(e)){case"string":case"number":a=e;break;case"object":if(o=t.map(e.slice(1),(function(t){return s.emit(t,n)})),r=e[0].toLowerCase(),"function"!=typeof s[r])throw new Error('unknown operation "'+r+'"');a=s[r](o,n);break;case"undefined":a="";break;default:throw new Error("unexpected type in AST: "+i(e))}return a},concat:function(e){var n="";return t.each(e,(function(t,e){n+=e})),n},replace:function(t,e){var n=parseInt(t[0],10);return n=parseInt(t[0],10)&&e[0]{},1536:()=>{},2559:()=>{},2553:()=>{},5264:()=>{},6387:()=>{},5985:()=>{},63:()=>{},3888:()=>{},7278:()=>{},3704:()=>{}},t=>{var e=e=>t(t.s=e);t.O(0,[852],(()=>(e(2811),e(7852),e(6108),e(5779),e(6618),e(3441),e(1680),e(9654),e(5611),e(3600),e(514),e(9307),e(6730),e(1595),e(1223),e(9662),e(63),e(1536),e(2559),e(2553),e(5264),e(6387),e(5985),e(3888),e(3704),e(7278))));t.O()}]); \ No newline at end of file diff --git a/public/build/entrypoints.json b/public/build/entrypoints.json index 3a05dd4fb..5faf1d6f7 100644 --- a/public/build/entrypoints.json +++ b/public/build/entrypoints.json @@ -4,7 +4,7 @@ "js": [ "/build/runtime.c217f8c4.js", "/build/852.96913092.js", - "/build/app.a7ec0e72.js" + "/build/app.9cc563c1.js" ], "css": [ "/build/app.7692d209.css" diff --git a/public/build/manifest.json b/public/build/manifest.json index 7e4d1dcd4..f7f7be4a4 100644 --- a/public/build/manifest.json +++ b/public/build/manifest.json @@ -1,6 +1,6 @@ { "build/app.css": "/build/app.7692d209.css", - "build/app.js": "/build/app.a7ec0e72.js", + "build/app.js": "/build/app.9cc563c1.js", "build/runtime.js": "/build/runtime.c217f8c4.js", "build/852.96913092.js": "/build/852.96913092.js", "build/images/VPS-badge.svg": "/build/images/VPS-badge.svg", diff --git a/src/Controller/AdminScoreController.php b/src/Controller/AdminScoreController.php index 6a6da1219..fc27f23d6 100644 --- a/src/Controller/AdminScoreController.php +++ b/src/Controller/AdminScoreController.php @@ -1,6 +1,6 @@ params['project']) && isset($this->params['username'])) { - return $this->redirectToRoute('AdminScoreResult', $this->params); - } - - return $this->render('adminscore/index.html.twig', [ - 'xtPage' => 'AdminScore', - 'xtPageTitle' => 'tool-adminscore', - 'xtSubtitle' => 'tool-adminscore-desc', - 'project' => $this->project, - ]); - } - - /** - * Display the AdminScore results. - * @codeCoverageIgnore - */ - #[Route('/adminscore/{project}/{username}', name: 'AdminScoreResult')] - public function resultAction(AdminScoreRepository $adminScoreRepo): Response - { - $adminScore = new AdminScore($adminScoreRepo, $this->project, $this->user); - - return $this->getFormattedResponse('adminscore/result', [ - 'xtPage' => 'AdminScore', - 'xtTitle' => $this->user->getUsername(), - 'as' => $adminScore, - ]); - } +class AdminScoreController extends XtoolsController { + /** + * @inheritDoc + * @codeCoverageIgnore + */ + public function getIndexRoute(): string { + return 'AdminScore'; + } + + /** + * Display the AdminScore search form. + */ + #[Route( '/adminscore', name: 'AdminScore' )] + #[Route( '/adminscore/index.php', name: 'AdminScoreIndexPhp' )] + #[Route( '/scottywong tools/adminscore.php', name: 'AdminScoreLegacy' )] + #[Route( '/adminscore/{project}', name: 'AdminScoreProject' )] + public function indexAction(): Response { + // Redirect if we have a project and user. + if ( isset( $this->params['project'] ) && isset( $this->params['username'] ) ) { + return $this->redirectToRoute( 'AdminScoreResult', $this->params ); + } + + return $this->render( 'adminscore/index.html.twig', [ + 'xtPage' => 'AdminScore', + 'xtPageTitle' => 'tool-adminscore', + 'xtSubtitle' => 'tool-adminscore-desc', + 'project' => $this->project, + ] ); + } + + /** + * Display the AdminScore results. + * @codeCoverageIgnore + */ + #[Route( '/adminscore/{project}/{username}', name: 'AdminScoreResult' )] + public function resultAction( AdminScoreRepository $adminScoreRepo ): Response { + $adminScore = new AdminScore( $adminScoreRepo, $this->project, $this->user ); + + return $this->getFormattedResponse( 'adminscore/result', [ + 'xtPage' => 'AdminScore', + 'xtTitle' => $this->user->getUsername(), + 'as' => $adminScore, + ] ); + } } diff --git a/src/Controller/AdminStatsController.php b/src/Controller/AdminStatsController.php index 371d252bf..98b935709 100644 --- a/src/Controller/AdminStatsController.php +++ b/src/Controller/AdminStatsController.php @@ -1,6 +1,6 @@ isApi ? self::MAX_DAYS_API : self::MAX_DAYS_UI; - } - - /** - * @inheritDoc - * @codeCoverageIgnore - */ - public function defaultDays(): ?int - { - return self::DEFAULT_DAYS; - } - - /** - * Method for rendering the AdminStats Main Form. - * This method redirects if valid parameters are found, making it a valid form endpoint as well. - */ - #[Route( - "/adminstats", - name: "AdminStats", - requirements: ["group" => "admin|patroller|steward"], - defaults: ["group" => "admin"] - )] - #[Route( - "/patrollerstats", - name: "PatrollerStats", - requirements: ["group" => "admin|patroller|steward"], - defaults: ["group" => "patroller"] - )] - #[Route( - "/stewardstats", - name: "StewardStats", - requirements: ["group" => "admin|patroller|steward"], - defaults: ["group" => "steward"] - )] - public function indexAction(AdminStatsRepository $adminStatsRepo): Response - { - $this->getAndSetRequestedActions(); - - // Redirect if we have a project. - if (isset($this->params['project'])) { - // We want pretty URLs. - if ($this->getActionNames($this->params['group']) === explode('|', $this->params['actions'])) { - unset($this->params['actions']); - } - $route = $this->generateUrl('AdminStatsResult', $this->params); - $url = str_replace('%7C', '|', $route); - return $this->redirect($url); - } - - $actionsConfig = $adminStatsRepo->getConfig($this->project); - $group = $this->params['group']; - $xtPage = lcfirst($group).'Stats'; - - $params = array_merge([ - 'xtPage' => $xtPage, - 'xtPageTitle' => "tool-{$group}stats", - 'xtSubtitle' => "tool-{$group}stats-desc", - 'actionsConfig' => $actionsConfig, - - // Defaults that will get overridden if in $params. - 'start' => '', - 'end' => '', - 'group' => 'admin', - ], $this->params); - $params['project'] = $this->normalizeProject($params['group']); - - $params['isAllActions'] = $params['actions'] === implode('|', $this->getActionNames($params['group'])); - - // Otherwise render form. - return $this->render('adminStats/index.html.twig', $params); - } - - /** - * Normalize the Project to be Meta if viewing Steward Stats. - * @param string $group - * @return Project - */ - private function normalizeProject(string $group): Project - { - if ('meta.wikimedia.org' !== $this->project->getDomain() && - 'steward' === $group && - $this->getParameter('app.is_wmf') - ) { - $this->project = $this->projectRepo->getProject('meta.wikimedia.org'); - } - - return $this->project; - } - - /** - * Get the requested actions and set the class property. - * @return string[] - * @codeCoverageIgnore - */ - private function getAndSetRequestedActions(): array - { - /** @var string $group The requested 'group'. See keys at admin_stats.yaml for possible values. */ - $group = $this->params['group'] = $this->params['group'] ?? 'admin'; - - // Query param for sections gets priority. - $actionsQuery = $this->request->get('actions', ''); - - // Either a pipe-separated string or an array. - $actionsRequested = is_array($actionsQuery) ? $actionsQuery : array_filter(explode('|', $actionsQuery)); - - // Filter out any invalid action names. - $actions = array_filter($actionsRequested, function ($action) use ($group) { - return in_array($action, $this->getActionNames($group)); - }); - - // Warn about unsupported actions in the API. - if ($this->isApi) { - foreach (array_diff($actionsRequested, $actions) as $value) { - $this->addFlashMessage('warning', 'error-param', [$value, 'actions']); - } - } - - // Fallback for when no valid sections were requested. - if (0 === count($actions)) { - $actions = $this->getActionNames($group); - } - - // Store as pipe-separated string for prettier URLs. - $this->params['actions'] = str_replace('%7C', '|', implode('|', $actions)); - - return $actions; - } - - /** - * Get the names of the available sections. - * @param string $group Corresponds to the groups specified in admin_stats.yaml - * @return string[] - * @codeCoverageIgnore - */ - private function getActionNames(string $group): array - { - $actionsConfig = $this->getParameter('admin_stats'); - return array_keys($actionsConfig[$group]['actions']); - } - - /** - * Every action in this controller (other than 'index') calls this first. - * @codeCoverageIgnore - */ - public function setUpAdminStats(AdminStatsRepository $adminStatsRepo): AdminStats - { - $group = $this->params['group'] ?? 'admin'; - - $this->adminStats = new AdminStats( - $adminStatsRepo, - $this->normalizeProject($group), - (int)$this->start, - (int)$this->end, - $group ?? 'admin', - $this->getAndSetRequestedActions() - ); - - // For testing purposes. - return $this->adminStats; - } - - /** - * Method for rendering the AdminStats results. - * @codeCoverageIgnore - */ - #[Route( - "/{group}stats/{project}/{start}/{end}", - name: "AdminStatsResult", - requirements: [ - "start" => "|\d{4}-\d{2}-\d{2}", - "end" => "|\d{4}-\d{2}-\d{2}", - "group" => "admin|patroller|steward", - ], - defaults: [ - "start" => false, - "end" => false, - "group" => "admin", - ] - )] - public function resultAction( - AdminStatsRepository $adminStatsRepo, - UserRightsRepository $userRightsRepo, - I18nHelper $i18n - ): Response { - $this->setUpAdminStats($adminStatsRepo); - - $this->adminStats->prepareStats(); - - // For the HTML view, we want the localized name of the user groups. - // These are in the 'title' attribute of the icons for each user group. - $rightsNames = $userRightsRepo->getRightsNames($this->project, $i18n->getLang()); - - return $this->getFormattedResponse('adminStats/result', [ - 'xtPage' => lcfirst($this->params['group']).'Stats', - 'xtTitle' => $this->project->getDomain(), - 'as' => $this->adminStats, - 'rightsNames' => $rightsNames, - ]); - } - - /************************ API endpoints ************************/ - - /** - * Get users of the project that are capable of making admin, patroller, or steward actions. - * @OA\Tag(name="Project API") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Parameter(ref="#/components/parameters/Group") - * @OA\Response( - * response=200, - * description="List of users and their groups.", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="group", ref="#/components/parameters/Group/schema"), - * @OA\Property(property="users_and_groups", - * type="object", - * title="username", - * example={"Jimbo Wales":{"sysop", "steward"}} - * ), - * @OA\Property(property="elapsed_time", ref="#/components/schemas/elapsed_time") - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - * @codeCoverageIgnore - */ - #[Route( - "/api/project/{group}_groups/{project}", - name: "ProjectApiAdminsGroups", - requirements: ["group" => "admin|patroller|steward"], - defaults: ["group" => "admin"], - methods: ["GET"] - )] - public function adminsGroupsApiAction(AdminStatsRepository $adminStatsRepo): JsonResponse - { - $this->recordApiUsage('project/admin_groups'); - - $this->setUpAdminStats($adminStatsRepo); - - unset($this->params['actions']); - unset($this->params['start']); - unset($this->params['end']); - - return $this->getFormattedApiResponse([ - 'users_and_groups' => $this->adminStats->getUsersAndGroups(), - ]); - } - - /** - * Get counts of logged actions by admins, patrollers, or stewards. - * @OA\Tag(name="Project API") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Parameter(ref="#/components/parameters/Group") - * @OA\Parameter(ref="#/components/parameters/Start") - * @OA\Parameter(ref="#/components/parameters/End") - * @OA\Parameter(ref="#/components/parameters/Actions") - * @OA\Response( - * response=200, - * description="List of users and counts of their logged actions.", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="group", ref="#/components/parameters/Group/schema"), - * @OA\Property(property="start", ref="#/components/parameters/Start/schema"), - * @OA\Property(property="end", ref="#/components/parameters/End/schema"), - * @OA\Property(property="actions", ref="#/components/parameters/Actions/schema"), - * @OA\Property(property="users", - * type="object", - * example={"Jimbo Wales":{ - * "username": "Jimbo Wales", - * "delete": 10, - * "re-block": 15, - * "re-protect": 5, - * "total": 30, - * "user-groups": {"sysop"} - * }} - * ), - * @OA\Property(property="elapsed_time", ref="#/components/schemas/elapsed_time") - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - * @codeCoverageIgnore - */ - #[Route( - "/api/project/{group}_stats/{project}/{start}/{end}", - name: "ProjectApiAdminStats", - requirements: [ - "start" => "|\d{4}-\d{2}-\d{2}", - "end" => "|\d{4}-\d{2}-\d{2}", - "group" => "admin|patroller|steward", - ], - defaults: [ - "start" => false, - "end" => false, - "group" => "admin", - ], - methods: ["GET"] - )] - public function adminStatsApiAction(AdminStatsRepository $adminStatsRepo): JsonResponse - { - $this->recordApiUsage('project/adminstats'); - - $this->setUpAdminStats($adminStatsRepo); - $this->adminStats->prepareStats(); - - return $this->getFormattedApiResponse([ - 'users' => $this->adminStats->getStats(), - ]); - } +class AdminStatsController extends XtoolsController { + protected AdminStats $adminStats; + + public const DEFAULT_DAYS = 31; + public const MAX_DAYS_UI = 365; + public const MAX_DAYS_API = 31; + + /** + * @inheritDoc + * @codeCoverageIgnore + */ + public function getIndexRoute(): string { + return 'AdminStats'; + } + + /** + * Set the max length for the date range. Value is smaller for API requests. + * @inheritDoc + * @codeCoverageIgnore + */ + public function maxDays(): ?int { + return $this->isApi ? self::MAX_DAYS_API : self::MAX_DAYS_UI; + } + + /** + * @inheritDoc + * @codeCoverageIgnore + */ + public function defaultDays(): ?int { + return self::DEFAULT_DAYS; + } + + #[Route( + "/adminstats", + name: "AdminStats", + requirements: [ "group" => "admin|patroller|steward" ], + defaults: [ "group" => "admin" ] + )] + #[Route( + "/patrollerstats", + name: "PatrollerStats", + requirements: [ "group" => "admin|patroller|steward" ], + defaults: [ "group" => "patroller" ] + )] + #[Route( + "/stewardstats", + name: "StewardStats", + requirements: [ "group" => "admin|patroller|steward" ], + defaults: [ "group" => "steward" ] + )] + /** + * Method for rendering the AdminStats Main Form. + * This method redirects if valid parameters are found, making it a valid form endpoint as well. + */ + public function indexAction( AdminStatsRepository $adminStatsRepo ): Response { + $this->getAndSetRequestedActions(); + + // Redirect if we have a project. + if ( isset( $this->params['project'] ) ) { + // We want pretty URLs. + if ( $this->getActionNames( $this->params['group'] ) === explode( '|', $this->params['actions'] ) ) { + unset( $this->params['actions'] ); + } + $route = $this->generateUrl( 'AdminStatsResult', $this->params ); + $url = str_replace( '%7C', '|', $route ); + return $this->redirect( $url ); + } + + $actionsConfig = $adminStatsRepo->getConfig( $this->project ); + $group = $this->params['group']; + $xtPage = lcfirst( $group ) . 'Stats'; + + $params = array_merge( [ + 'xtPage' => $xtPage, + 'xtPageTitle' => "tool-{$group}stats", + 'xtSubtitle' => "tool-{$group}stats-desc", + 'actionsConfig' => $actionsConfig, + + // Defaults that will get overridden if in $params. + 'start' => '', + 'end' => '', + 'group' => 'admin', + ], $this->params ); + $params['project'] = $this->normalizeProject( $params['group'] ); + + $params['isAllActions'] = $params['actions'] === implode( '|', $this->getActionNames( $params['group'] ) ); + + // Otherwise render form. + return $this->render( 'adminStats/index.html.twig', $params ); + } + + /** + * Normalize the Project to be Meta if viewing Steward Stats. + * @param string $group + * @return Project + */ + private function normalizeProject( string $group ): Project { + if ( $this->project->getDomain() !== 'meta.wikimedia.org' && + $group === 'steward' && + $this->getParameter( 'app.is_wmf' ) + ) { + $this->project = $this->projectRepo->getProject( 'meta.wikimedia.org' ); + } + + return $this->project; + } + + /** + * Get the requested actions and set the class property. + * @return string[] + * @codeCoverageIgnore + */ + private function getAndSetRequestedActions(): array { + /** @var string $group The requested 'group'. See keys at admin_stats.yaml for possible values. */ + $group = $this->params['group'] = $this->params['group'] ?? 'admin'; + + // Query param for sections gets priority. + $actionsQuery = $this->request->get( 'actions', '' ); + + // Either a pipe-separated string or an array. + $actionsRequested = is_array( $actionsQuery ) ? $actionsQuery : array_filter( explode( '|', $actionsQuery ) ); + + // Filter out any invalid action names. + $actions = array_filter( $actionsRequested, function ( $action ) use ( $group ) { + return in_array( $action, $this->getActionNames( $group ) ); + } ); + + // Warn about unsupported actions in the API. + if ( $this->isApi ) { + foreach ( array_diff( $actionsRequested, $actions ) as $value ) { + $this->addFlashMessage( 'warning', 'error-param', [ $value, 'actions' ] ); + } + } + + // Fallback for when no valid sections were requested. + if ( count( $actions ) === 0 ) { + $actions = $this->getActionNames( $group ); + } + + // Store as pipe-separated string for prettier URLs. + $this->params['actions'] = str_replace( '%7C', '|', implode( '|', $actions ) ); + + return $actions; + } + + /** + * Get the names of the available sections. + * @param string $group Corresponds to the groups specified in admin_stats.yaml + * @return string[] + * @codeCoverageIgnore + */ + private function getActionNames( string $group ): array { + $actionsConfig = $this->getParameter( 'admin_stats' ); + return array_keys( $actionsConfig[$group]['actions'] ); + } + + /** + * Every action in this controller (other than 'index') calls this first. + * @codeCoverageIgnore + */ + public function setUpAdminStats( AdminStatsRepository $adminStatsRepo ): AdminStats { + $group = $this->params['group'] ?? 'admin'; + + $this->adminStats = new AdminStats( + $adminStatsRepo, + $this->normalizeProject( $group ), + (int)$this->start, + (int)$this->end, + $group ?? 'admin', + $this->getAndSetRequestedActions() + ); + + // For testing purposes. + return $this->adminStats; + } + + #[Route( + "/{group}stats/{project}/{start}/{end}", + name: "AdminStatsResult", + requirements: [ + "start" => "|\d{4}-\d{2}-\d{2}", + "end" => "|\d{4}-\d{2}-\d{2}", + "group" => "admin|patroller|steward", + ], + defaults: [ + "start" => false, + "end" => false, + "group" => "admin", + ] + )] + /** + * Method for rendering the AdminStats results. + * @codeCoverageIgnore + */ + public function resultAction( + AdminStatsRepository $adminStatsRepo, + UserRightsRepository $userRightsRepo, + I18nHelper $i18n + ): Response { + $this->setUpAdminStats( $adminStatsRepo ); + + $this->adminStats->prepareStats(); + + // For the HTML view, we want the localized name of the user groups. + // These are in the 'title' attribute of the icons for each user group. + $rightsNames = $userRightsRepo->getRightsNames( $this->project, $i18n->getLang() ); + + return $this->getFormattedResponse( 'adminStats/result', [ + 'xtPage' => lcfirst( $this->params['group'] ) . 'Stats', + 'xtTitle' => $this->project->getDomain(), + 'as' => $this->adminStats, + 'rightsNames' => $rightsNames, + ] ); + } + + /************************ API endpoints */ + + #[OA\Tag( name: "Project API" )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Parameter( ref: "#/components/parameters/Group" )] + #[OA\Response( + response: 200, + description: "List of users and their groups.", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "group", ref: "#/components/parameters/Group/schema" ), + new OA\Property( + property: "users_and_groups", + title: "username", + type: "object", + example: [ "Jimbo Wales" => [ "sysop", "steward" ] ] + ), + new OA\Property( property: "elapsed_time", ref: "#/components/schemas/elapsed_time" ), + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[OA\Response( ref: "#/components/responses/503", response: 503 )] + #[OA\Response( ref: "#/components/responses/504", response: 504 )] + #[Route( + "/api/project/{group}_groups/{project}", + name: "ProjectApiAdminsGroups", + requirements: [ "group" => "admin|patroller|steward" ], + defaults: [ "group" => "admin" ], + methods: [ "GET" ] + )] + /** + * Get users of the project that are capable of making admin, patroller, or steward actions. + * @codeCoverageIgnore + */ + public function adminsGroupsApiAction( AdminStatsRepository $adminStatsRepo ): JsonResponse { + $this->recordApiUsage( 'project/admin_groups' ); + + $this->setUpAdminStats( $adminStatsRepo ); + + unset( $this->params['actions'] ); + unset( $this->params['start'] ); + unset( $this->params['end'] ); + + return $this->getFormattedApiResponse( [ + 'users_and_groups' => $this->adminStats->getUsersAndGroups(), + ] ); + } + + #[OA\Tag( name: "Project API" )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Parameter( ref: "#/components/parameters/Group" )] + #[OA\Parameter( ref: "#/components/parameters/Start" )] + #[OA\Parameter( ref: "#/components/parameters/End" )] + #[OA\Parameter( ref: "#/components/parameters/Actions" )] + #[OA\Response( + response: 200, + description: "List of users and counts of their logged actions.", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "group", ref: "#/components/parameters/Group/schema" ), + new OA\Property( property: "start", ref: "#/components/parameters/Start/schema" ), + new OA\Property( property: "end", ref: "#/components/parameters/End/schema" ), + new OA\Property( property: "actions", ref: "#/components/parameters/Actions/schema" ), + new OA\Property( + property: "users", + type: "object", + example: [ + "Jimbo Wales" => [ + "username" => "Jimbo Wales", + "delete" => 10, + "re-block" => 15, + "re-protect" => 5, + "total" => 30, + "user-groups" => [ "sysop" ], + ], + ], + ), + new OA\Property( property: "elapsed_time", ref: "#/components/schemas/elapsed_time" ), + ], + ), + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[OA\Response( ref: "#/components/responses/503", response: 503 )] + #[OA\Response( ref: "#/components/responses/504", response: 504 )] + #[Route( + "/api/project/{group}_stats/{project}/{start}/{end}", + name: "ProjectApiAdminStats", + requirements: [ + "start" => "|\d{4}-\d{2}-\d{2}", + "end" => "|\d{4}-\d{2}-\d{2}", + "group" => "admin|patroller|steward", + ], + defaults: [ + "start" => false, + "end" => false, + "group" => "admin", + ], + methods: [ "GET" ] + )] + /** + * Get counts of logged actions by admins, patrollers, or stewards. + * @codeCoverageIgnore + */ + public function adminStatsApiAction( AdminStatsRepository $adminStatsRepo ): JsonResponse { + $this->recordApiUsage( 'project/adminstats' ); + + $this->setUpAdminStats( $adminStatsRepo ); + $this->adminStats->prepareStats(); + + return $this->getFormattedApiResponse( [ + 'users' => $this->adminStats->getStats(), + ] ); + } } diff --git a/src/Controller/AuthorshipController.php b/src/Controller/AuthorshipController.php index 09a291cf2..52a5f951b 100644 --- a/src/Controller/AuthorshipController.php +++ b/src/Controller/AuthorshipController.php @@ -1,6 +1,6 @@ params['target'] = $this->request->query->get('target', ''); + #[Route( '/authorship', name: 'Authorship' )] + #[Route( '/authorship/{project}', name: 'AuthorshipProject' )] + /** + * The search form. + */ + public function indexAction(): Response { + $this->params['target'] = $this->request->query->get( 'target', '' ); - if (isset($this->params['project']) && isset($this->params['page'])) { - return $this->redirectToRoute('AuthorshipResult', $this->params); - } + if ( isset( $this->params['project'] ) && isset( $this->params['page'] ) ) { + return $this->redirectToRoute( 'AuthorshipResult', $this->params ); + } - if (preg_match('/\d{4}-\d{2}-\d{2}/', $this->params['target'])) { - $show = 'date'; - } elseif (is_numeric($this->params['target'])) { - $show = 'id'; - } else { - $show = 'latest'; - } + if ( preg_match( '/\d{4}-\d{2}-\d{2}/', $this->params['target'] ) ) { + $show = 'date'; + } elseif ( is_numeric( $this->params['target'] ) ) { + $show = 'id'; + } else { + $show = 'latest'; + } - return $this->render('authorship/index.html.twig', array_merge([ - 'xtPage' => 'Authorship', - 'xtPageTitle' => 'tool-authorship', - 'xtSubtitle' => 'tool-authorship-desc', - 'project' => $this->project, + return $this->render( 'authorship/index.html.twig', array_merge( [ + 'xtPage' => 'Authorship', + 'xtPageTitle' => 'tool-authorship', + 'xtSubtitle' => 'tool-authorship-desc', + 'project' => $this->project, - // Defaults that will get overridden if in $params. - 'page' => '', - 'supportedProjects' => Authorship::SUPPORTED_PROJECTS, - ], $this->params, [ - 'project' => $this->project, - 'show' => $show, - 'target' => '', - ])); - } + // Defaults that will get overridden if in $params. + 'page' => '', + 'supportedProjects' => Authorship::SUPPORTED_PROJECTS, + ], $this->params, [ + 'project' => $this->project, + 'show' => $show, + 'target' => '', + ] ) ); + } - /** - * The result page. - */ - #[Route( - '/authorship/{project}/{page}/{target}', - name: 'AuthorshipResult', - requirements: [ - 'page' => '(.+?)', - 'target' => '|latest|\d+|\d{4}-\d{2}-\d{2}', - ], - defaults: ['target' => 'latest'] - )] - #[Route( - '/articleinfo-authorship/{project}/{page}', - name: 'AuthorshipResultLegacy', - requirements: [ - 'page' => '(.+?)', - 'target' => '|latest|\d+|\d{4}-\d{2}-\d{2}', - ], - defaults: ['target' => 'latest'] - )] - public function resultAction( - string $target, - AuthorshipRepository $authorshipRepo, - RequestStack $requestStack - ): Response { - if (0 !== $this->page->getNamespace()) { - $this->addFlashMessage('danger', 'error-authorship-non-mainspace'); - return $this->redirectToRoute('AuthorshipProject', [ - 'project' => $this->project->getDomain(), - ]); - } + #[Route( + '/authorship/{project}/{page}/{target}', + name: 'AuthorshipResult', + requirements: [ + 'page' => '(.+?)', + 'target' => '|latest|\d+|\d{4}-\d{2}-\d{2}', + ], + defaults: [ 'target' => 'latest' ] + )] + #[Route( + '/articleinfo-authorship/{project}/{page}', + name: 'AuthorshipResultLegacy', + requirements: [ + 'page' => '(.+?)', + 'target' => '|latest|\d+|\d{4}-\d{2}-\d{2}', + ], + defaults: [ 'target' => 'latest' ] + )] + /** + * The result page. + */ + public function resultAction( + string $target, + AuthorshipRepository $authorshipRepo, + RequestStack $requestStack + ): Response { + if ( $this->page->getNamespace() !== 0 ) { + $this->addFlashMessage( 'danger', 'error-authorship-non-mainspace' ); + return $this->redirectToRoute( 'AuthorshipProject', [ + 'project' => $this->project->getDomain(), + ] ); + } - // This action sometimes requires more memory. 256M should be safe. - ini_set('memory_limit', '256M'); + // This action sometimes requires more memory. 256M should be safe. + ini_set( 'memory_limit', '256M' ); - $isSubRequest = $this->request->get('htmlonly') || null !== $requestStack->getParentRequest(); - $limit = $isSubRequest ? 10 : ($this->limit ?? 500); + $isSubRequest = $this->request->get( 'htmlonly' ) || $requestStack->getParentRequest() !== null; + $limit = $isSubRequest ? 10 : ( $this->limit ?? 500 ); - $authorship = new Authorship($authorshipRepo, $this->page, $target, $limit); - $authorship->prepareData(); + $authorship = new Authorship( $authorshipRepo, $this->page, $target, $limit ); + $authorship->prepareData(); - return $this->getFormattedResponse('authorship/authorship', [ - 'xtPage' => 'Authorship', - 'xtTitle' => $this->page->getTitle(), - 'authorship' => $authorship, - 'is_sub_request' => $isSubRequest, - ]); - } + return $this->getFormattedResponse( 'authorship/authorship', [ + 'xtPage' => 'Authorship', + 'xtTitle' => $this->page->getTitle(), + 'authorship' => $authorship, + 'is_sub_request' => $isSubRequest, + ] ); + } } diff --git a/src/Controller/AutomatedEditsController.php b/src/Controller/AutomatedEditsController.php index a617945fe..60d6bfc87 100644 --- a/src/Controller/AutomatedEditsController.php +++ b/src/Controller/AutomatedEditsController.php @@ -1,6 +1,6 @@ getIndexRoute(); - } - - /** - * Display the search form. - */ - #[Route("/autoedits", name: "AutoEdits")] - #[Route("/automatededits", name: "AutoEditsLong")] - #[Route("/autoedits/index.php", name: "AutoEditsIndexPhp")] - #[Route("/automatededits/index.php", name: "AutoEditsLongIndexPhp")] - #[Route("/autoedits/{project}", name: "AutoEditsProject")] - public function indexAction(): Response - { - // Redirect if at minimum project and username are provided. - if (isset($this->params['project']) && isset($this->params['username'])) { - // If 'tool' param is given, redirect to corresponding action. - $tool = $this->request->query->get('tool'); - - if ('all' === $tool) { - unset($this->params['tool']); - return $this->redirectToRoute('AutoEditsContributionsResult', $this->params); - } elseif ('' != $tool && 'none' !== $tool) { - $this->params['tool'] = $tool; - return $this->redirectToRoute('AutoEditsContributionsResult', $this->params); - } elseif ('none' === $tool) { - unset($this->params['tool']); - } - - // Otherwise redirect to the normal result action. - return $this->redirectToRoute('AutoEditsResult', $this->params); - } - - return $this->render('autoEdits/index.html.twig', array_merge([ - 'xtPageTitle' => 'tool-autoedits', - 'xtSubtitle' => 'tool-autoedits-desc', - 'xtPage' => 'AutoEdits', - - // Defaults that will get overridden if in $this->params. - 'username' => '', - 'namespace' => 0, - 'start' => '', - 'end' => '', - ], $this->params, ['project' => $this->project])); - } - - /** - * Set defaults, and instantiate the AutoEdits model. This is called at the top of every view action. - * @codeCoverageIgnore - */ - private function setupAutoEdits(AutoEditsRepository $autoEditsRepo, EditRepository $editRepo): void - { - $tool = $this->request->query->get('tool', null); - $useSandbox = (bool)$this->request->query->get('usesandbox', false); - - if ($useSandbox && !$this->request->getSession()->get('logged_in_user')) { - $this->addFlashMessage('danger', 'auto-edits-logged-out'); - $useSandbox = false; - } - $autoEditsRepo->setUseSandbox($useSandbox); - - $misconfigured = $autoEditsRepo->getInvalidTools($this->project); - $helpLink = "https://w.wiki/ppr"; - foreach ($misconfigured as $tool) { - $this->addFlashMessage('warning', 'auto-edits-misconfiguration', [$tool, $helpLink]); - } - - // Validate tool. - // FIXME: instead of redirecting to index page, show result page listing all tools for that project, - // clickable to show edits by the user, etc. - if ($tool && !isset($autoEditsRepo->getTools($this->project)[$tool])) { - $this->throwXtoolsException( - $this->getIndexRoute(), - 'auto-edits-unknown-tool', - [$tool], - 'tool' - ); - } - - $this->autoEdits = new AutoEdits( - $autoEditsRepo, - $editRepo, - $this->pageRepo, - $this->userRepo, - $this->project, - $this->user, - $this->namespace, - $this->start, - $this->end, - $tool, - $this->offset, - $this->limit - ); - - $this->output = [ - 'xtPage' => 'AutoEdits', - 'xtTitle' => $this->user->getUsername(), - 'ae' => $this->autoEdits, - 'is_sub_request' => $this->isSubRequest, - ]; - - if ($useSandbox) { - $this->output['usesandbox'] = 1; - } - } - - /** - * Display the results. - * @codeCoverageIgnore - */ - #[Route( - "/autoedits/{project}/{username}/{namespace}/{start}/{end}/{offset}", - name: "AutoEditsResult", - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - "namespace" => "|all|\d+", - "start" => "|\d{4}-\d{2}-\d{2}", - "end" => "|\d{4}-\d{2}-\d{2}", - "offset" => "|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?", - ], - defaults: ["namespace" => 0, "start" => false, "end" => false, "offset" => false] - )] - public function resultAction(AutoEditsRepository $autoEditsRepo, EditRepository $editRepo): Response - { - // Will redirect back to index if the user has too high of an edit count. - $this->setupAutoEdits($autoEditsRepo, $editRepo); - - if (in_array('bot', $this->user->getUserRights($this->project))) { - $this->addFlashMessage('warning', 'auto-edits-bot'); - } - - return $this->getFormattedResponse('autoEdits/result', $this->output); - } - - /** - * Get non-automated edits for the given user. - * @codeCoverageIgnore - */ - #[Route( - "/nonautoedits-contributions/{project}/{username}/{namespace}/{start}/{end}/{offset}", - name: "NonAutoEditsContributionsResult", - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - "namespace" => "|all|\d+", - "start" => "|\d{4}-\d{2}-\d{2}", - "end" => "|\d{4}-\d{2}-\d{2}", - "offset" => "|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?", - ], - defaults: ["namespace" => 0, "start" => false, "end" => false, "offset" => false] - )] - public function nonAutomatedEditsAction(AutoEditsRepository $autoEditsRepo, EditRepository $editRepo): Response - { - $this->setupAutoEdits($autoEditsRepo, $editRepo); - return $this->getFormattedResponse('autoEdits/nonautomated_edits', $this->output); - } - - /** - * Get automated edits for the given user using the given tool. - * @codeCoverageIgnore - */ - #[Route( - "/autoedits-contributions/{project}/{username}/{namespace}/{start}/{end}/{offset}", - name: "AutoEditsContributionsResult", - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - "namespace" => "|all|\d+", - "start" => "|\d{4}-\d{2}-\d{2}", - "end" => "|\d{4}-\d{2}-\d{2}", - "offset" => "|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?", - ], - defaults: ["namespace" => 0, "start" => false, "end" => false, "offset" => false] - )] - public function automatedEditsAction(AutoEditsRepository $autoEditsRepo, EditRepository $editRepo): Response - { - $this->setupAutoEdits($autoEditsRepo, $editRepo); - - return $this->getFormattedResponse('autoEdits/automated_edits', $this->output); - } - - /************************ API endpoints ************************/ - - /** - * Get a list of the known automated tools for a project along with their regex/tags/etc. - * @OA\Tag(name="Project API") - * @OA\ExternalDocumentation(url="https://www.mediawiki.org/wiki/XTools/API/Project#Automated_tools") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Response( - * response=200, - * description="List of known (semi-)automated tools.", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="tools", type="object", example={ - * "My tool": { - * "regex": "\\(using My tool", - * "link": "Project:My tool", - * "label": "MyTool", - * "namespaces": {0, 2, 4}, - * "tags": {"mytool"} - * } - * }), - * @OA\Property(property="elapsed_time", ref="#/components/schemas/elapsed_time") - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @codeCoverageIgnore - */ - #[Route("/api/project/automated_tools/{project}", name: "ProjectApiAutoEditsTools", methods: ["GET"])] - public function automatedToolsApiAction(AutoEditsRepository $autoEditsRepo): JsonResponse - { - $this->recordApiUsage('user/automated_tools'); - return $this->getFormattedApiResponse( - ['tools' => $autoEditsRepo->getTools($this->project)], - ); - } - - /** - * Get the number of automated edits a user has made. - * @OA\Tag(name="User API") - * @OA\Get(description="Get the number of edits a user has made using - [known semi-automated tools](https://w.wiki/6oKQ), and optionally how many times each tool was used.") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Parameter(ref="#/components/parameters/UsernameOrIp") - * @OA\Parameter(ref="#/components/parameters/Namespace") - * @OA\Parameter(ref="#/components/parameters/Start") - * @OA\Parameter(ref="#/components/parameters/End") - * @OA\Parameter(ref="#/components/parameters/Tools") - * @OA\Response( - * response=200, - * description="Count of edits made using [known (semi-)automated tools](https://w.wiki/6oKQ).", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="username", ref="#/components/parameters/Username/schema"), - * @OA\Property(property="namespace", ref="#/components/schemas/Namespace"), - * @OA\Property(property="start", ref="#/components/parameters/Start/schema"), - * @OA\Property(property="end", ref="#/components/parameters/End/schema"), - * @OA\Property(property="tools", ref="#/components/parameters/Tools/schema"), - * @OA\Property(property="total_editcount", type="integer"), - * @OA\Property(property="automated_editcount", type="integer"), - * @OA\Property(property="nonautomated_editcount", type="integer"), - * @OA\Property(property="automated_tools", ref="#/components/schemas/AutomatedTools"), - * @OA\Property(property="elapsed_time", type="float") - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=501, ref="#/components/responses/501") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - * @codeCoverageIgnore - */ - #[Route( - "/api/user/automated_editcount/{project}/{username}/{namespace}/{start}/{end}/{tools}", - name: "UserApiAutoEditsCount", - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - "namespace" => "|all|\d+", - "start" => "|\d{4}-\d{2}-\d{2}", - "end" => "|\d{4}-\d{2}-\d{2}", - ], - defaults: ["namespace" => "all", "start" => false, "end" => false, "tools" => false], - methods: ["GET"] - )] - public function automatedEditCountApiAction( - AutoEditsRepository $autoEditsRepo, - EditRepository $editRepo - ): JsonResponse { - $this->recordApiUsage('user/automated_editcount'); - - $this->setupAutoEdits($autoEditsRepo, $editRepo); - - $ret = [ - 'total_editcount' => $this->autoEdits->getEditCount(), - 'automated_editcount' => $this->autoEdits->getAutomatedCount(), - ]; - $ret['nonautomated_editcount'] = $ret['total_editcount'] - $ret['automated_editcount']; - - if ($this->getBoolVal('tools')) { - $tools = $this->autoEdits->getToolCounts(); - $ret['automated_tools'] = $tools; - } - - return $this->getFormattedApiResponse($ret); - } - - /** - * Get non-automated contributions for a user. - * @OA\Tag(name="User API") - * @OA\Get(description="Get a list of contributions a user has made without the use of any - [known (semi-)automated tools](https://w.wiki/6oKQ). If more results are available than the `limit`, a - `continue` property is returned with the value that can passed as the `offset` in another API request - to paginate through the results.") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Parameter(ref="#/components/parameters/UsernameOrIp") - * @OA\Parameter(ref="#/components/parameters/Namespace") - * @OA\Parameter(ref="#/components/parameters/Start") - * @OA\Parameter(ref="#/components/parameters/End") - * @OA\Parameter(ref="#/components/parameters/Offset") - * @OA\Parameter(ref="#/components/parameters/LimitQuery") - * @OA\Response( - * response=200, - * description="List of contributions made without [known (semi-)automated tools](https://w.wiki/6oKQ).", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="username", ref="#/components/parameters/UsernameOrIp/schema"), - * @OA\Property(property="namespace", ref="#/components/schemas/Namespace"), - * @OA\Property(property="start", ref="#/components/parameters/Start/schema"), - * @OA\Property(property="end", ref="#/components/parameters/End/schema"), - * @OA\Property(property="offset", ref="#/components/parameters/Offset/schema"), - * @OA\Property(property="limit", ref="#/components/parameters/Limit/schema"), - * @OA\Property(property="nonautomated_edits", type="array", @OA\Items(ref="#/components/schemas/Edit")), - * @OA\Property(property="continue", type="date-time", example="2020-01-31T12:59:59Z"), - * @OA\Property(property="elapsed_time", ref="#/components/schemas/elapsed_time") - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=501, ref="#/components/responses/501") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - * @codeCoverageIgnore - */ - #[Route( - "/api/user/nonautomated_edits/{project}/{username}/{namespace}/{start}/{end}/{offset}", - name: "UserApiNonAutoEdits", - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - "namespace" => "|all|\d+", - "start" => "|\d{4}-\d{2}-\d{2}", - "end" => "|\d{4}-\d{2}-\d{2}", - "offset" => "|\d{4}-?\d{2}-?\d{2}T?\d{2}:?\d{2}:?\d{2}Z?", - ], - defaults: ["namespace" => 0, "start" => false, "end" => false, "offset" => false, "limit" => 50], - methods: ["GET"] - )] - public function nonAutomatedEditsApiAction( - AutoEditsRepository $autoEditsRepo, - EditRepository $editRepo - ): JsonResponse { - $this->recordApiUsage('user/nonautomated_edits'); - - $this->setupAutoEdits($autoEditsRepo, $editRepo); - - $results = $this->autoEdits->getNonAutomatedEdits(true); - $out = $this->addFullPageTitlesAndContinue('nonautomated_edits', [], $results); - if (count($results) === $this->limit) { - $out['continue'] = (new DateTime(end($results)['timestamp']))->format('Y-m-d\TH:i:s\Z'); - } - - return $this->getFormattedApiResponse($out); - } - - /** - * Get (semi-)automated contributions made by a user. - * @OA\Tag(name="User API") - * @OA\Get(description="Get a list of contributions a user has made using of any of the - [known (semi-)automated tools](https://w.wiki/6oKQ). If more results are available than the `limit`, a - `continue` property is returned with the value that can passed as the `offset` in another API request - to paginate through the results.") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Parameter(ref="#/components/parameters/UsernameOrIp") - * @OA\Parameter(ref="#/components/parameters/Namespace") - * @OA\Parameter(ref="#/components/parameters/Start") - * @OA\Parameter(ref="#/components/parameters/End") - * @OA\Parameter(ref="#/components/parameters/Offset") - * @OA\Parameter(ref="#/components/parameters/LimitQuery") - * @OA\Parameter(name="tool", in="query", description="Get only contributions using this tool. - Use the [automated tools](#/Project%20API/get_ProjectApiAutoEditsTools) endpoint to list available tools.") - * @OA\Response( - * response=200, - * description="List of contributions made using [known (semi-)automated tools](https://w.wiki/6oKQ).", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="username", ref="#/components/parameters/UsernameOrIp/schema"), - * @OA\Property(property="namespace", ref="#/components/schemas/Namespace"), - * @OA\Property(property="start", ref="#/components/parameters/Start/schema"), - * @OA\Property(property="end", ref="#/components/parameters/End/schema"), - * @OA\Property(property="offset", ref="#/components/parameters/Offset/schema"), - * @OA\Property(property="limit", ref="#/components/parameters/Limit/schema"), - * @OA\Property(property="tool", type="string", example="Twinkle"), - * @OA\Property(property="automated_edits", type="array", @OA\Items(ref="#/components/schemas/Edit")), - * @OA\Property(property="continue", type="date-time", example="2020-01-31T12:59:59Z"), - * @OA\Property(property="elapsed_time", ref="#/components/schemas/elapsed_time") - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=501, ref="#/components/responses/501") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - * @codeCoverageIgnore - */ - #[Route( - "/api/user/automated_edits/{project}/{username}/{namespace}/{start}/{end}/{offset}", - name: "UserApiAutoEdits", - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - "namespace" => "|all|\d+", - "start" => "|\d{4}-\d{2}-\d{2}", - "end" => "|\d{4}-\d{2}-\d{2}", - "offset" => "|\d{4}-?\d{2}-?\d{2}T?\d{2}:?\d{2}:?\d{2}Z?", - ], - defaults: ["namespace" => 0, "start" => false, "end" => false, "offset" => false, "limit" => 50], - methods: ["GET"] - )] - public function automatedEditsApiAction(AutoEditsRepository $autoEditsRepo, EditRepository $editRepo): JsonResponse - { - $this->recordApiUsage('user/automated_edits'); - - $this->setupAutoEdits($autoEditsRepo, $editRepo); - - $extras = $this->autoEdits->getTool() - ? ['tool' => $this->autoEdits->getTool()] - : []; - - $results = $this->autoEdits->getAutomatedEdits(true); - $out = $this->addFullPageTitlesAndContinue('automated_edits', $extras, $results); - if (count($results) === $this->limit) { - $out['continue'] = (new DateTime(end($results)['timestamp']))->format('Y-m-d\TH:i:s\Z'); - } - - return $this->getFormattedApiResponse($out); - } +class AutomatedEditsController extends XtoolsController { + protected AutoEdits $autoEdits; + + /** @var array Data that is passed to the view. */ + private array $output; + + /** + * @inheritDoc + * @codeCoverageIgnore + */ + public function getIndexRoute(): string { + return 'AutoEdits'; + } + + /** + * This causes the tool to redirect back to the index page, with an error, + * if the user has too high of an edit count. + * @inheritDoc + * @codeCoverageIgnore + */ + public function tooHighEditCountRoute(): string { + return $this->getIndexRoute(); + } + + /** + * Display the search form. + */ + #[Route( "/autoedits", name: "AutoEdits" )] + #[Route( "/automatededits", name: "AutoEditsLong" )] + #[Route( "/autoedits/index.php", name: "AutoEditsIndexPhp" )] + #[Route( "/automatededits/index.php", name: "AutoEditsLongIndexPhp" )] + #[Route( "/autoedits/{project}", name: "AutoEditsProject" )] + public function indexAction(): Response { + // Redirect if at minimum project and username are provided. + if ( isset( $this->params['project'] ) && isset( $this->params['username'] ) ) { + // If 'tool' param is given, redirect to corresponding action. + $tool = $this->request->query->get( 'tool' ); + + if ( $tool === 'all' ) { + unset( $this->params['tool'] ); + return $this->redirectToRoute( 'AutoEditsContributionsResult', $this->params ); + } elseif ( $tool != '' && $tool !== 'none' ) { + $this->params['tool'] = $tool; + return $this->redirectToRoute( 'AutoEditsContributionsResult', $this->params ); + } elseif ( $tool === 'none' ) { + unset( $this->params['tool'] ); + } + + // Otherwise redirect to the normal result action. + return $this->redirectToRoute( 'AutoEditsResult', $this->params ); + } + + return $this->render( 'autoEdits/index.html.twig', array_merge( [ + 'xtPageTitle' => 'tool-autoedits', + 'xtSubtitle' => 'tool-autoedits-desc', + 'xtPage' => 'AutoEdits', + + // Defaults that will get overridden if in $this->params. + 'username' => '', + 'namespace' => 0, + 'start' => '', + 'end' => '', + ], $this->params, [ 'project' => $this->project ] ) ); + } + + /** + * Set defaults, and instantiate the AutoEdits model. This is called at the top of every view action. + * @codeCoverageIgnore + */ + private function setupAutoEdits( AutoEditsRepository $autoEditsRepo, EditRepository $editRepo ): void { + $tool = $this->request->query->get( 'tool', null ); + $useSandbox = (bool)$this->request->query->get( 'usesandbox', false ); + + if ( $useSandbox && !$this->request->getSession()->get( 'logged_in_user' ) ) { + $this->addFlashMessage( 'danger', 'auto-edits-logged-out' ); + $useSandbox = false; + } + $autoEditsRepo->setUseSandbox( $useSandbox ); + + $misconfigured = $autoEditsRepo->getInvalidTools( $this->project ); + $helpLink = "https://w.wiki/ppr"; + foreach ( $misconfigured as $tool ) { + $this->addFlashMessage( 'warning', 'auto-edits-misconfiguration', [ $tool, $helpLink ] ); + } + + // Validate tool. + // FIXME: instead of redirecting to index page, show result page listing all tools for that project, + // clickable to show edits by the user, etc. + if ( $tool && !isset( $autoEditsRepo->getTools( $this->project )[$tool] ) ) { + $this->throwXtoolsException( + $this->getIndexRoute(), + 'auto-edits-unknown-tool', + [ $tool ], + 'tool' + ); + } + + $this->autoEdits = new AutoEdits( + $autoEditsRepo, + $editRepo, + $this->pageRepo, + $this->userRepo, + $this->project, + $this->user, + $this->namespace, + $this->start, + $this->end, + $tool, + $this->offset, + $this->limit + ); + + $this->output = [ + 'xtPage' => 'AutoEdits', + 'xtTitle' => $this->user->getUsername(), + 'ae' => $this->autoEdits, + 'is_sub_request' => $this->isSubRequest, + ]; + + if ( $useSandbox ) { + $this->output['usesandbox'] = 1; + } + } + + #[Route( + "/autoedits/{project}/{username}/{namespace}/{start}/{end}/{offset}", + name: "AutoEditsResult", + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + "namespace" => "|all|\d+", + "start" => "|\d{4}-\d{2}-\d{2}", + "end" => "|\d{4}-\d{2}-\d{2}", + "offset" => "|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?", + ], + defaults: [ "namespace" => 0, "start" => false, "end" => false, "offset" => false ] + )] + /** + * Display the results. + * @codeCoverageIgnore + */ + public function resultAction( AutoEditsRepository $autoEditsRepo, EditRepository $editRepo ): Response { + // Will redirect back to index if the user has too high of an edit count. + $this->setupAutoEdits( $autoEditsRepo, $editRepo ); + + if ( in_array( 'bot', $this->user->getUserRights( $this->project ) ) ) { + $this->addFlashMessage( 'warning', 'auto-edits-bot' ); + } + + return $this->getFormattedResponse( 'autoEdits/result', $this->output ); + } + + #[Route( + "/nonautoedits-contributions/{project}/{username}/{namespace}/{start}/{end}/{offset}", + name: "NonAutoEditsContributionsResult", + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + "namespace" => "|all|\d+", + "start" => "|\d{4}-\d{2}-\d{2}", + "end" => "|\d{4}-\d{2}-\d{2}", + "offset" => "|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?", + ], + defaults: [ "namespace" => 0, "start" => false, "end" => false, "offset" => false ] + )] + /** + * Get non-automated edits for the given user. + * @codeCoverageIgnore + */ + public function nonAutomatedEditsAction( AutoEditsRepository $autoEditsRepo, EditRepository $editRepo ): Response { + $this->setupAutoEdits( $autoEditsRepo, $editRepo ); + return $this->getFormattedResponse( 'autoEdits/nonautomated_edits', $this->output ); + } + + #[Route( + "/autoedits-contributions/{project}/{username}/{namespace}/{start}/{end}/{offset}", + name: "AutoEditsContributionsResult", + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + "namespace" => "|all|\d+", + "start" => "|\d{4}-\d{2}-\d{2}", + "end" => "|\d{4}-\d{2}-\d{2}", + "offset" => "|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?", + ], + defaults: [ "namespace" => 0, "start" => false, "end" => false, "offset" => false ] + )] + /** + * Get automated edits for the given user using the given tool. + * @codeCoverageIgnore + */ + public function automatedEditsAction( AutoEditsRepository $autoEditsRepo, EditRepository $editRepo ): Response { + $this->setupAutoEdits( $autoEditsRepo, $editRepo ); + + return $this->getFormattedResponse( 'autoEdits/automated_edits', $this->output ); + } + + /************************ API endpoints */ + + #[OA\Tag( name: "Project API" )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Response( + response: 200, + description: "List of known (semi-)automated tools.", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "tools", type: "object", example: [ + "My tool" => [ + "regex" => "\\(using My tool", + "link" => "Project:My tool", + "label" => "MyTool", + "namespaces" => [ 0, 2, 4 ], + "tags" => [ "mytool" ], + ], + ] ), + new OA\Property( property: "elapsed_time", ref: "#/components/schemas/elapsed_time" ), + ] + ) + ) ] + #[OA\Response( response: 404, ref: "#/components/responses/404" )] + #[Route( "/api/project/automated_tools/{project}", name: "ProjectApiAutoEditsTools", methods: [ "GET" ] )] + /** + * Get a list of the known automated tools for a project along with their regex/tags/etc. + * @codeCoverageIgnore + */ + public function automatedToolsApiAction( AutoEditsRepository $autoEditsRepo ): JsonResponse { + $this->recordApiUsage( 'user/automated_tools' ); + return $this->getFormattedApiResponse( + [ 'tools' => $autoEditsRepo->getTools( $this->project ) ], + ); + } + + #[OA\Tag( name: "User API" )] + #[OA\Get( description: + "Get the number of edits a user has made using [known semi-automated tools](https://w.wiki/6oKQ), " . + "and optionally how many times each tool was used." + )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Parameter( ref: "#/components/parameters/UsernameOrIp" )] + #[OA\Parameter( ref: "#/components/parameters/Namespace" )] + #[OA\Parameter( ref: "#/components/parameters/Start" )] + #[OA\Parameter( ref: "#/components/parameters/End" )] + #[OA\Parameter( ref: "#/components/parameters/Tools" )] + #[OA\Response( + response: 200, + description: "Count of edits made using [known (semi-)automated tools](https://w.wiki/6oKQ).", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "username", ref: "#/components/parameters/Username/schema" ), + new OA\Property( property: "namespace", ref: "#/components/schemas/Namespace" ), + new OA\Property( property: "start", ref: "#/components/parameters/Start/schema" ), + new OA\Property( property: "end", ref: "#/components/parameters/End/schema" ), + new OA\Property( property: "tools", ref: "#/components/parameters/Tools/schema" ), + new OA\Property( property: "total_editcount", type: "integer" ), + new OA\Property( property: "automated_editcount", type: "integer" ), + new OA\Property( property: "nonautomated_editcount", type: "integer" ), + new OA\Property( property: "automated_tools", ref: "#/components/schemas/AutomatedTools" ), + new OA\Property( property: "elapsed_time", type: "float" ), + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[OA\Response( ref: "#/components/responses/501", response: 501 )] + #[OA\Response( ref: "#/components/responses/503", response: 503 )] + #[OA\Response( ref: "#/components/responses/504", response: 504 )] + #[Route( + "/api/user/automated_editcount/{project}/{username}/{namespace}/{start}/{end}/{tools}", + name: "UserApiAutoEditsCount", + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + "namespace" => "|all|\d+", + "start" => "|\d{4}-\d{2}-\d{2}", + "end" => "|\d{4}-\d{2}-\d{2}", + ], + defaults: [ "namespace" => "all", "start" => false, "end" => false, "tools" => false ], + methods: [ "GET" ] + )] + /** + * Get the number of automated edits a user has made. + * @codeCoverageIgnore + */ + public function automatedEditCountApiAction( + AutoEditsRepository $autoEditsRepo, + EditRepository $editRepo + ): JsonResponse { + $this->recordApiUsage( 'user/automated_editcount' ); + + $this->setupAutoEdits( $autoEditsRepo, $editRepo ); + + $ret = [ + 'total_editcount' => $this->autoEdits->getEditCount(), + 'automated_editcount' => $this->autoEdits->getAutomatedCount(), + ]; + $ret['nonautomated_editcount'] = $ret['total_editcount'] - $ret['automated_editcount']; + + if ( $this->getBoolVal( 'tools' ) ) { + $tools = $this->autoEdits->getToolCounts(); + $ret['automated_tools'] = $tools; + } + + return $this->getFormattedApiResponse( $ret ); + } + + #[OA\Tag( name: "User API" )] + #[OA\Get( description: "Get a list of contributions a user has made without the use of any " . + "[known (semi-)automated tools](https://w.wiki/6oKQ). If more results are available than the `limit`, " . + "a `continue` property is returned with the value that can passed as the `offset` in another " . + "API request to paginate through the results." )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Parameter( ref: "#/components/parameters/UsernameOrIp" )] + #[OA\Parameter( ref: "#/components/parameters/Namespace" )] + #[OA\Parameter( ref: "#/components/parameters/Start" )] + #[OA\Parameter( ref: "#/components/parameters/End" )] + #[OA\Parameter( ref: "#/components/parameters/Offset" )] + #[OA\Parameter( ref: "#/components/parameters/LimitQuery" )] + #[OA\Response( + response: 200, + description: "List of contributions made without [known (semi-)automated tools](https://w.wiki/6oKQ).", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "username", ref: "#/components/parameters/UsernameOrIp/schema" ), + new OA\Property( property: "namespace", ref: "#/components/schemas/Namespace" ), + new OA\Property( property: "start", ref: "#/components/parameters/Start/schema" ), + new OA\Property( property: "end", ref: "#/components/parameters/End/schema" ), + new OA\Property( property: "offset", ref: "#/components/parameters/Offset/schema" ), + new OA\Property( property: "limit", ref: "#/components/parameters/Limit/schema" ), + new OA\Property( + property: "nonautomated_edits", + type: "array", + items: new OA\Items( ref: "#/components/schemas/Edit" ) + ), + new OA\Property( property: "continue", type: "date-time", example: "2020-01-31T12:59:59Z" ), + new OA\Property( property: "elapsed_time", ref: "#/components/schemas/elapsed_time" ), + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[OA\Response( ref: "#/components/responses/501", response: 501 )] + #[OA\Response( ref: "#/components/responses/503", response: 503 )] + #[OA\Response( ref: "#/components/responses/504", response: 504 )] + #[Route( + "/api/user/nonautomated_edits/{project}/{username}/{namespace}/{start}/{end}/{offset}", + name: "UserApiNonAutoEdits", + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + "namespace" => "|all|\d+", + "start" => "|\d{4}-\d{2}-\d{2}", + "end" => "|\d{4}-\d{2}-\d{2}", + "offset" => "|\d{4}-?\d{2}-?\d{2}T?\d{2}:?\d{2}:?\d{2}Z?", + ], + defaults: [ "namespace" => 0, "start" => false, "end" => false, "offset" => false, "limit" => 50 ], + methods: [ "GET" ] + )] + /** + * Get non-automated edits for the given user. + * @codeCoverageIgnore + */ + public function nonAutomatedEditsApiAction( + AutoEditsRepository $autoEditsRepo, + EditRepository $editRepo + ): JsonResponse { + $this->recordApiUsage( 'user/nonautomated_edits' ); + + $this->setupAutoEdits( $autoEditsRepo, $editRepo ); + + $results = $this->autoEdits->getNonAutomatedEdits( true ); + $out = $this->addFullPageTitlesAndContinue( 'nonautomated_edits', [], $results ); + if ( count( $results ) === $this->limit ) { + $out['continue'] = ( new DateTime( end( $results )['timestamp'] ) )->format( 'Y-m-d\TH:i:s\Z' ); + } + + return $this->getFormattedApiResponse( $out ); + } + + #[OA\Tag( name: "User API" )] + #[OA\Get( description: + "Get a list of contributions a user has made using of any of the known (semi-)automated tools " . + "(https://w.wiki/6oKQ). If more results are available than the `limit`, a `continue` property is returned " . + "with the value that can passed as the `offset` in another API request to paginate through the results." + )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Parameter( ref: "#/components/parameters/UsernameOrIp" )] + #[OA\Parameter( ref: "#/components/parameters/Namespace" )] + #[OA\Parameter( ref: "#/components/parameters/Start" )] + #[OA\Parameter( ref: "#/components/parameters/End" )] + #[OA\Parameter( ref: "#/components/parameters/Offset" )] + #[OA\Parameter( ref: "#/components/parameters/LimitQuery" )] + #[OA\Parameter( + name: "tool", + description: "Get only contributions using this tool. " . + "Use the automated tools endpoint to list available tools.", + in: "query" + )] + #[OA\Response( + response: 200, + description: "List of contributions made using [known (semi-)automated tools](https://w.wiki/6oKQ).", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "username", ref: "#/components/parameters/UsernameOrIp/schema" ), + new OA\Property( property: "namespace", ref: "#/components/schemas/Namespace" ), + new OA\Property( property: "start", ref: "#/components/parameters/Start/schema" ), + new OA\Property( property: "end", ref: "#/components/parameters/End/schema" ), + new OA\Property( property: "offset", ref: "#/components/parameters/Offset/schema" ), + new OA\Property( property: "limit", ref: "#/components/parameters/Limit/schema" ), + new OA\Property( property: "tool", type: "string", example: "Twinkle" ), + new OA\Property( + property: "automated_edits", + type: "array", + items: new OA\Items( ref: "#/components/schemas/Edit" ) + ), + new OA\Property( property: "continue", type: "date-time", example: "2020-01-31T12:59:59Z" ), + new OA\Property( property: "elapsed_time", ref: "#/components/schemas/elapsed_time" ), + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[OA\Response( ref: "#/components/responses/501", response: 501 )] + #[OA\Response( ref: "#/components/responses/503", response: 503 )] + #[OA\Response( ref: "#/components/responses/504", response: 504 )] + #[Route( + "/api/user/automated_edits/{project}/{username}/{namespace}/{start}/{end}/{offset}", + name: "UserApiAutoEdits", + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + "namespace" => "|all|\d+", + "start" => "|\d{4}-\d{2}-\d{2}", + "end" => "|\d{4}-\d{2}-\d{2}", + "offset" => "|\d{4}-?\d{2}-?\d{2}T?\d{2}:?\d{2}:?\d{2}Z?", + ], + defaults: [ "namespace" => 0, "start" => false, "end" => false, "offset" => false, "limit" => 50 ], + methods: [ "GET" ] + )] + /** + * Get (semi-)automated contributions made by a user. + * @codeCoverageIgnore + */ + public function automatedEditsApiAction( + AutoEditsRepository $autoEditsRepo, + EditRepository $editRepo + ): JsonResponse { + $this->recordApiUsage( 'user/automated_edits' ); + + $this->setupAutoEdits( $autoEditsRepo, $editRepo ); + + $extras = $this->autoEdits->getTool() + ? [ 'tool' => $this->autoEdits->getTool() ] + : []; + + $results = $this->autoEdits->getAutomatedEdits( true ); + $out = $this->addFullPageTitlesAndContinue( 'automated_edits', $extras, $results ); + if ( count( $results ) === $this->limit ) { + $out['continue'] = ( new DateTime( end( $results )['timestamp'] ) )->format( 'Y-m-d\TH:i:s\Z' ); + } + + return $this->getFormattedApiResponse( $out ); + } } diff --git a/src/Controller/BlameController.php b/src/Controller/BlameController.php index 418ccc93f..846f1fff5 100644 --- a/src/Controller/BlameController.php +++ b/src/Controller/BlameController.php @@ -1,6 +1,6 @@ params['target'] = $this->request->query->get('target', ''); + #[Route( "/blame", name: "Blame" )] + #[Route( "/blame/{project}", name: "BlameProject" )] + /** + * The search form. + */ + public function indexAction(): Response { + $this->params['target'] = $this->request->query->get( 'target', '' ); - if (isset($this->params['project']) && isset($this->params['page']) && isset($this->params['q'])) { - return $this->redirectToRoute('BlameResult', $this->params); - } + if ( isset( $this->params['project'] ) && isset( $this->params['page'] ) && isset( $this->params['q'] ) ) { + return $this->redirectToRoute( 'BlameResult', $this->params ); + } - if (preg_match('/\d{4}-\d{2}-\d{2}/', $this->params['target'])) { - $show = 'date'; - } elseif (is_numeric($this->params['target'])) { - $show = 'id'; - } else { - $show = 'latest'; - } + if ( preg_match( '/\d{4}-\d{2}-\d{2}/', $this->params['target'] ) ) { + $show = 'date'; + } elseif ( is_numeric( $this->params['target'] ) ) { + $show = 'id'; + } else { + $show = 'latest'; + } - return $this->render('blame/index.html.twig', array_merge([ - 'xtPage' => 'Blame', - 'xtPageTitle' => 'tool-blame', - 'xtSubtitle' => 'tool-blame-desc', + return $this->render( 'blame/index.html.twig', array_merge( [ + 'xtPage' => 'Blame', + 'xtPageTitle' => 'tool-blame', + 'xtSubtitle' => 'tool-blame-desc', - // Defaults that will get overridden if in $params. - 'page' => '', - 'supportedProjects' => Authorship::SUPPORTED_PROJECTS, - ], $this->params, [ - 'project' => $this->project, - 'show' => $show, - 'target' => '', - ])); - } + // Defaults that will get overridden if in $params. + 'page' => '', + 'supportedProjects' => Authorship::SUPPORTED_PROJECTS, + ], $this->params, [ + 'project' => $this->project, + 'show' => $show, + 'target' => '', + ] ) ); + } - /** - * The results page. - */ - #[Route( - "/blame/{project}/{page}/{target}", - name: "BlameResult", - requirements: [ - "page" => "(.+?)", - "target" => "|latest|\d+|\d{4}-\d{2}-\d{2}", - ], - defaults: ["target" => "latest"] - )] - public function resultAction(string $target, BlameRepository $blameRepo): Response - { - if (!isset($this->params['q'])) { - return $this->redirectToRoute('BlameProject', [ - 'project' => $this->project->getDomain(), - ]); - } - if (0 !== $this->page->getNamespace()) { - $this->addFlashMessage('danger', 'error-authorship-non-mainspace'); - return $this->redirectToRoute('BlameProject', [ - 'project' => $this->project->getDomain(), - ]); - } + #[Route( + "/blame/{project}/{page}/{target}", + name: "BlameResult", + requirements: [ + "page" => "(.+?)", + "target" => "|latest|\d+|\d{4}-\d{2}-\d{2}", + ], + defaults: [ "target" => "latest" ] + )] + /** + * The results page. + */ + public function resultAction( string $target, BlameRepository $blameRepo ): Response { + if ( !isset( $this->params['q'] ) ) { + return $this->redirectToRoute( 'BlameProject', [ + 'project' => $this->project->getDomain(), + ] ); + } + if ( $this->page->getNamespace() !== 0 ) { + $this->addFlashMessage( 'danger', 'error-authorship-non-mainspace' ); + return $this->redirectToRoute( 'BlameProject', [ + 'project' => $this->project->getDomain(), + ] ); + } - // This action sometimes requires more memory. 256M should be safe. - ini_set('memory_limit', '256M'); + // This action sometimes requires more memory. 256M should be safe. + ini_set( 'memory_limit', '256M' ); - $blame = new Blame($blameRepo, $this->page, $this->params['q'], $target); - $blame->setRepository($blameRepo); - $blame->prepareData(); + $blame = new Blame( $blameRepo, $this->page, $this->params['q'], $target ); + $blame->setRepository( $blameRepo ); + $blame->prepareData(); - return $this->getFormattedResponse('blame/blame', [ - 'xtPage' => 'Blame', - 'xtTitle' => $this->page->getTitle(), - 'blame' => $blame, - ]); - } + return $this->getFormattedResponse( 'blame/blame', [ + 'xtPage' => 'Blame', + 'xtTitle' => $this->page->getTitle(), + 'blame' => $blame, + ] ); + } } diff --git a/src/Controller/CategoryEditsController.php b/src/Controller/CategoryEditsController.php index f3aa003e4..7ca813641 100644 --- a/src/Controller/CategoryEditsController.php +++ b/src/Controller/CategoryEditsController.php @@ -1,13 +1,13 @@ getIndexRoute(); - } - - /** - * Display the search form. - * @codeCoverageIgnore - */ - #[Route(path: '/categoryedits', name: 'CategoryEdits')] - #[Route(path: '/categoryedits/{project}', name: 'CategoryEditsProject')] - public function indexAction(): Response - { - // Redirect if at minimum project, username and categories are provided. - if (isset($this->params['project']) && isset($this->params['username']) && isset($this->params['categories'])) { - return $this->redirectToRoute('CategoryEditsResult', $this->params); - } - - return $this->render('categoryEdits/index.html.twig', array_merge([ - 'xtPageTitle' => 'tool-categoryedits', - 'xtSubtitle' => 'tool-categoryedits-desc', - 'xtPage' => 'CategoryEdits', - - // Defaults that will get overridden if in $params. - 'namespace' => 0, - 'start' => '', - 'end' => '', - 'username' => '', - 'categories' => '', - ], $this->params, ['project' => $this->project])); - } - - /** - * Set defaults, and instantiate the CategoryEdits model. This is called at the top of every view action. - * @codeCoverageIgnore - */ - private function setupCategoryEdits(CategoryEditsRepository $categoryEditsRepo): void - { - $this->extractCategories(); - - $this->categoryEdits = new CategoryEdits( - $categoryEditsRepo, - $this->project, - $this->user, - $this->categories, - $this->start, - $this->end, - $this->offset - ); - - $this->output = [ - 'xtPage' => 'CategoryEdits', - 'xtTitle' => $this->user->getUsername(), - 'project' => $this->project, - 'user' => $this->user, - 'ce' => $this->categoryEdits, - 'is_sub_request' => $this->isSubRequest, - ]; - } - - /** - * Go through the categories and normalize values, and set them on class properties. - * @codeCoverageIgnore - */ - private function extractCategories(): void - { - // Split categories by pipe. - $categories = explode('|', $this->request->get('categories')); - - // Loop through the given categories, stripping out the namespace. - // If a namespace was removed, it is flagged it as normalize - // We look for the wiki's category namespace name, and the MediaWiki default - // 'Category:', which sometimes is used cross-wiki (because it still works). - $normalized = false; - $nsName = $this->project->getNamespaces()[14].':'; - $this->categories = array_map(function ($category) use ($nsName, &$normalized) { - if (0 === strpos($category, $nsName) || 0 === strpos($category, 'Category:')) { - $normalized = true; - } - return preg_replace('/^'.$nsName.'/', '', $category); - }, $categories); - - // Redirect if normalized, since we don't want the Category: prefix in the URL. - if ($normalized) { - throw new XtoolsHttpException( - '', - $this->generateUrl($this->request->get('_route'), array_merge( - $this->request->attributes->get('_route_params'), - ['categories' => implode('|', $this->categories)] - )) - ); - } - } - - /** - * Display the results. - * @codeCoverageIgnore - */ - #[Route( - "/categoryedits/{project}/{username}/{categories}/{start}/{end}/{offset}", - name: "CategoryEditsResult", - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - "categories" => "(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?)?$", - "start" => "|\d{4}-\d{2}-\d{2}", - "end" => "|\d{4}-\d{2}-\d{2}", - "offset" => "|\d{4}-?\d{2}-?\d{2}T?\d{2}:?\d{2}:?\d{2}Z?", - ], - defaults: ["start" => false, "end" => false, "offset" => false] - )] - public function resultAction(CategoryEditsRepository $categoryEditsRepo): Response - { - $this->setupCategoryEdits($categoryEditsRepo); - - return $this->getFormattedResponse('categoryEdits/result', $this->output); - } - - /** - * Get edits by a user to pages in given categories. - * @codeCoverageIgnore - */ - #[Route( - "/categoryedits-contributions/{project}/{username}/{categories}/{start}/{end}/{offset}", - name: "CategoryContributionsResult", - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - "categories" => "(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2}))?", - "start" => "|\d{4}-\d{2}-\d{2}", - "end" => "|\d{4}-\d{2}-\d{2}", - "offset" => "|\d{4}-?\d{2}-?\d{2}T?\d{2}:?\d{2}:?\d{2}Z?", - ], - defaults: ["start" => false, "end" => false, "offset" => false] - )] - public function categoryContributionsAction(CategoryEditsRepository $categoryEditsRepo): Response - { - $this->setupCategoryEdits($categoryEditsRepo); - - return $this->render('categoryEdits/contributions.html.twig', $this->output); - } - - /************************ API endpoints ************************/ - - /** - * Count the number of edits a user has made in a category. - * @OA\Tag(name="User API") - * @OA\Get(description="Count the number of edits a user has made to pages in - any of the given [categories](https://w.wiki/6oKx).") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Parameter(ref="#/components/parameters/UsernameOrIp") - * @OA\Parameter( - * name="categories", - * in="path", - * description="Pipe-separated list of category names, without the namespace prefix.", - * style="pipeDelimited", - * @OA\Schema(type="array", @OA\Items(type="string"), example={"Living people"}) - * ) - * @OA\Parameter(ref="#/components/parameters/Start") - * @OA\Parameter(ref="#/components/parameters/End") - * @OA\Response( - * response=200, - * description="Count of edits made to any of the given categories.", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="username", ref="#/components/parameters/UsernameOrIp/schema"), - * @OA\Property(property="categories", type="array", @OA\Items(type="string"), example={"Living people"}), - * @OA\Property(property="start", ref="#/components/parameters/Start/schema"), - * @OA\Property(property="end", ref="#/components/parameters/End/schema"), - * @OA\Property(property="total_editcount", type="integer"), - * @OA\Property(property="category_editcount", type="integer"), - * @OA\Property(property="elapsed_time", ref="#/components/schemas/elapsed_time") - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=501, ref="#/components/responses/501") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - * @codeCoverageIgnore - */ - #[Route( - "/api/user/category_editcount/{project}/{username}/{categories}/{start}/{end}", - name: "UserApiCategoryEditCount", - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - "categories" => "(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?)?$", - "start" => "|\d{4}-\d{2}-\d{2}", - "end" => "|\d{4}-\d{2}-\d{2}", - ], - defaults: ["start" => false, "end" => false], - methods: ["GET"] - )] - public function categoryEditCountApiAction(CategoryEditsRepository $categoryEditsRepo): JsonResponse - { - $this->recordApiUsage('user/category_editcount'); - - $this->setupCategoryEdits($categoryEditsRepo); - - $ret = [ - // Ensure `categories` is always treated as an array, even if one element. - // (XtoolsController would otherwise see it as a single value from the URL query string). - 'categories' => $this->categories, - 'total_editcount' => $this->categoryEdits->getEditCount(), - 'category_editcount' => $this->categoryEdits->getCategoryEditCount(), - ]; - - return $this->getFormattedApiResponse($ret); - } +class CategoryEditsController extends XtoolsController { + protected CategoryEdits $categoryEdits; + + /** @var string[] The categories, with or without namespace. */ + protected array $categories; + + /** @var array Data that is passed to the view. */ + private array $output; + + /** + * @inheritDoc + * @codeCoverageIgnore + */ + public function getIndexRoute(): string { + return 'CategoryEdits'; + } + + /** + * @inheritDoc + * @codeCoverageIgnore + */ + public function tooHighEditCountRoute(): string { + return $this->getIndexRoute(); + } + + #[Route( path: '/categoryedits', name: 'CategoryEdits' )] + #[Route( path: '/categoryedits/{project}', name: 'CategoryEditsProject' )] + /** + * Display the search form. + * @codeCoverageIgnore + */ + public function indexAction(): Response { + // Redirect if at minimum project, username and categories are provided. + if ( isset( $this->params['project'] ) + && isset( $this->params['username'] ) + && isset( $this->params['categories'] ) + ) { + return $this->redirectToRoute( 'CategoryEditsResult', $this->params ); + } + + return $this->render( 'categoryEdits/index.html.twig', array_merge( [ + 'xtPageTitle' => 'tool-categoryedits', + 'xtSubtitle' => 'tool-categoryedits-desc', + 'xtPage' => 'CategoryEdits', + + // Defaults that will get overridden if in $params. + 'namespace' => 0, + 'start' => '', + 'end' => '', + 'username' => '', + 'categories' => '', + ], $this->params, [ 'project' => $this->project ] ) ); + } + + /** + * Set defaults, and instantiate the CategoryEdits model. This is called at the top of every view action. + * @codeCoverageIgnore + */ + private function setupCategoryEdits( CategoryEditsRepository $categoryEditsRepo ): void { + $this->extractCategories(); + + $this->categoryEdits = new CategoryEdits( + $categoryEditsRepo, + $this->project, + $this->user, + $this->categories, + $this->start, + $this->end, + $this->offset + ); + + $this->output = [ + 'xtPage' => 'CategoryEdits', + 'xtTitle' => $this->user->getUsername(), + 'project' => $this->project, + 'user' => $this->user, + 'ce' => $this->categoryEdits, + 'is_sub_request' => $this->isSubRequest, + ]; + } + + /** + * Go through the categories and normalize values, and set them on class properties. + * @codeCoverageIgnore + */ + private function extractCategories(): void { + // Split categories by pipe. + $categories = explode( '|', $this->request->get( 'categories' ) ); + + // Loop through the given categories, stripping out the namespace. + // If a namespace was removed, it is flagged it as normalize + // We look for the wiki's category namespace name, and the MediaWiki default + // 'Category:', which sometimes is used cross-wiki (because it still works). + $normalized = false; + $nsName = $this->project->getNamespaces()[14] . ':'; + $this->categories = array_map( static function ( $category ) use ( $nsName, &$normalized ) { + if ( str_starts_with( $category, $nsName ) || str_starts_with( $category, 'Category:' ) ) { + $normalized = true; + } + return preg_replace( '/^' . $nsName . '/', '', $category ); + }, $categories ); + + // Redirect if normalized, since we don't want the Category: prefix in the URL. + if ( $normalized ) { + throw new XtoolsHttpException( + '', + $this->generateUrl( $this->request->get( '_route' ), array_merge( + $this->request->attributes->get( '_route_params' ), + [ 'categories' => implode( '|', $this->categories ) ] + ) ) + ); + } + } + + #[Route( + "/categoryedits/{project}/{username}/{categories}/{start}/{end}/{offset}", + name: "CategoryEditsResult", + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + "categories" => "(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?)?$", + "start" => "|\d{4}-\d{2}-\d{2}", + "end" => "|\d{4}-\d{2}-\d{2}", + "offset" => "|\d{4}-?\d{2}-?\d{2}T?\d{2}:?\d{2}:?\d{2}Z?", + ], + defaults: [ "start" => false, "end" => false, "offset" => false ] + )] + /** + * Display the results. + * @codeCoverageIgnore + */ + public function resultAction( CategoryEditsRepository $categoryEditsRepo ): Response { + $this->setupCategoryEdits( $categoryEditsRepo ); + + return $this->getFormattedResponse( 'categoryEdits/result', $this->output ); + } + + #[Route( + "/categoryedits-contributions/{project}/{username}/{categories}/{start}/{end}/{offset}", + name: "CategoryContributionsResult", + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + "categories" => "(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2}))?", + "start" => "|\d{4}-\d{2}-\d{2}", + "end" => "|\d{4}-\d{2}-\d{2}", + "offset" => "|\d{4}-?\d{2}-?\d{2}T?\d{2}:?\d{2}:?\d{2}Z?", + ], + defaults: [ "start" => false, "end" => false, "offset" => false ] + )] + /** + * Get edits by a user to pages in given categories. + * @codeCoverageIgnore + */ + public function categoryContributionsAction( CategoryEditsRepository $categoryEditsRepo ): Response { + $this->setupCategoryEdits( $categoryEditsRepo ); + + return $this->render( 'categoryEdits/contributions.html.twig', $this->output ); + } + + /************************ API endpoints */ + + #[OA\Tag( name: "User API" )] + #[OA\Get( description: + "Count the number of edits a user has made to pages in any of the given [categories](https://w.wiki/6oKx)." + )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Parameter( ref: "#/components/parameters/UsernameOrIp" )] + #[OA\Parameter( + name: "categories", + description: "Pipe-separated list of category names, without the namespace prefix.", + in: "path", + schema: new OA\Schema( type: "array", items: new OA\Items( type: "string" ), example: [ "Living people" ] ), + style: "pipeDelimited" + )] + #[OA\Parameter( ref: "#/components/parameters/Start" )] + #[OA\Parameter( ref: "#/components/parameters/End" )] + #[OA\Response( + response: 200, + description: "Count of edits made to any of the given categories.", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "username", ref: "#/components/parameters/UsernameOrIp/schema" ), + new OA\Property( + property: "categories", + type: "array", + items: new OA\Items( type: "string" ), + example: [ "Living people" ] + ), + new OA\Property( property: "start", ref: "#/components/parameters/Start/schema" ), + new OA\Property( property: "end", ref: "#/components/parameters/End/schema" ), + new OA\Property( property: "total_editcount", type: "integer" ), + new OA\Property( property: "category_editcount", type: "integer" ), + new OA\Property( property: "elapsed_time", ref: "#/components/schemas/elapsed_time" ), + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[OA\Response( ref: "#/components/responses/501", response: 501 )] + #[OA\Response( ref: "#/components/responses/503", response: 503 )] + #[OA\Response( ref: "#/components/responses/504", response: 504 )] + #[Route( + "/api/user/category_editcount/{project}/{username}/{categories}/{start}/{end}", + name: "UserApiCategoryEditCount", + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + "categories" => "(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?)?$", + "start" => "|\d{4}-\d{2}-\d{2}", + "end" => "|\d{4}-\d{2}-\d{2}", + ], + defaults: [ "start" => false, "end" => false ], + methods: [ "GET" ] + )] + /** + * Count the number of edits a user has made in a category. + * @codeCoverageIgnore + */ + public function categoryEditCountApiAction( CategoryEditsRepository $categoryEditsRepo ): JsonResponse { + $this->recordApiUsage( 'user/category_editcount' ); + + $this->setupCategoryEdits( $categoryEditsRepo ); + + $ret = [ + // Ensure `categories` is always treated as an array, even if one element. + // (XtoolsController would otherwise see it as a single value from the URL query string). + 'categories' => $this->categories, + 'total_editcount' => $this->categoryEdits->getEditCount(), + 'category_editcount' => $this->categoryEdits->getCategoryEditCount(), + ]; + + return $this->getFormattedApiResponse( $ret ); + } } diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php index e5806e1d0..59ff9814e 100644 --- a/src/Controller/DefaultController.php +++ b/src/Controller/DefaultController.php @@ -1,6 +1,6 @@ render('default/index.html.twig', [ - 'xtPage' => 'home', - ]); - } + #[Route( '/', name: 'homepage' )] + #[Route( '/index.php', name: 'homepageIndexPhp' )] + public function indexAction(): Response { + return $this->render( 'default/index.html.twig', [ + 'xtPage' => 'home', + ] ); + } - /** - * Redirect to the default project (or Meta) for Oauth authentication. - */ - #[Route('/login', name: 'login')] - public function loginAction( - Request $request, - RequestStack $requestStack, - ProjectRepository $projectRepo, - UrlGeneratorInterface $urlGenerator, - string $centralAuthProject - ): RedirectResponse { - try { - [$next, $token] = $this->getOauthClient($request, $projectRepo, $urlGenerator, $centralAuthProject) - ->initiate(); - } catch (Exception $oauthException) { - $this->addFlashMessage('notice', 'error-login'); - return $this->redirectToRoute('homepage'); - } + #[Route( '/login', name: 'login' )] + /** + * Redirect to the default project (or Meta) for Oauth authentication. + */ + public function loginAction( + Request $request, + RequestStack $requestStack, + ProjectRepository $projectRepo, + UrlGeneratorInterface $urlGenerator, + string $centralAuthProject + ): RedirectResponse { + try { + [ $next, $token ] = $this->getOauthClient( $request, $projectRepo, $urlGenerator, $centralAuthProject ) + ->initiate(); + } catch ( Exception $oauthException ) { + $this->addFlashMessage( 'notice', 'error-login' ); + return $this->redirectToRoute( 'homepage' ); + } - // Save the request token to the session. - $requestStack->getSession()->set('oauth_request_token', $token); - return new RedirectResponse($next); - } + // Save the request token to the session. + $requestStack->getSession()->set( 'oauth_request_token', $token ); + return new RedirectResponse( $next ); + } - /** - * Receive authentication credentials back from the Oauth wiki. - */ - #[Route('/oauth_callback', name: 'oauth_callback')] - #[Route('/oauthredirector.php', name: 'old_oauth_callback')] - public function oauthCallbackAction( - RequestStack $requestStack, - ProjectRepository $projectRepo, - UrlGeneratorInterface $urlGenerator, - string $centralAuthProject - ): RedirectResponse { - $request = $requestStack->getCurrentRequest(); - $session = $requestStack->getSession(); - // Give up if the required GET params don't exist. - if (!$request->get('oauth_verifier')) { - throw $this->createNotFoundException('No OAuth verifier given.'); - } + #[Route( '/oauth_callback', name: 'oauth_callback' )] + #[Route( '/oauthredirector.php', name: 'old_oauth_callback' )] + /** + * Receive authentication credentials back from the Oauth wiki. + */ + public function oauthCallbackAction( + RequestStack $requestStack, + ProjectRepository $projectRepo, + UrlGeneratorInterface $urlGenerator, + string $centralAuthProject + ): RedirectResponse { + $request = $requestStack->getCurrentRequest(); + $session = $requestStack->getSession(); + // Give up if the required GET params don't exist. + if ( !$request->get( 'oauth_verifier' ) ) { + throw $this->createNotFoundException( 'No OAuth verifier given.' ); + } - // Complete authentication. - $client = $this->getOauthClient($request, $projectRepo, $urlGenerator, $centralAuthProject); - $token = $requestStack->getSession()->get('oauth_request_token'); + // Complete authentication. + $client = $this->getOauthClient( $request, $projectRepo, $urlGenerator, $centralAuthProject ); + $token = $requestStack->getSession()->get( 'oauth_request_token' ); - if (!is_a($token, Token::class)) { - $this->addFlashMessage('notice', 'error-login'); - return $this->redirectToRoute('homepage'); - } + if ( !is_a( $token, Token::class ) ) { + $this->addFlashMessage( 'notice', 'error-login' ); + return $this->redirectToRoute( 'homepage' ); + } - $verifier = $request->get('oauth_verifier'); - $accessToken = $client->complete($token, $verifier); + $verifier = $request->get( 'oauth_verifier' ); + $accessToken = $client->complete( $token, $verifier ); - // Store access token, and remove request token. - $session->set('oauth_access_token', $accessToken); - $session->remove('oauth_request_token'); + // Store access token, and remove request token. + $session->set( 'oauth_access_token', $accessToken ); + $session->remove( 'oauth_request_token' ); - // Store user identity. - $ident = $client->identify($accessToken); - $session->set('logged_in_user', $ident); + // Store user identity. + $ident = $client->identify( $accessToken ); + $session->set( 'logged_in_user', $ident ); - // Store reference to the client. - $session->set('oauth_client', $this->oauthClient); + // Store reference to the client. + $session->set( 'oauth_client', $this->oauthClient ); - // Redirect to callback, if given. - if ($request->query->get('redirect')) { - return $this->redirect($request->query->get('redirect')); - } + // Redirect to callback, if given. + if ( $request->query->get( 'redirect' ) ) { + return $this->redirect( $request->query->get( 'redirect' ) ); + } - // Send back to homepage. - return $this->redirectToRoute('homepage'); - } + // Send back to homepage. + return $this->redirectToRoute( 'homepage' ); + } - /** - * Get an OAuth client, configured to the default project. - * (This shouldn't really be in this class, but oh well.) - * @codeCoverageIgnore - */ - protected function getOauthClient( - Request $request, - ProjectRepository $projectRepo, - UrlGeneratorInterface $urlGenerator, - string $centralAuthProject - ): Client { - if (isset($this->oauthClient)) { - return $this->oauthClient; - } - $defaultProject = $projectRepo->getProject($centralAuthProject); - $endpoint = $defaultProject->getUrl(false) - . $defaultProject->getScript() - . '?title=Special:OAuth'; - $conf = new ClientConfig($endpoint); - $consumerKey = $this->getParameter('oauth_key'); - $consumerSecret = $this->getParameter('oauth_secret'); - $conf->setConsumer(new Consumer($consumerKey, $consumerSecret)); - $conf->setUserAgent( - 'XTools/'.$this->getParameter('app.version').' ('. - rtrim( - $urlGenerator->generate($this->getIndexRoute(), [], UrlGeneratorInterface::ABSOLUTE_URL), - '/' - ).' '.$this->getParameter('mailer.to_email').')' - ); - $this->oauthClient = new Client($conf); + /** + * Get an OAuth client, configured to the default project. + * (This shouldn't really be in this class, but oh well.) + * @codeCoverageIgnore + */ + protected function getOauthClient( + Request $request, + ProjectRepository $projectRepo, + UrlGeneratorInterface $urlGenerator, + string $centralAuthProject + ): Client { + if ( isset( $this->oauthClient ) ) { + return $this->oauthClient; + } + $defaultProject = $projectRepo->getProject( $centralAuthProject ); + $endpoint = $defaultProject->getUrl( false ) + . $defaultProject->getScript() + . '?title=Special:OAuth'; + $conf = new ClientConfig( $endpoint ); + $consumerKey = $this->getParameter( 'oauth_key' ); + $consumerSecret = $this->getParameter( 'oauth_secret' ); + $conf->setConsumer( new Consumer( $consumerKey, $consumerSecret ) ); + $conf->setUserAgent( + 'XTools/' . $this->getParameter( 'app.version' ) . ' (' . + rtrim( + $urlGenerator->generate( $this->getIndexRoute(), [], UrlGeneratorInterface::ABSOLUTE_URL ), + '/' + ) . ' ' . $this->getParameter( 'mailer.to_email' ) . ')' + ); + $this->oauthClient = new Client( $conf ); - // Set the callback URL if given. Used to redirect back to target page after logging in. - if ($request->query->get('callback')) { - $this->oauthClient->setCallback($request->query->get('callback')); - } + // Set the callback URL if given. Used to redirect back to target page after logging in. + if ( $request->query->get( 'callback' ) ) { + $this->oauthClient->setCallback( $request->query->get( 'callback' ) ); + } - return $this->oauthClient; - } + return $this->oauthClient; + } - /** - * Log out the user and return to the homepage. - */ - #[Route('/logout', name: 'logout')] - public function logoutAction(RequestStack $requestStack): RedirectResponse - { - $requestStack->getSession()->invalidate(); - return $this->redirectToRoute('homepage'); - } + #[Route( '/logout', name: 'logout' )] + /** + * Log out the user and return to the homepage. + */ + public function logoutAction( RequestStack $requestStack ): RedirectResponse { + $requestStack->getSession()->invalidate(); + return $this->redirectToRoute( 'homepage' ); + } - /************************ API endpoints ************************/ + /************************ API endpoints */ - /** - * Get domain name, URL, API path and database name for the given project. - * @OA\Tag(name="Project API") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Response( - * response=200, - * description="The domain, URL, API path and database name.", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="domain", type="string", example="en.wikipedia.org"), - * @OA\Property(property="url", type="string", example="https://en.wikipedia.org"), - * @OA\Property(property="api", type="string", example="https://en.wikipedia.org/w/api.php"), - * @OA\Property(property="database", type="string", example="enwiki"), - * @OA\Property(property="elapsed_time", ref="#/components/schemas/elapsed_time") - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - */ - #[Route('/api/project/normalize/{project}', name: 'ProjectApiNormalize', methods: ['GET'])] - public function normalizeProjectApiAction(): JsonResponse - { - return $this->getFormattedApiResponse([ - 'domain' => $this->project->getDomain(), - 'url' => $this->project->getUrl(), - 'api' => $this->project->getApiUrl(), - 'database' => $this->project->getDatabaseName(), - ]); - } + #[OA\Tag( name: "Project API" )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Response( + response: 200, + description: "The domain, URL, API path and database name.", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "domain", type: "string", example: "en.wikipedia.org" ), + new OA\Property( property: "url", type: "string", example: "https://en.wikipedia.org" ), + new OA\Property( property: "api", type: "string", example: "https://en.wikipedia.org/w/api.php" ), + new OA\Property( property: "database", type: "string", example: "enwiki" ), + new OA\Property( property: "elapsed_time", ref: "#/components/schemas/elapsed_time" ), + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[OA\Response( ref: "#/components/responses/503", response: 503 )] + #[OA\Response( ref: "#/components/responses/504", response: 504 )] + #[Route( '/api/project/normalize/{project}', name: 'ProjectApiNormalize', methods: [ 'GET' ] )] + /** + * Get domain name, URL, API path and database name for the given project. + */ + public function normalizeProjectApiAction(): JsonResponse { + return $this->getFormattedApiResponse( [ + 'domain' => $this->project->getDomain(), + 'url' => $this->project->getUrl(), + 'api' => $this->project->getApiUrl(), + 'database' => $this->project->getDatabaseName(), + ] ); + } - /** - * Get the localized names for each namespaces of the given project. - * @OA\Tag(name="Project API") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Response( - * response=200, - * description="List of localized namespaces keyed by their ID.", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="url", type="string", example="https://en.wikipedia.org"), - * @OA\Property(property="api", type="string", example="https://en.wikipedia.org/w/api.php"), - * @OA\Property(property="database", type="string", example="enwiki"), - * @OA\Property(property="namespaces", type="object", example={"0": "", "3": "User talk"}), - * @OA\Property(property="elapsed_time", ref="#/components/schemas/elapsed_time") - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - */ - #[Route('/api/project/namespaces/{project}', name: 'ProjectApiNamespaces', methods: ['GET'])] - public function namespacesApiAction(): JsonResponse - { - return $this->getFormattedApiResponse([ - 'domain' => $this->project->getDomain(), - 'url' => $this->project->getUrl(), - 'api' => $this->project->getApiUrl(), - 'database' => $this->project->getDatabaseName(), - 'namespaces' => $this->project->getNamespaces(), - ]); - } + #[OA\Tag( name: "Project API" )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Response( + response: 200, + description: "List of localized namespaces keyed by their ID.", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "url", type: "string", example: "https://en.wikipedia.org" ), + new OA\Property( property: "api", type: "string", example: "https://en.wikipedia.org/w/api.php" ), + new OA\Property( property: "database", type: "string", example: "enwiki" ), + new OA\Property( property: "namespaces", type: "object", example: [ '0' => '', '3' => 'User talk' ] ), + new OA\Property( property: "elapsed_time", ref: "#/components/schemas/elapsed_time" ), + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[OA\Response( ref: "#/components/responses/503", response: 503 )] + #[OA\Response( ref: "#/components/responses/504", response: 504 )] + #[Route( '/api/project/namespaces/{project}', name: 'ProjectApiNamespaces', methods: [ 'GET' ] )] + /** + * Get the localized names for each namespaces of the given project. + */ + public function namespacesApiAction(): JsonResponse { + return $this->getFormattedApiResponse( [ + 'domain' => $this->project->getDomain(), + 'url' => $this->project->getUrl(), + 'api' => $this->project->getApiUrl(), + 'database' => $this->project->getDatabaseName(), + 'namespaces' => $this->project->getNamespaces(), + ] ); + } - /** - * Get page assessment metadata for a project. - * @OA\Tag(name="Project API") - * @OA\ExternalDocumentation(url="https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:PageAssessments") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Response( - * response=200, - * description="List of classifications and importance levels, along with their associated colours and badges.", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="assessments", type="object", example={ - * "wikiproject_prefix": "Wikipedia:WikiProject ", - * "class": { - * "FA": { - * "badge": "b/bc/Featured_article_star.svg", - * "color": "#9CBDFF", - * "category": "Category:FA-Class articles" - * } - * }, - * "importance": { - * "Top": { - * "color": "#FF97FF", - * "category": "Category:Top-importance articles", - * "weight": 5 - * } - * } - * }), - * @OA\Property(property="elapsed_time", ref="#/components/schemas/elapsed_time") - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - */ - #[Route('/api/project/assessments/{project}', name: 'ProjectApiAssessments', methods: ['GET'])] - public function projectAssessmentsApiAction(): JsonResponse - { - return $this->getFormattedApiResponse([ - 'project' => $this->project->getDomain(), - 'assessments' => $this->project->getPageAssessments()->getConfig(), - ]); - } + #[OA\Tag( name: "Project API" )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Response( + response: 200, + description: "List of classifications and importance levels, along with their associated colours and badges.", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( + property: "assessments", + type: "object", + example: [ + "wikiproject_prefix" => "Wikipedia:WikiProject ", + "class" => [ + "FA" => [ + "badge" => "b/bc/Featured_article_star.svg", + "color" => "#9CBDFF", + "category" => "Category:FA-Class articles", + ], + ], + "importance" => [ + "Top" => [ + "color" => "#FF97FF", + "category" => "Category:Top-importance articles", + "weight" => 5, + ], + ], + ] + ), + new OA\Property( property: "elapsed_time", ref: "#/components/schemas/elapsed_time" ), + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[Route( '/api/project/assessments/{project}', name: 'ProjectApiAssessments', methods: [ 'GET' ] )] + /** + * Get page assessment metadata for a project. + */ + public function projectAssessmentsApiAction(): JsonResponse { + return $this->getFormattedApiResponse( [ + 'project' => $this->project->getDomain(), + 'assessments' => $this->project->getPageAssessments()->getConfig(), + ] ); + } - /** - * Get assessment metadata for all projects. - * @OA\Tag(name="Project API") - * @OA\ExternalDocumentation(url="https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:PageAssessments") - * @OA\Response( - * response=200, - * description="Page assessment metadata for all projects that have - PageAssessments installed.", - * @OA\JsonContent( - * @OA\Property(property="projects", type="array", @OA\Items(type="string"), - * example={"en.wikipedia.org", "fr.wikipedia.org"} - * ), - * @OA\Property(property="config", type="object", example={ - * "en.wikipedia.org": { - * "wikiproject_prefix": "Wikipedia:WikiProject ", - * "class": { - * "FA": { - * "badge": "b/bc/Featured_article_star.svg", - * "color": "#9CBDFF", - * "category": "Category:FA-Class articles" - * } - * }, - * "importance": { - * "Top": { - * "color": "#FF97FF", - * "category": "Category:Top-importance articles", - * "weight": 5 - * } - * } - * } - * }), - * @OA\Property(property="elapsed_time", ref="#/components/schemas/elapsed_time") - * ) - * ) - */ - #[Route('/api/project/assessments', name: 'ApiAssessmentsConfig', methods: ['GET'])] - public function assessmentsConfigApiAction(): JsonResponse - { - // Here there is no Project, so we don't use XtoolsController::getFormattedApiResponse(). - $response = new JsonResponse(); - $response->setEncodingOptions(JSON_NUMERIC_CHECK); - $response->setStatusCode(Response::HTTP_OK); - $response->setData([ - 'projects' => array_keys($this->getParameter('assessments')), - 'config' => $this->getParameter('assessments'), - ]); + #[OA\Tag( name: "Project API" )] + #[OA\Response( + response: 200, + description: "Page assessment metadata for all projects that have\n" . + "PageAssessments installed.", + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: "projects", + type: "array", + items: new OA\Items( type: "string" ), + example: [ "en.wikipedia.org", "fr.wikipedia.org" ] + ), + new OA\Property( + property: "config", + type: "object", + example: [ + "en.wikipedia.org" => [ + "wikiproject_prefix" => "Wikipedia:WikiProject ", + "class" => [ + "FA" => [ + "badge" => "b/bc/Featured_article_star.svg", + "color" => "#9CBDFF", + "category" => "Category:FA-Class articles", + ], + ], + "importance" => [ + "Top" => [ + "color" => "#FF97FF", + "category" => "Category:Top-importance articles", + "weight" => 5, + ], + ], + ], + ] + ), + new OA\Property( property: "elapsed_time", ref: "#/components/schemas/elapsed_time" ), + ] + ) + )] + #[Route( '/api/project/assessments', name: 'ApiAssessmentsConfig', methods: [ 'GET' ] )] + /** + * Get assessment metadata for all projects. + */ + public function assessmentsConfigApiAction(): JsonResponse { + // Here there is no Project, so we don't use XtoolsController::getFormattedApiResponse(). + $response = new JsonResponse(); + $response->setEncodingOptions( JSON_NUMERIC_CHECK ); + $response->setStatusCode( Response::HTTP_OK ); + $response->setData( [ + 'projects' => array_keys( $this->getParameter( 'assessments' ) ), + 'config' => $this->getParameter( 'assessments' ), + ] ); - return $response; - } + return $response; + } - /** - * Transform given wikitext to HTML using the XTools parser. Wikitext must be passed in as the query 'wikitext'. - * @return JsonResponse Safe HTML. - */ - #[Route('/api/project/parser/{project}')] - public function wikifyApiAction(): JsonResponse - { - return new JsonResponse( - Edit::wikifyString($this->request->query->get('wikitext', ''), $this->project) - ); - } + #[Route( '/api/project/parser/{project}' )] + /** + * Transform given wikitext to HTML using the XTools parser. Wikitext must be passed in as the query 'wikitext'. + * @return JsonResponse Safe HTML. + */ + public function wikifyApiAction(): JsonResponse { + return new JsonResponse( + Edit::wikifyString( $this->request->query->get( 'wikitext', '' ), $this->project ) + ); + } } diff --git a/src/Controller/EditCounterController.php b/src/Controller/EditCounterController.php index 1fa9044dd..84c121ede 100644 --- a/src/Controller/EditCounterController.php +++ b/src/Controller/EditCounterController.php @@ -1,6 +1,6 @@ 'EditCounterGeneralStats', - 'namespace-totals' => 'EditCounterNamespaceTotals', - 'year-counts' => 'EditCounterYearCounts', - 'month-counts' => 'EditCounterMonthCounts', - 'timecard' => 'EditCounterTimecard', - 'top-edited-pages' => 'TopEditsResultNamespace', - 'rights-changes' => 'EditCounterRightsChanges', - ]; - - protected EditCounter $editCounter; - protected UserRights $userRights; - - /** @var string[] Which sections to show. */ - protected array $sections; - - /** - * @inheritDoc - * @codeCoverageIgnore - */ - public function getIndexRoute(): string - { - return 'EditCounter'; - } - - /** - * Causes the tool to redirect to the Simple Edit Counter if the user has too high of an edit count. - * @inheritDoc - * @codeCoverageIgnore - */ - public function tooHighEditCountRoute(): string - { - return 'SimpleEditCounterResult'; - } - - /** - * @inheritDoc - * @codeCoverageIgnore - */ - public function tooHighEditCountActionAllowlist(): array - { - return ['rightsChanges']; - } - - /** - * @inheritDoc - * @codeCoverageIgnore - */ - public function restrictedApiActions(): array - { - return ['monthCountsApi', 'timecardApi']; - } - - /** - * Every action in this controller (other than 'index') calls this first. - * If a response is returned, the calling action is expected to return it. - * @param EditCounterRepository $editCounterRepo - * @param UserRightsRepository $userRightsRepo - * @param RequestStack $requestStack - * @codeCoverageIgnore - */ - protected function setUpEditCounter( - EditCounterRepository $editCounterRepo, - UserRightsRepository $userRightsRepo, - RequestStack $requestStack, - AutomatedEditsHelper $autoEditsHelper - ): void { - // Whether we're making a subrequest (the view makes a request to another action). - // Subrequests to the same controller do not re-instantiate a new controller, and hence - // this flag would not be set in XtoolsController::__construct(), so we must do it here as well. - $this->isSubRequest = $this->request->get('htmlonly') - || null !== $requestStack->getParentRequest(); - - // Return the EditCounter if we already have one. - if (isset($this->editCounter)) { - return; - } - - // Will redirect to Simple Edit Counter if they have too many edits, as defined self::construct. - $this->validateUser($this->user->getUsername()); - - // Store which sections of the Edit Counter they requested. - $this->sections = $this->getRequestedSections(); - - $this->userRights = new UserRights($userRightsRepo, $this->project, $this->user, $this->i18n); - - // Instantiate EditCounter. - $this->editCounter = new EditCounter( - $editCounterRepo, - $this->i18n, - $this->userRights, - $this->project, - $this->user, - $autoEditsHelper - ); - } - - /** - * The initial GET request that displays the search form. - */ - #[Route("/ec", name: "EditCounter")] - #[Route("/ec/index.php", name: "EditCounterIndexPhp")] - #[Route("/ec/{project}", name: "EditCounterProject")] - public function indexAction(): Response|RedirectResponse - { - if (isset($this->params['project']) && isset($this->params['username'])) { - return $this->redirectFromSections(); - } - - $this->sections = $this->getRequestedSections(true); - - // Otherwise fall through. - return $this->render('editCounter/index.html.twig', [ - 'xtPageTitle' => 'tool-editcounter', - 'xtSubtitle' => 'tool-editcounter-desc', - 'xtPage' => 'EditCounter', - 'project' => $this->project, - 'sections' => $this->sections, - 'availableSections' => $this->getSectionNames(), - 'isAllSections' => $this->sections === $this->getSectionNames(), - ]); - } - - /** - * Get the requested sections either from the URL, cookie, or the defaults (all sections). - * @param bool $useCookies Whether or not to check cookies for the preferred sections. - * This option should not be true except on the index form. - * @return array|string[] - * @codeCoverageIgnore - */ - private function getRequestedSections(bool $useCookies = false): array - { - // Happens from sub-tool index pages, e.g. see self::generalStatsIndexAction(). - if (isset($this->sections)) { - return $this->sections; - } - - // Query param for sections gets priority. - $sectionsQuery = $this->request->get('sections', ''); - - // If not present, try the cookie, and finally the defaults (all sections). - if ($useCookies && '' == $sectionsQuery) { - $sectionsQuery = $this->request->cookies->get('XtoolsEditCounterOptions', ''); - } - - // Either a pipe-separated string or an array. - $sections = is_array($sectionsQuery) ? $sectionsQuery : explode('|', $sectionsQuery); - - // Filter out any invalid section IDs. - $sections = array_filter($sections, function ($section) { - return in_array($section, $this->getSectionNames()); - }); - - // Fallback for when no valid sections were requested or provided by the cookie. - if (0 === count($sections)) { - $sections = $this->getSectionNames(); - } - - return $sections; - } - - /** - * Get the names of the available sections. - * @return string[] - * @codeCoverageIgnore - */ - private function getSectionNames(): array - { - return array_keys(self::AVAILABLE_SECTIONS); - } - - /** - * Redirect to the appropriate action based on what sections are being requested. - * @return RedirectResponse - * @codeCoverageIgnore - */ - private function redirectFromSections(): RedirectResponse - { - $this->sections = $this->getRequestedSections(); - - if (1 === count($this->sections)) { - // Redirect to dedicated route. - $response = $this->redirectToRoute(self::AVAILABLE_SECTIONS[$this->sections[0]], $this->params); - } elseif ($this->sections === $this->getSectionNames()) { - $response = $this->redirectToRoute('EditCounterResult', $this->params); - } else { - // Add sections to the params, which $this->generalUrl() will append to the URL. - $this->params['sections'] = implode('|', $this->sections); - - // We want a pretty URL, with pipes | instead of the encoded value %7C - $url = str_replace('%7C', '|', $this->generateUrl('EditCounterResult', $this->params)); - - $response = $this->redirect($url); - } - - // Save the preferred sections in a cookie. - $response->headers->setCookie( - new Cookie('XtoolsEditCounterOptions', implode('|', $this->sections)) - ); - - return $response; - } - - /** - * Display all results. - * @codeCoverageIgnore - */ - #[Route( - "/ec/{project}/{username}", - name: "EditCounterResult", - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - ] - )] - public function resultAction( - EditCounterRepository $editCounterRepo, - UserRightsRepository $userRightsRepo, - RequestStack $requestStack, - AutomatedEditsHelper $autoEditsHelper - ): Response|RedirectResponse { - $this->setUpEditCounter($editCounterRepo, $userRightsRepo, $requestStack, $autoEditsHelper); - - if (1 === count($this->sections)) { - // Redirect to dedicated route. - return $this->redirectToRoute(self::AVAILABLE_SECTIONS[$this->sections[0]], $this->params); - } - - $ret = [ - 'xtTitle' => $this->user->getUsername() . ' - ' . $this->project->getTitle(), - 'xtPage' => 'EditCounter', - 'user' => $this->user, - 'project' => $this->project, - 'ec' => $this->editCounter, - 'sections' => $this->sections, - 'isAllSections' => $this->sections === $this->getSectionNames(), - ]; - - // Used when querying for global rights changes. - if ($this->isWMF) { - $ret['metaProject'] = $this->projectRepo->getProject('metawiki'); - } - - return $this->getFormattedResponse('editCounter/result', $ret); - } - - /** - * Display the general statistics section. - * @codeCoverageIgnore - */ - #[Route( - "/ec-generalstats/{project}/{username}", - name: "EditCounterGeneralStats", - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - ] - )] - public function generalStatsAction( - EditCounterRepository $editCounterRepo, - UserRightsRepository $userRightsRepo, - GlobalContribsRepository $globalContribsRepo, - EditRepository $editRepo, - RequestStack $requestStack, - AutomatedEditsHelper $autoEditsHelper - ): Response { - $this->setUpEditCounter($editCounterRepo, $userRightsRepo, $requestStack, $autoEditsHelper); - - $globalContribs = new GlobalContribs( - $globalContribsRepo, - $this->pageRepo, - $this->userRepo, - $editRepo, - $this->user - ); - $ret = [ - 'xtTitle' => $this->user->getUsername(), - 'xtPage' => 'EditCounter', - 'subtool_msg_key' => 'general-stats', - 'is_sub_request' => $this->isSubRequest, - 'user' => $this->user, - 'project' => $this->project, - 'ec' => $this->editCounter, - 'gc' => $globalContribs, - ]; - - // Output the relevant format template. - return $this->getFormattedResponse('editCounter/general_stats', $ret); - } - - /** - * Search form for general stats. - */ - #[Route( - "/ec-generalstats", - name: "EditCounterGeneralStatsIndex", - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - ] - )] - public function generalStatsIndexAction(): Response - { - $this->sections = ['general-stats']; - return $this->indexAction(); - } - - /** - * Display the namespace totals section. - * @codeCoverageIgnore - */ - #[Route( - "/ec-namespacetotals/{project}/{username}", - name: "EditCounterNamespaceTotals", - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - ] - )] - public function namespaceTotalsAction( - EditCounterRepository $editCounterRepo, - UserRightsRepository $userRightsRepo, - RequestStack $requestStack, - AutomatedEditsHelper $autoEditsHelper - ): Response { - $this->setUpEditCounter($editCounterRepo, $userRightsRepo, $requestStack, $autoEditsHelper); - - $ret = [ - 'xtTitle' => $this->user->getUsername(), - 'xtPage' => 'EditCounter', - 'subtool_msg_key' => 'namespace-totals', - 'is_sub_request' => $this->isSubRequest, - 'user' => $this->user, - 'project' => $this->project, - 'ec' => $this->editCounter, - ]; - - // Output the relevant format template. - return $this->getFormattedResponse('editCounter/namespace_totals', $ret); - } - - /** - * Search form for namespace totals. - */ - #[Route("/ec-namespacetotals", name: "EditCounterNamespaceTotalsIndex")] - public function namespaceTotalsIndexAction(): Response - { - $this->sections = ['namespace-totals']; - return $this->indexAction(); - } - - /** - * Display the timecard section. - * @codeCoverageIgnore - */ - #[Route( - "/ec-timecard/{project}/{username}", - name: "EditCounterTimecard", - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - ] - )] - public function timecardAction( - EditCounterRepository $editCounterRepo, - UserRightsRepository $userRightsRepo, - RequestStack $requestStack, - AutomatedEditsHelper $autoEditsHelper - ): Response { - $this->setUpEditCounter($editCounterRepo, $userRightsRepo, $requestStack, $autoEditsHelper); - - $ret = [ - 'xtTitle' => $this->user->getUsername(), - 'xtPage' => 'EditCounter', - 'subtool_msg_key' => 'timecard', - 'is_sub_request' => $this->isSubRequest, - 'user' => $this->user, - 'project' => $this->project, - 'ec' => $this->editCounter, - 'opted_in_page' => $this->getOptedInPage(), - ]; - - // Output the relevant format template. - return $this->getFormattedResponse('editCounter/timecard', $ret); - } - - /** - * Search form for timecard. - */ - #[Route("/ec-timecard", name: "EditCounterTimecardIndex")] - public function timecardIndexAction(): Response - { - $this->sections = ['timecard']; - return $this->indexAction(); - } - - /** - * Display the year counts section. - * @codeCoverageIgnore - */ - #[Route( - "/ec-yearcounts/{project}/{username}", - name: "EditCounterYearCounts", - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - ] - )] - public function yearCountsAction( - EditCounterRepository $editCounterRepo, - UserRightsRepository $userRightsRepo, - RequestStack $requestStack, - AutomatedEditsHelper $autoEditsHelper - ): Response { - $this->setUpEditCounter($editCounterRepo, $userRightsRepo, $requestStack, $autoEditsHelper); - - $ret = [ - 'xtTitle' => $this->user->getUsername(), - 'xtPage' => 'EditCounter', - 'subtool_msg_key' => 'year-counts', - 'is_sub_request' => $this->isSubRequest, - 'user' => $this->user, - 'project' => $this->project, - 'ec' => $this->editCounter, - ]; - - // Output the relevant format template. - return $this->getFormattedResponse('editCounter/yearcounts', $ret); - } - - /** - * Search form for year counts. - * @return Response - */ - #[Route("/ec-yearcounts", name: "EditCounterYearCountsIndex")] - public function yearCountsIndexAction(): Response - { - $this->sections = ['year-counts']; - return $this->indexAction(); - } - - /** - * Display the month counts section. - * @codeCoverageIgnore - */ - #[Route( - "/ec-monthcounts/{project}/{username}", - name: "EditCounterMonthCounts", - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - ] - )] - public function monthCountsAction( - EditCounterRepository $editCounterRepo, - UserRightsRepository $userRightsRepo, - RequestStack $requestStack, - AutomatedEditsHelper $autoEditsHelper - ): Response { - $this->setUpEditCounter($editCounterRepo, $userRightsRepo, $requestStack, $autoEditsHelper); - - $ret = [ - 'xtTitle' => $this->user->getUsername(), - 'xtPage' => 'EditCounter', - 'subtool_msg_key' => 'month-counts', - 'is_sub_request' => $this->isSubRequest, - 'user' => $this->user, - 'project' => $this->project, - 'ec' => $this->editCounter, - 'opted_in_page' => $this->getOptedInPage(), - ]; - - // Output the relevant format template. - return $this->getFormattedResponse('editCounter/monthcounts', $ret); - } - - /** - * Search form for month counts. - */ - #[Route("/ec-monthcounts", name: "EditCounterMonthCountsIndex")] - public function monthCountsIndexAction(): Response - { - $this->sections = ['month-counts']; - return $this->indexAction(); - } - - /** - * Display the user rights changes section. - * @codeCoverageIgnore - */ - #[Route( - "/ec-rightschanges/{project}/{username}", - name: "EditCounterRightsChanges", - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - ] - )] - public function rightsChangesAction( - EditCounterRepository $editCounterRepo, - UserRightsRepository $userRightsRepo, - RequestStack $requestStack, - AutomatedEditsHelper $autoEditsHelper - ): Response { - $this->setUpEditCounter($editCounterRepo, $userRightsRepo, $requestStack, $autoEditsHelper); - - $ret = [ - 'xtTitle' => $this->user->getUsername(), - 'xtPage' => 'EditCounter', - 'is_sub_request' => $this->isSubRequest, - 'user' => $this->user, - 'project' => $this->project, - 'ec' => $this->editCounter, - ]; - - if ($this->isWMF) { - $ret['metaProject'] = $this->projectRepo->getProject('metawiki'); - } - - // Output the relevant format template. - return $this->getFormattedResponse('editCounter/rights_changes', $ret); - } - - /** - * Search form for rights changes. - */ - #[Route("/ec-rightschanges", name: "EditCounterRightsChangesIndex")] - public function rightsChangesIndexAction(): Response - { - $this->sections = ['rights-changes']; - return $this->indexAction(); - } - - /************************ API endpoints ************************/ - - /** - * Get counts of various log actions made by the user. - * @OA\Tag(name="User API") - * @OA\Get(description="Get counts of various logged actions made by a user. The keys of the returned `log_counts` - property describe the log type and log action in the form of _type-action_. - See also the [logevents API](https://www.mediawiki.org/wiki/Special:MyLanguage/API:Logevents).") - * @OA\ExternalDocumentation(url="https://www.mediawiki.org/wiki/Manual:Log_actions") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Parameter(ref="#/components/parameters/UsernameOrIp") - * @OA\Response( - * response=200, - * description="Counts of logged actions", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="username", ref="#/components/parameters/UsernameOrIp/schema"), - * @OA\Property(property="log_counts", type="object", example={ - * "block-block": 0, - * "block-unblock": 0, - * "protect-protect": 0, - * "protect-unprotect": 0, - * "move-move": 0, - * "move-move_redir": 0 - * }) - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=501, ref="#/components/responses/501") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - * @codeCoverageIgnore - */ - #[Route( - "/api/user/log_counts/{project}/{username}", - name: "UserApiLogCounts", - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - ], - methods: ["GET"] - )] - public function logCountsApiAction( - EditCounterRepository $editCounterRepo, - UserRightsRepository $userRightsRepo, - RequestStack $requestStack, - AutomatedEditsHelper $autoEditsHelper - ): JsonResponse { - $this->setUpEditCounter($editCounterRepo, $userRightsRepo, $requestStack, $autoEditsHelper); - - return $this->getFormattedApiResponse([ - 'log_counts' => $this->editCounter->getLogCounts(), - ]); - } - - /** - * Get the number of edits made by the user to each namespace. - * @OA\Tag(name="User API") - * @OA\Get(description="Get edit counts of a user broken down by [namespace](https://w.wiki/6oKq).") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Parameter(ref="#/components/parameters/UsernameOrIp") - * @OA\Response( - * response=200, - * description="Namepsace totals", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="username", ref="#/components/parameters/UsernameOrIp/schema"), - * @OA\Property(property="namespace_totals", type="object", example={"0": 50, "2": 10, "3": 100}, - * description="Keys are namespace IDs, values are edit counts.") - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=501, ref="#/components/responses/501") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - * @codeCoverageIgnore - */ - #[Route( - "/api/user/namespace_totals/{project}/{username}", - name: "UserApiNamespaceTotals", - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - ], - methods: ["GET"] - )] - public function namespaceTotalsApiAction( - EditCounterRepository $editCounterRepo, - UserRightsRepository $userRightsRepo, - RequestStack $requestStack, - AutomatedEditsHelper $autoEditsHelper - ): JsonResponse { - $this->setUpEditCounter($editCounterRepo, $userRightsRepo, $requestStack, $autoEditsHelper); - - return $this->getFormattedApiResponse([ - 'namespace_totals' => (object)$this->editCounter->namespaceTotals(), - ]); - } - - /** - * Get the number of edits made by the user for each month, grouped by namespace. - * @OA\Tag(name="User API") - * @OA\Get(description="Get the number of edits a user has made grouped by namespace and month.") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Parameter(ref="#/components/parameters/UsernameOrIp") - * @OA\Response( - * response=200, - * description="Month counts", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="username", ref="#/components/parameters/UsernameOrIp/schema"), - * @OA\Property(property="totals", type="object", example={ - * "0": { - * "2020-11": 40, - * "2020-12": 50, - * "2021-01": 5 - * }, - * "3": { - * "2020-11": 0, - * "2020-12": 10, - * "2021-01": 0 - * } - * }) - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=501, ref="#/components/responses/501") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - * @codeCoverageIgnore - */ - #[Route( - "/api/user/month_counts/{project}/{username}", - name: "UserApiMonthCounts", - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - ], - methods: ["GET"] - )] - public function monthCountsApiAction( - EditCounterRepository $editCounterRepo, - UserRightsRepository $userRightsRepo, - RequestStack $requestStack, - AutomatedEditsHelper $autoEditsHelper - ): JsonResponse { - $this->setUpEditCounter($editCounterRepo, $userRightsRepo, $requestStack, $autoEditsHelper); - - $ret = $this->editCounter->monthCounts(); - - // Remove labels that are only needed by Twig views, and not consumers of the API. - unset($ret['yearLabels']); - unset($ret['monthLabels']); - - // Ensure 'totals' keys are strings, see T292031. - $ret['totals'] = (object)$ret['totals']; - - return $this->getFormattedApiResponse($ret); - } - - /** - * Get the total number of edits made by a user during each hour of day and day of week. - * @OA\Tag(name="User API") - * @OA\Get(description="Get the raw number of edits made by a user during each hour of day and day of week. The - `scale` is a value that indicates the number of edits made relative to other hours and days of the week.") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Parameter(ref="#/components/parameters/UsernameOrIp") - * @OA\Response( - * response=200, - * description="Timecard", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="username", ref="#/components/parameters/UsernameOrIp/schema"), - * @OA\Property(property="timecard", type="array", @OA\Items(type="object"), example={ - * { - * "day_of_week": 1, - * "hour": 0, - * "value": 50, - * "scale": 5 - * } - * }) - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=501, ref="#/components/responses/501") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - * @codeCoverageIgnore - */ - #[Route( - "/api/user/timecard/{project}/{username}", - name: "UserApiTimeCard", - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - ], - methods: ["GET"] - )] - public function timecardApiAction( - EditCounterRepository $editCounterRepo, - UserRightsRepository $userRightsRepo, - RequestStack $requestStack, - AutomatedEditsHelper $autoEditsHelper - ): JsonResponse { - $this->setUpEditCounter($editCounterRepo, $userRightsRepo, $requestStack, $autoEditsHelper); - - return $this->getFormattedApiResponse([ - 'timecard' => $this->editCounter->timeCard(), - ]); - } +class EditCounterController extends XtoolsController { + /** + * Available statistic sections. These can be hand-picked on the index form so that you only get the data you + * want and hence speed up the tool. Keys are the i18n messages (and DOM IDs), values are the action names. + */ + private const AVAILABLE_SECTIONS = [ + 'general-stats' => 'EditCounterGeneralStats', + 'namespace-totals' => 'EditCounterNamespaceTotals', + 'year-counts' => 'EditCounterYearCounts', + 'month-counts' => 'EditCounterMonthCounts', + 'timecard' => 'EditCounterTimecard', + 'top-edited-pages' => 'TopEditsResultNamespace', + 'rights-changes' => 'EditCounterRightsChanges', + ]; + + protected EditCounter $editCounter; + protected UserRights $userRights; + + /** @var string[] Which sections to show. */ + protected array $sections; + + /** + * @inheritDoc + * @codeCoverageIgnore + */ + public function getIndexRoute(): string { + return 'EditCounter'; + } + + /** + * Causes the tool to redirect to the Simple Edit Counter if the user has too high of an edit count. + * @inheritDoc + * @codeCoverageIgnore + */ + public function tooHighEditCountRoute(): string { + return 'SimpleEditCounterResult'; + } + + /** + * @inheritDoc + * @codeCoverageIgnore + */ + public function tooHighEditCountActionAllowlist(): array { + return [ 'rightsChanges' ]; + } + + /** + * @inheritDoc + * @codeCoverageIgnore + */ + public function restrictedApiActions(): array { + return [ 'monthCountsApi', 'timecardApi' ]; + } + + /** + * Every action in this controller (other than 'index') calls this first. + * If a response is returned, the calling action is expected to return it. + * @param EditCounterRepository $editCounterRepo + * @param UserRightsRepository $userRightsRepo + * @param RequestStack $requestStack + * @codeCoverageIgnore + */ + protected function setUpEditCounter( + EditCounterRepository $editCounterRepo, + UserRightsRepository $userRightsRepo, + RequestStack $requestStack, + AutomatedEditsHelper $autoEditsHelper + ): void { + // Whether we're making a subrequest (the view makes a request to another action). + // Subrequests to the same controller do not re-instantiate a new controller, and hence + // this flag would not be set in XtoolsController::__construct(), so we must do it here as well. + $this->isSubRequest = $this->request->get( 'htmlonly' ) + || $requestStack->getParentRequest() !== null; + + // Return the EditCounter if we already have one. + if ( isset( $this->editCounter ) ) { + return; + } + + // Will redirect to Simple Edit Counter if they have too many edits, as defined self::construct. + $this->validateUser( $this->user->getUsername() ); + + // Store which sections of the Edit Counter they requested. + $this->sections = $this->getRequestedSections(); + + $this->userRights = new UserRights( $userRightsRepo, $this->project, $this->user, $this->i18n ); + + // Instantiate EditCounter. + $this->editCounter = new EditCounter( + $editCounterRepo, + $this->i18n, + $this->userRights, + $this->project, + $this->user, + $autoEditsHelper + ); + } + + /** + * The initial GET request that displays the search form. + */ + #[Route( "/ec", name: "EditCounter" )] + #[Route( "/ec/index.php", name: "EditCounterIndexPhp" )] + #[Route( "/ec/{project}", name: "EditCounterProject" )] + public function indexAction(): Response|RedirectResponse { + if ( isset( $this->params['project'] ) && isset( $this->params['username'] ) ) { + return $this->redirectFromSections(); + } + + $this->sections = $this->getRequestedSections( true ); + + // Otherwise fall through. + return $this->render( 'editCounter/index.html.twig', [ + 'xtPageTitle' => 'tool-editcounter', + 'xtSubtitle' => 'tool-editcounter-desc', + 'xtPage' => 'EditCounter', + 'project' => $this->project, + 'sections' => $this->sections, + 'availableSections' => $this->getSectionNames(), + 'isAllSections' => $this->sections === $this->getSectionNames(), + ] ); + } + + /** + * Get the requested sections either from the URL, cookie, or the defaults (all sections). + * @param bool $useCookies Whether or not to check cookies for the preferred sections. + * This option should not be true except on the index form. + * @return array|string[] + * @codeCoverageIgnore + */ + private function getRequestedSections( bool $useCookies = false ): array { + // Happens from sub-tool index pages, e.g. see self::generalStatsIndexAction(). + if ( isset( $this->sections ) ) { + return $this->sections; + } + + // Query param for sections gets priority. + $sectionsQuery = $this->request->get( 'sections', '' ); + + // If not present, try the cookie, and finally the defaults (all sections). + if ( $useCookies && $sectionsQuery == '' ) { + $sectionsQuery = $this->request->cookies->get( 'XtoolsEditCounterOptions', '' ); + } + + // Either a pipe-separated string or an array. + $sections = is_array( $sectionsQuery ) ? $sectionsQuery : explode( '|', $sectionsQuery ); + + // Filter out any invalid section IDs. + $sections = array_filter( $sections, function ( $section ) { + return in_array( $section, $this->getSectionNames() ); + } ); + + // Fallback for when no valid sections were requested or provided by the cookie. + if ( count( $sections ) === 0 ) { + $sections = $this->getSectionNames(); + } + + return $sections; + } + + /** + * Get the names of the available sections. + * @return string[] + * @codeCoverageIgnore + */ + private function getSectionNames(): array { + return array_keys( self::AVAILABLE_SECTIONS ); + } + + /** + * Redirect to the appropriate action based on what sections are being requested. + * @return RedirectResponse + * @codeCoverageIgnore + */ + private function redirectFromSections(): RedirectResponse { + $this->sections = $this->getRequestedSections(); + + if ( count( $this->sections ) === 1 ) { + // Redirect to dedicated route. + $response = $this->redirectToRoute( self::AVAILABLE_SECTIONS[$this->sections[0]], $this->params ); + } elseif ( $this->sections === $this->getSectionNames() ) { + $response = $this->redirectToRoute( 'EditCounterResult', $this->params ); + } else { + // Add sections to the params, which $this->generalUrl() will append to the URL. + $this->params['sections'] = implode( '|', $this->sections ); + + // We want a pretty URL, with pipes | instead of the encoded value %7C + $url = str_replace( '%7C', '|', $this->generateUrl( 'EditCounterResult', $this->params ) ); + + $response = $this->redirect( $url ); + } + + // Save the preferred sections in a cookie. + $response->headers->setCookie( + new Cookie( 'XtoolsEditCounterOptions', implode( '|', $this->sections ) ) + ); + + return $response; + } + + #[Route( + "/ec/{project}/{username}", + name: "EditCounterResult", + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + ] + )] + /** + * Display all results. + * @codeCoverageIgnore + */ + public function resultAction( + EditCounterRepository $editCounterRepo, + UserRightsRepository $userRightsRepo, + RequestStack $requestStack, + AutomatedEditsHelper $autoEditsHelper + ): Response|RedirectResponse { + $this->setUpEditCounter( $editCounterRepo, $userRightsRepo, $requestStack, $autoEditsHelper ); + + if ( count( $this->sections ) === 1 ) { + // Redirect to dedicated route. + return $this->redirectToRoute( self::AVAILABLE_SECTIONS[$this->sections[0]], $this->params ); + } + + $ret = [ + 'xtTitle' => $this->user->getUsername() . ' - ' . $this->project->getTitle(), + 'xtPage' => 'EditCounter', + 'user' => $this->user, + 'project' => $this->project, + 'ec' => $this->editCounter, + 'sections' => $this->sections, + 'isAllSections' => $this->sections === $this->getSectionNames(), + ]; + + // Used when querying for global rights changes. + if ( $this->isWMF ) { + $ret['metaProject'] = $this->projectRepo->getProject( 'metawiki' ); + } + + return $this->getFormattedResponse( 'editCounter/result', $ret ); + } + + #[Route( + "/ec-generalstats/{project}/{username}", + name: "EditCounterGeneralStats", + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + ] + )] + /** + * Display the general statistics section. + * @codeCoverageIgnore + */ + public function generalStatsAction( + EditCounterRepository $editCounterRepo, + UserRightsRepository $userRightsRepo, + GlobalContribsRepository $globalContribsRepo, + EditRepository $editRepo, + RequestStack $requestStack, + AutomatedEditsHelper $autoEditsHelper + ): Response { + $this->setUpEditCounter( $editCounterRepo, $userRightsRepo, $requestStack, $autoEditsHelper ); + + $globalContribs = new GlobalContribs( + $globalContribsRepo, + $this->pageRepo, + $this->userRepo, + $editRepo, + $this->user + ); + $ret = [ + 'xtTitle' => $this->user->getUsername(), + 'xtPage' => 'EditCounter', + 'subtool_msg_key' => 'general-stats', + 'is_sub_request' => $this->isSubRequest, + 'user' => $this->user, + 'project' => $this->project, + 'ec' => $this->editCounter, + 'gc' => $globalContribs, + ]; + + // Output the relevant format template. + return $this->getFormattedResponse( 'editCounter/general_stats', $ret ); + } + + #[Route( + "/ec-generalstats", + name: "EditCounterGeneralStatsIndex", + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + ] + )] + /** + * Search form for general stats. + */ + public function generalStatsIndexAction(): Response { + $this->sections = [ 'general-stats' ]; + return $this->indexAction(); + } + + #[Route( + "/ec-namespacetotals/{project}/{username}", + name: "EditCounterNamespaceTotals", + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + ] + )] + /** + * Display the namespace totals section. + * @codeCoverageIgnore + */ + public function namespaceTotalsAction( + EditCounterRepository $editCounterRepo, + UserRightsRepository $userRightsRepo, + RequestStack $requestStack, + AutomatedEditsHelper $autoEditsHelper + ): Response { + $this->setUpEditCounter( $editCounterRepo, $userRightsRepo, $requestStack, $autoEditsHelper ); + + $ret = [ + 'xtTitle' => $this->user->getUsername(), + 'xtPage' => 'EditCounter', + 'subtool_msg_key' => 'namespace-totals', + 'is_sub_request' => $this->isSubRequest, + 'user' => $this->user, + 'project' => $this->project, + 'ec' => $this->editCounter, + ]; + + // Output the relevant format template. + return $this->getFormattedResponse( 'editCounter/namespace_totals', $ret ); + } + + #[Route( "/ec-namespacetotals", name: "EditCounterNamespaceTotalsIndex" )] + /** + * Search form for namespace totals. + */ + public function namespaceTotalsIndexAction(): Response { + $this->sections = [ 'namespace-totals' ]; + return $this->indexAction(); + } + + #[Route( + "/ec-timecard/{project}/{username}", + name: "EditCounterTimecard", + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + ] + )] + /** + * Display the timecard section. + * @codeCoverageIgnore + */ + public function timecardAction( + EditCounterRepository $editCounterRepo, + UserRightsRepository $userRightsRepo, + RequestStack $requestStack, + AutomatedEditsHelper $autoEditsHelper + ): Response { + $this->setUpEditCounter( $editCounterRepo, $userRightsRepo, $requestStack, $autoEditsHelper ); + + $ret = [ + 'xtTitle' => $this->user->getUsername(), + 'xtPage' => 'EditCounter', + 'subtool_msg_key' => 'timecard', + 'is_sub_request' => $this->isSubRequest, + 'user' => $this->user, + 'project' => $this->project, + 'ec' => $this->editCounter, + 'opted_in_page' => $this->getOptedInPage(), + ]; + + // Output the relevant format template. + return $this->getFormattedResponse( 'editCounter/timecard', $ret ); + } + + #[Route( "/ec-timecard", name: "EditCounterTimecardIndex" )] + /** + * Search form for timecard. + */ + public function timecardIndexAction(): Response { + $this->sections = [ 'timecard' ]; + return $this->indexAction(); + } + + #[Route( + "/ec-yearcounts/{project}/{username}", + name: "EditCounterYearCounts", + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + ] + )] + /** + * Display the year counts section. + * @codeCoverageIgnore + */ + public function yearCountsAction( + EditCounterRepository $editCounterRepo, + UserRightsRepository $userRightsRepo, + RequestStack $requestStack, + AutomatedEditsHelper $autoEditsHelper + ): Response { + $this->setUpEditCounter( $editCounterRepo, $userRightsRepo, $requestStack, $autoEditsHelper ); + + $ret = [ + 'xtTitle' => $this->user->getUsername(), + 'xtPage' => 'EditCounter', + 'subtool_msg_key' => 'year-counts', + 'is_sub_request' => $this->isSubRequest, + 'user' => $this->user, + 'project' => $this->project, + 'ec' => $this->editCounter, + ]; + + // Output the relevant format template. + return $this->getFormattedResponse( 'editCounter/yearcounts', $ret ); + } + + #[Route( "/ec-yearcounts", name: "EditCounterYearCountsIndex" )] + /** + * Search form for year counts. + */ + public function yearCountsIndexAction(): Response { + $this->sections = [ 'year-counts' ]; + return $this->indexAction(); + } + + #[Route( + "/ec-monthcounts/{project}/{username}", + name: "EditCounterMonthCounts", + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + ] + )] + /** + * Display the month counts section. + * @codeCoverageIgnore + */ + public function monthCountsAction( + EditCounterRepository $editCounterRepo, + UserRightsRepository $userRightsRepo, + RequestStack $requestStack, + AutomatedEditsHelper $autoEditsHelper + ): Response { + $this->setUpEditCounter( $editCounterRepo, $userRightsRepo, $requestStack, $autoEditsHelper ); + + $ret = [ + 'xtTitle' => $this->user->getUsername(), + 'xtPage' => 'EditCounter', + 'subtool_msg_key' => 'month-counts', + 'is_sub_request' => $this->isSubRequest, + 'user' => $this->user, + 'project' => $this->project, + 'ec' => $this->editCounter, + 'opted_in_page' => $this->getOptedInPage(), + ]; + + // Output the relevant format template. + return $this->getFormattedResponse( 'editCounter/monthcounts', $ret ); + } + + #[Route( "/ec-monthcounts", name: "EditCounterMonthCountsIndex" )] + /** + * Search form for month counts. + */ + public function monthCountsIndexAction(): Response { + $this->sections = [ 'month-counts' ]; + return $this->indexAction(); + } + + #[Route( + "/ec-rightschanges/{project}/{username}", + name: "EditCounterRightsChanges", + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + ] + )] + /** + * Display the user rights changes section. + * @codeCoverageIgnore + */ + public function rightsChangesAction( + EditCounterRepository $editCounterRepo, + UserRightsRepository $userRightsRepo, + RequestStack $requestStack, + AutomatedEditsHelper $autoEditsHelper + ): Response { + $this->setUpEditCounter( $editCounterRepo, $userRightsRepo, $requestStack, $autoEditsHelper ); + + $ret = [ + 'xtTitle' => $this->user->getUsername(), + 'xtPage' => 'EditCounter', + 'is_sub_request' => $this->isSubRequest, + 'user' => $this->user, + 'project' => $this->project, + 'ec' => $this->editCounter, + ]; + + if ( $this->isWMF ) { + $ret['metaProject'] = $this->projectRepo->getProject( 'metawiki' ); + } + + // Output the relevant format template. + return $this->getFormattedResponse( 'editCounter/rights_changes', $ret ); + } + + /** + * Search form for rights changes. + */ + #[Route( "/ec-rightschanges", name: "EditCounterRightsChangesIndex" )] + public function rightsChangesIndexAction(): Response { + $this->sections = [ 'rights-changes' ]; + return $this->indexAction(); + } + + /************************ API endpoints */ + + #[OA\Tag( name: "User API" )] + #[OA\Get( description: + "Get counts of various logged actions made by a user. The keys of the returned `log_counts` " . + "property describe the log type and log action in the form of _type-action_. " . + "See also the [logevents API](https://www.mediawiki.org/wiki/Special:MyLanguage/API:Logevents)." + )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Parameter( ref: "#/components/parameters/UsernameOrIp" )] + #[OA\Response( + response: 200, + description: "Counts of logged actions", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "username", ref: "#/components/parameters/UsernameOrIp/schema" ), + new OA\Property( + property: "log_counts", + type: "object", + example: [ + "block-block" => 0, + "block-unblock" => 0, + "protect-protect" => 0, + "protect-unprotect" => 0, + "move-move" => 0, + "move-move_redir" => 0 + ] + ), + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[OA\Response( ref: "#/components/responses/501", response: 501 )] + #[OA\Response( ref: "#/components/responses/503", response: 503 )] + #[OA\Response( ref: "#/components/responses/504", response: 504 )] + #[Route( + "/api/user/log_counts/{project}/{username}", + name: "UserApiLogCounts", + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + ], + methods: [ "GET" ] + )] + /** + * Get counts of various log actions made by the user. + * @codeCoverageIgnore + */ + public function logCountsApiAction( + EditCounterRepository $editCounterRepo, + UserRightsRepository $userRightsRepo, + RequestStack $requestStack, + AutomatedEditsHelper $autoEditsHelper + ): JsonResponse { + $this->setUpEditCounter( $editCounterRepo, $userRightsRepo, $requestStack, $autoEditsHelper ); + + return $this->getFormattedApiResponse( [ + 'log_counts' => $this->editCounter->getLogCounts(), + ] ); + } + + #[OA\Tag( name: "User API" )] + #[OA\Get( description: "Get edit counts of a user broken down by [namespace](https://w.wiki/6oKq)." )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Parameter( ref: "#/components/parameters/UsernameOrIp" )] + #[OA\Response( + response: 200, + description: "Namespace totals", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "username", ref: "#/components/parameters/UsernameOrIp/schema" ), + new OA\Property( + property: "namespace_totals", + description: "Keys are namespace IDs, values are edit counts.", + type: "object", + example: [ "0" => 50, "2" => 10, "3" => 100 ] + ), + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[OA\Response( ref: "#/components/responses/501", response: 501 )] + #[OA\Response( ref: "#/components/responses/503", response: 503 )] + #[OA\Response( ref: "#/components/responses/504", response: 504 )] + #[Route( + "/api/user/namespace_totals/{project}/{username}", + name: "UserApiNamespaceTotals", + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + ], + methods: [ "GET" ] + )] + /** + * Get the number of edits made by the user to each namespace. + * @codeCoverageIgnore + */ + public function namespaceTotalsApiAction( + EditCounterRepository $editCounterRepo, + UserRightsRepository $userRightsRepo, + RequestStack $requestStack, + AutomatedEditsHelper $autoEditsHelper + ): JsonResponse { + $this->setUpEditCounter( $editCounterRepo, $userRightsRepo, $requestStack, $autoEditsHelper ); + + return $this->getFormattedApiResponse( [ + 'namespace_totals' => (object)$this->editCounter->namespaceTotals(), + ] ); + } + + #[OA\Tag( name: "User API" )] + #[OA\Get( description: "Get the number of edits a user has made grouped by namespace and month." )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Parameter( ref: "#/components/parameters/UsernameOrIp" )] + #[OA\Response( + response: 200, + description: "Month counts", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "username", ref: "#/components/parameters/UsernameOrIp/schema" ), + new OA\Property( + property: "totals", + type: "object", + example: [ + "0" => [ + "2020-11" => 40, + "2020-12" => 50, + "2021-01" => 5, + ], + "3" => [ + "2020-11" => 0, + "2020-12" => 10, + "2021-01" => 0, + ], + ] + ), + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[OA\Response( ref: "#/components/responses/501", response: 501 )] + #[OA\Response( ref: "#/components/responses/503", response: 503 )] + #[OA\Response( ref: "#/components/responses/504", response: 504 )] + #[Route( + "/api/user/month_counts/{project}/{username}", + name: "UserApiMonthCounts", + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + ], + methods: [ "GET" ] + )] + /** + * Get the number of edits made by the user for each month, grouped by namespace. + * @codeCoverageIgnore + */ + public function monthCountsApiAction( + EditCounterRepository $editCounterRepo, + UserRightsRepository $userRightsRepo, + RequestStack $requestStack, + AutomatedEditsHelper $autoEditsHelper + ): JsonResponse { + $this->setUpEditCounter( $editCounterRepo, $userRightsRepo, $requestStack, $autoEditsHelper ); + + $ret = $this->editCounter->monthCounts(); + + // Remove labels that are only needed by Twig views, and not consumers of the API. + unset( $ret['yearLabels'] ); + unset( $ret['monthLabels'] ); + + // Ensure 'totals' keys are strings, see T292031. + $ret['totals'] = (object)$ret['totals']; + + return $this->getFormattedApiResponse( $ret ); + } + + #[OA\Tag( name: "User API" )] + #[OA\Get( description: + "Get the raw number of edits made by a user during each hour of day and day of week. " . + "The `scale` is a value that indicates the number of edits made relative to other hours and days of the week." + )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Parameter( ref: "#/components/parameters/UsernameOrIp" )] + #[OA\Response( + response: 200, + description: "Timecard", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "username", ref: "#/components/parameters/UsernameOrIp/schema" ), + new OA\Property( + property: "timecard", + type: "array", + items: new OA\Items( type: "object" ), + example: [ + [ + "day_of_week" => 1, + "hour" => 0, + "value" => 50, + "scale" => 5, + ], + ] + ), + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[OA\Response( ref: "#/components/responses/501", response: 501 )] + #[OA\Response( ref: "#/components/responses/503", response: 503 )] + #[OA\Response( ref: "#/components/responses/504", response: 504 )] + #[Route( + "/api/user/timecard/{project}/{username}", + name: "UserApiTimeCard", + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + ], + methods: [ "GET" ] + )] + /** + * Get the total number of edits made by a user during each hour of day and day of week. + * @codeCoverageIgnore + */ + public function timecardApiAction( + EditCounterRepository $editCounterRepo, + UserRightsRepository $userRightsRepo, + RequestStack $requestStack, + AutomatedEditsHelper $autoEditsHelper + ): JsonResponse { + $this->setUpEditCounter( $editCounterRepo, $userRightsRepo, $requestStack, $autoEditsHelper ); + + return $this->getFormattedApiResponse( [ + 'timecard' => $this->editCounter->timeCard(), + ] ); + } } diff --git a/src/Controller/EditSummaryController.php b/src/Controller/EditSummaryController.php index 580645048..a2592e544 100644 --- a/src/Controller/EditSummaryController.php +++ b/src/Controller/EditSummaryController.php @@ -1,12 +1,12 @@ params['project']) && isset($this->params['username'])) { - return $this->redirectToRoute('EditSummaryResult', $this->params); - } + /** + * The Edit Summary search form. + */ + #[Route( '/editsummary', name: 'EditSummary' )] + #[Route( '/editsummary/index.php', name: 'EditSummaryIndexPhp' )] + #[Route( '/editsummary/{project}', name: 'EditSummaryProject' )] + public function indexAction(): Response { + // If we've got a project, user, and namespace, redirect to results. + if ( isset( $this->params['project'] ) && isset( $this->params['username'] ) ) { + return $this->redirectToRoute( 'EditSummaryResult', $this->params ); + } - // Show the form. - return $this->render('editSummary/index.html.twig', array_merge([ - 'xtPageTitle' => 'tool-editsummary', - 'xtSubtitle' => 'tool-editsummary-desc', - 'xtPage' => 'EditSummary', + // Show the form. + return $this->render( 'editSummary/index.html.twig', array_merge( [ + 'xtPageTitle' => 'tool-editsummary', + 'xtSubtitle' => 'tool-editsummary-desc', + 'xtPage' => 'EditSummary', - // Defaults that will get overridden if in $params. - 'username' => '', - 'namespace' => 0, - 'start' => '', - 'end' => '', - ], $this->params, ['project' => $this->project])); - } + // Defaults that will get overridden if in $params. + 'username' => '', + 'namespace' => 0, + 'start' => '', + 'end' => '', + ], $this->params, [ 'project' => $this->project ] ) ); + } - /** - * Display the Edit Summary results - * @codeCoverageIgnore - */ - #[Route( - "/editsummary/{project}/{username}/{namespace}/{start}/{end}", - name: 'EditSummaryResult', - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - "namespace" => "|all|\d+", - "start" => "|\d{4}-\d{2}-\d{2}-\d{2}", - "end" => "|\d{4}-\d{2}-\d{2}-\d{2}", - ], - defaults: ["namespace" => "all", "start" => false, "end" => false] - )] - public function resultAction(EditSummaryRepository $editSummaryRepo): Response - { - // Instantiate an EditSummary, treating the past 150 edits as 'recent'. - $editSummary = new EditSummary( - $editSummaryRepo, - $this->project, - $this->user, - $this->namespace, - $this->start, - $this->end, - 150 - ); - $editSummary->prepareData(); + #[Route( + "/editsummary/{project}/{username}/{namespace}/{start}/{end}", + name: 'EditSummaryResult', + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + "namespace" => "|all|\d+", + "start" => "|\d{4}-\d{2}-\d{2}-\d{2}", + "end" => "|\d{4}-\d{2}-\d{2}-\d{2}", + ], + defaults: [ "namespace" => "all", "start" => false, "end" => false ] + )] + /** + * Display the Edit Summary results + * @codeCoverageIgnore + */ + public function resultAction( EditSummaryRepository $editSummaryRepo ): Response { + // Instantiate an EditSummary, treating the past 150 edits as 'recent'. + $editSummary = new EditSummary( + $editSummaryRepo, + $this->project, + $this->user, + $this->namespace, + $this->start, + $this->end, + 150 + ); + $editSummary->prepareData(); - return $this->getFormattedResponse('editSummary/result', [ - 'xtPage' => 'EditSummary', - 'xtTitle' => $this->user->getUsername(), - 'es' => $editSummary, - ]); - } + return $this->getFormattedResponse( 'editSummary/result', [ + 'xtPage' => 'EditSummary', + 'xtTitle' => $this->user->getUsername(), + 'es' => $editSummary, + ] ); + } - /************************ API endpoints ************************/ + /************************ API endpoints */ - /** - * Get statistics on how many times a user has used edit summaries. - * @OA\Tag(name="User API") - * @OA\Get(description="Get edit summage usage statistics for the user, with a month-by-month breakdown.") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Parameter(ref="#/components/parameters/UsernameOrIp") - * @OA\Parameter(ref="#/components/parameters/Namespace") - * @OA\Parameter(ref="#/components/parameters/Start") - * @OA\Parameter(ref="#/components/parameters/End") - * @OA\Response( - * response=200, - * description="Edit summary usage statistics", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="username", ref="#/components/parameters/UsernameOrIp/schema"), - * @OA\Property(property="namespace", ref="#/components/schemas/Namespace"), - * @OA\Property(property="start", ref="#/components/parameters/Start/schema"), - * @OA\Property(property="end", ref="#/components/parameters/End/schema"), - * @OA\Property(property="recent_edits_minor", type="integer", - * description="Number of minor edits within the last 150 edits"), - * @OA\Property(property="recent_edits_major", type="integer", - * description="Number of non-minor edits within the last 150 edits"), - * @OA\Property(property="total_edits_minor", type="integer", - * description="Total number of minor edits"), - * @OA\Property(property="total_edits_major", type="integer", - * description="Total number of non-minor edits"), - * @OA\Property(property="total_edits", type="integer", description="Total number of edits"), - * @OA\Property(property="recent_summaries_minor", type="integer", - * description="Number of minor edits with summaries within the last 150 edits"), - * @OA\Property(property="recent_summaries_major", type="integer", - * description="Number of non-minor edits with summaries within the last 150 edits"), - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=501, ref="#/components/responses/501") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - * @codeCoverageIgnore - */ - #[Route( - "/api/user/edit_summaries/{project}/{username}/{namespace}/{start}/{end}", - name: 'UserApiEditSummaries', - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - "namespace" => "|all|\d+", - "start" => "|\d{4}-\d{2}-\d{2}-\d{2}", - "end" => "|\d{4}-\d{2}-\d{2}-\d{2}", - ], - defaults: ["namespace" => "all", "start" => false, "end" => false], - methods: ["GET"] - )] - public function editSummariesApiAction(EditSummaryRepository $editSummaryRepo): JsonResponse - { - $this->recordApiUsage('user/edit_summaries'); + #[OA\Tag( name: "User API" )] + #[OA\Get( description: "Get edit summage usage statistics for the user, with a month-by-month breakdown." )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Parameter( ref: "#/components/parameters/UsernameOrIp" )] + #[OA\Parameter( ref: "#/components/parameters/Namespace" )] + #[OA\Parameter( ref: "#/components/parameters/Start" )] + #[OA\Parameter( ref: "#/components/parameters/End" )] + #[OA\Response( + response: 200, + description: "Edit summary usage statistics", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "username", ref: "#/components/parameters/UsernameOrIp/schema" ), + new OA\Property( property: "namespace", ref: "#/components/schemas/Namespace" ), + new OA\Property( property: "start", ref: "#/components/parameters/Start/schema" ), + new OA\Property( property: "end", ref: "#/components/parameters/End/schema" ), + new OA\Property( + property: "recent_edits_minor", + description: "Number of minor edits within the last 150 edits", + type: "integer" + ), + new OA\Property( + property: "recent_edits_major", + description: "Number of non-minor edits within the last 150 edits", + type: "integer" + ), + new OA\Property( + property: "total_edits_minor", + description: "Total number of minor edits", + type: "integer" + ), + new OA\Property( + property: "total_edits_major", + description: "Total number of non-minor edits", + type: "integer" + ), + new OA\Property( + property: "total_edits", + description: "Total number of edits", + type: "integer" + ), + new OA\Property( + property: "recent_summaries_minor", + description: "Number of minor edits with summaries within the last 150 edits", + type: "integer" + ), + new OA\Property( + property: "recent_summaries_major", + description: "Number of non-minor edits with summaries within the last 150 edits", + type: "integer" + ), + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[OA\Response( ref: "#/components/responses/501", response: 501 )] + #[OA\Response( ref: "#/components/responses/503", response: 503 )] + #[OA\Response( ref: "#/components/responses/504", response: 504 )] + #[Route( + "/api/user/edit_summaries/{project}/{username}/{namespace}/{start}/{end}", + name: 'UserApiEditSummaries', + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + "namespace" => "|all|\d+", + "start" => "|\d{4}-\d{2}-\d{2}-\d{2}", + "end" => "|\d{4}-\d{2}-\d{2}-\d{2}", + ], + defaults: [ "namespace" => "all", "start" => false, "end" => false ], + methods: [ "GET" ] + )] + /** + * Get statistics on how many times a user has used edit summaries. + * @codeCoverageIgnore + */ + public function editSummariesApiAction( EditSummaryRepository $editSummaryRepo ): JsonResponse { + $this->recordApiUsage( 'user/edit_summaries' ); - // Instantiate an EditSummary, treating the past 150 edits as 'recent'. - $editSummary = new EditSummary( - $editSummaryRepo, - $this->project, - $this->user, - $this->namespace, - $this->start, - $this->end, - 150 - ); - $editSummary->prepareData(); + // Instantiate an EditSummary, treating the past 150 edits as 'recent'. + $editSummary = new EditSummary( + $editSummaryRepo, + $this->project, + $this->user, + $this->namespace, + $this->start, + $this->end, + 150 + ); + $editSummary->prepareData(); - return $this->getFormattedApiResponse($editSummary->getData()); - } + return $this->getFormattedApiResponse( $editSummary->getData() ); + } } diff --git a/src/Controller/GlobalContribsController.php b/src/Controller/GlobalContribsController.php index c432fae1f..85d208122 100644 --- a/src/Controller/GlobalContribsController.php +++ b/src/Controller/GlobalContribsController.php @@ -1,6 +1,6 @@ params['username'])) { - return $this->redirectToRoute('GlobalContribsResult', $this->params); - } - - // FIXME: Nasty hack until T226072 is resolved. - $project = $this->projectRepo->getProject($this->i18n->getLang().'.wikipedia'); - if (!$project->exists()) { - $project = $this->projectRepo->getProject($centralAuthProject); - } - - return $this->render('globalContribs/index.html.twig', array_merge([ - 'xtPage' => 'GlobalContribs', - 'xtPageTitle' => 'tool-globalcontribs', - 'xtSubtitle' => 'tool-globalcontribs-desc', - 'project' => $project, - - // Defaults that will get overridden if in $this->params. - 'namespace' => 'all', - 'start' => '', - 'end' => '', - ], $this->params)); - } - - /** - * @codeCoverageIgnore - */ - public function getGlobalContribs( - GlobalContribsRepository $globalContribsRepo, - EditRepository $editRepo - ): GlobalContribs { - return new GlobalContribs( - $globalContribsRepo, - $this->pageRepo, - $this->userRepo, - $editRepo, - $this->user, - $this->namespace, - $this->start, - $this->end, - $this->offset, - $this->limit - ); - } - - /** - * Display the latest global edits tool. First two routes are legacy. - * @codeCoverageIgnore - */ - #[Route( - "/globalcontribs/{username}/{namespace}/{start}/{end}/{offset}", - name: "GlobalContribsResult", - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - "namespace" => "|all|\d+", - "start" => "|\d*|\d{4}-\d{2}-\d{2}", - "end" => "|\d{4}-\d{2}-\d{2}", - "offset" => "|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?", - ], - defaults: [ - "namespace" => "all", - "start" => false, - "end" => false, - "offset" => false, - ] - )] - public function resultsAction( - GlobalContribsRepository $globalContribsRepo, - EditRepository $editRepo, - string $centralAuthProject - ): Response { - $globalContribs = $this->getGlobalContribs($globalContribsRepo, $editRepo); - $defaultProject = $this->projectRepo->getProject($centralAuthProject); - - return $this->render('globalContribs/result.html.twig', [ - 'xtTitle' => $this->user->getUsername(), - 'xtPage' => 'GlobalContribs', - 'is_sub_request' => $this->isSubRequest, - 'user' => $this->user, - 'project' => $defaultProject, - 'gc' => $globalContribs, - ]); - } - - /************************ API endpoints ************************/ - - /** - * Get global edits made by a user, IP or IP range. - * @OA\Tag(name="User API") - * @OA\Get(description="Get contributions made by a user, IP or IP range across all Wikimedia projects.") - * @OA\Parameter(ref="#/components/parameters/UsernameOrIp") - * @OA\Parameter(ref="#/components/parameters/Namespace") - * @OA\Parameter(ref="#/components/parameters/Start") - * @OA\Parameter(ref="#/components/parameters/End") - * @OA\Parameter(ref="#/components/parameters/Offset") - * @OA\Response( - * response=200, - * description="Global contributions", - * @OA\JsonContent( - * @OA\Property(property="project", type="string", example="meta.wikimedia.org"), - * @OA\Property(property="username", ref="#/components/parameters/Username/schema"), - * @OA\Property(property="namespace", ref="#/components/schemas/Namespace"), - * @OA\Property(property="start", ref="#/components/parameters/Start/schema"), - * @OA\Property(property="end", ref="#/components/parameters/End/schema"), - * @OA\Property(property="globalcontribs", type="array", - * @OA\Items(ref="#/components/schemas/EditWithProject") - * ), - * @OA\Property(property="continue", type="date-time", example="2020-01-31T12:59:59Z"), - * @OA\Property(property="elapsed_time", ref="#/components/schemas/elapsed_time") - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=501, ref="#/components/responses/501") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - * @codeCoverageIgnore - */ - #[Route( - "/api/user/globalcontribs/{username}/{namespace}/{start}/{end}/{offset}", - name: "UserApiGlobalContribs", - requirements: [ - "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", - "namespace" => "|all|\d+", - "start" => "|\d*|\d{4}-\d{2}-\d{2}", - "end" => "|\d{4}-\d{2}-\d{2}", - "offset" => "|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?", - ], - defaults: [ - "namespace" => "all", - "start" => false, - "end" => false, - "offset" => false, - "limit" => 50, - ], - methods: ["GET"] - )] - public function resultsApiAction( - GlobalContribsRepository $globalContribsRepo, - EditRepository $editRepo, - string $centralAuthProject - ): JsonResponse { - $this->recordApiUsage('user/globalcontribs'); - - $globalContribs = $this->getGlobalContribs($globalContribsRepo, $editRepo); - $defaultProject = $this->projectRepo->getProject($centralAuthProject); - $this->project = $defaultProject; - - $results = $globalContribs->globalEdits(); - $results = array_map(function (Edit $edit) { - return $edit->getForJson(true); - }, array_values($results)); - $results = $this->addFullPageTitlesAndContinue('globalcontribs', [], $results); - - return $this->getFormattedApiResponse($results); - } +class GlobalContribsController extends XtoolsController { + + /** + * @inheritDoc + * @codeCoverageIgnore + */ + public function getIndexRoute(): string { + return 'GlobalContribs'; + } + + /** + * GlobalContribs can be very slow, especially for wide IP ranges, so limit to max 500 results. + * @inheritDoc + * @codeCoverageIgnore + */ + public function maxLimit(): int { + return 500; + } + + /** + * The search form. + */ + #[Route( '/globalcontribs', name: 'GlobalContribs' )] + public function indexAction( string $centralAuthProject ): Response { + // Redirect if username is given. + if ( isset( $this->params['username'] ) ) { + return $this->redirectToRoute( 'GlobalContribsResult', $this->params ); + } + + // FIXME: Nasty hack until T226072 is resolved. + $project = $this->projectRepo->getProject( $this->i18n->getLang() . '.wikipedia' ); + if ( !$project->exists() ) { + $project = $this->projectRepo->getProject( $centralAuthProject ); + } + + return $this->render( 'globalContribs/index.html.twig', array_merge( [ + 'xtPage' => 'GlobalContribs', + 'xtPageTitle' => 'tool-globalcontribs', + 'xtSubtitle' => 'tool-globalcontribs-desc', + 'project' => $project, + + // Defaults that will get overridden if in $this->params. + 'namespace' => 'all', + 'start' => '', + 'end' => '', + ], $this->params ) ); + } + + /** + * @codeCoverageIgnore + */ + public function getGlobalContribs( + GlobalContribsRepository $globalContribsRepo, + EditRepository $editRepo + ): GlobalContribs { + return new GlobalContribs( + $globalContribsRepo, + $this->pageRepo, + $this->userRepo, + $editRepo, + $this->user, + $this->namespace, + $this->start, + $this->end, + $this->offset, + $this->limit + ); + } + + #[Route( + "/globalcontribs/{username}/{namespace}/{start}/{end}/{offset}", + name: "GlobalContribsResult", + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + "namespace" => "|all|\d+", + "start" => "|\d*|\d{4}-\d{2}-\d{2}", + "end" => "|\d{4}-\d{2}-\d{2}", + "offset" => "|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?", + ], + defaults: [ + "namespace" => "all", + "start" => false, + "end" => false, + "offset" => false, + ] + )] + /** + * Display the latest global edits tool. First two routes are legacy. + * @codeCoverageIgnore + */ + public function resultsAction( + GlobalContribsRepository $globalContribsRepo, + EditRepository $editRepo, + string $centralAuthProject + ): Response { + $globalContribs = $this->getGlobalContribs( $globalContribsRepo, $editRepo ); + $defaultProject = $this->projectRepo->getProject( $centralAuthProject ); + + return $this->render( 'globalContribs/result.html.twig', [ + 'xtTitle' => $this->user->getUsername(), + 'xtPage' => 'GlobalContribs', + 'is_sub_request' => $this->isSubRequest, + 'user' => $this->user, + 'project' => $defaultProject, + 'gc' => $globalContribs, + ] ); + } + + /************************ API endpoints */ + + #[OA\Tag( name: "User API" )] + #[OA\Get( description: "Get contributions made by a user, IP or IP range across all Wikimedia projects." )] + #[OA\Parameter( ref: "#/components/parameters/UsernameOrIp" )] + #[OA\Parameter( ref: "#/components/parameters/Namespace" )] + #[OA\Parameter( ref: "#/components/parameters/Start" )] + #[OA\Parameter( ref: "#/components/parameters/End" )] + #[OA\Parameter( ref: "#/components/parameters/Offset" )] + #[OA\Response( + response: 200, + description: "Global contributions", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", type: "string", example: "meta.wikimedia.org" ), + new OA\Property( property: "username", ref: "#/components/parameters/Username/schema" ), + new OA\Property( property: "namespace", ref: "#/components/schemas/Namespace" ), + new OA\Property( property: "start", ref: "#/components/parameters/Start/schema" ), + new OA\Property( property: "end", ref: "#/components/parameters/End/schema" ), + new OA\Property( + property: "globalcontribs", + type: "array", + items: new OA\Items( ref: "#/components/schemas/EditWithProject" ) + ), + new OA\Property( + property: "continue", type: "string", format: "date-time", example: "2020-01-31T12:59:59Z" + ), + new OA\Property( property: "elapsed_time", ref: "#/components/schemas/elapsed_time" ), + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[OA\Response( ref: "#/components/responses/501", response: 501 )] + #[OA\Response( ref: "#/components/responses/503", response: 503 )] + #[OA\Response( ref: "#/components/responses/504", response: 504 )] + #[Route( + "/api/user/globalcontribs/{username}/{namespace}/{start}/{end}/{offset}", + name: "UserApiGlobalContribs", + requirements: [ + "username" => "(ipr-.+\/\d+[^\/])|([^\/]+)", + "namespace" => "|all|\d+", + "start" => "|\d*|\d{4}-\d{2}-\d{2}", + "end" => "|\d{4}-\d{2}-\d{2}", + "offset" => "|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?", + ], + defaults: [ + "namespace" => "all", + "start" => false, + "end" => false, + "offset" => false, + "limit" => 50, + ], + methods: [ "GET" ] + )] + /** + * Get global contributions made by a user, IP or IP range. + * @codeCoverageIgnore + */ + public function resultsApiAction( + GlobalContribsRepository $globalContribsRepo, + EditRepository $editRepo, + string $centralAuthProject + ): JsonResponse { + $this->recordApiUsage( 'user/globalcontribs' ); + + $globalContribs = $this->getGlobalContribs( $globalContribsRepo, $editRepo ); + $defaultProject = $this->projectRepo->getProject( $centralAuthProject ); + $this->project = $defaultProject; + + $results = $globalContribs->globalEdits(); + $results = array_map( static function ( Edit $edit ) { + return $edit->getForJson( true ); + }, array_values( $results ) ); + $results = $this->addFullPageTitlesAndContinue( 'globalcontribs', [], $results ); + + return $this->getFormattedApiResponse( $results ); + } } diff --git a/src/Controller/LargestPagesController.php b/src/Controller/LargestPagesController.php index 0aa45b1a9..4c7a79ab8 100644 --- a/src/Controller/LargestPagesController.php +++ b/src/Controller/LargestPagesController.php @@ -1,12 +1,12 @@ params['project'])) { - return $this->redirectToRoute('LargestPagesResult', $this->params); - } + #[Route( path: '/largestpages', name: 'LargestPages' )] + /** + * The search form. + */ + public function indexAction(): Response { + // Redirect if required params are given. + if ( isset( $this->params['project'] ) ) { + return $this->redirectToRoute( 'LargestPagesResult', $this->params ); + } - return $this->render('largestPages/index.html.twig', array_merge([ - 'xtPage' => 'LargestPages', - 'xtPageTitle' => 'tool-largestpages', - 'xtSubtitle' => 'tool-largestpages-desc', + return $this->render( 'largestPages/index.html.twig', array_merge( [ + 'xtPage' => 'LargestPages', + 'xtPageTitle' => 'tool-largestpages', + 'xtSubtitle' => 'tool-largestpages-desc', - // Defaults that will get overriden if in $this->params. - 'project' => $this->project, - 'namespace' => 'all', - 'include_pattern' => '', - 'exclude_pattern' => '', - ], $this->params)); - } + // Defaults that will get overriden if in $this->params. + 'project' => $this->project, + 'namespace' => 'all', + 'include_pattern' => '', + 'exclude_pattern' => '', + ], $this->params ) ); + } - /** - * Instantiate a LargestPages object. - * @param LargestPagesRepository $largestPagesRepo - * @return LargestPages - * @codeCoverageIgnore - */ - protected function getLargestPages(LargestPagesRepository $largestPagesRepo): LargestPages - { - $this->params['include_pattern'] = $this->request->get('include_pattern', ''); - $this->params['exclude_pattern'] = $this->request->get('exclude_pattern', ''); - $largestPages = new LargestPages( - $largestPagesRepo, - $this->project, - $this->namespace, - $this->params['include_pattern'], - $this->params['exclude_pattern'] - ); - $largestPages->setRepository($largestPagesRepo); - return $largestPages; - } + /** + * Instantiate a LargestPages object. + * @param LargestPagesRepository $largestPagesRepo + * @return LargestPages + * @codeCoverageIgnore + */ + protected function getLargestPages( LargestPagesRepository $largestPagesRepo ): LargestPages { + $this->params['include_pattern'] = $this->request->get( 'include_pattern', '' ); + $this->params['exclude_pattern'] = $this->request->get( 'exclude_pattern', '' ); + $largestPages = new LargestPages( + $largestPagesRepo, + $this->project, + $this->namespace, + $this->params['include_pattern'], + $this->params['exclude_pattern'] + ); + $largestPages->setRepository( $largestPagesRepo ); + return $largestPages; + } - /** - * Display the largest pages on the requested project. - * @codeCoverageIgnore - */ - #[Route( - path: '/largestpages/{project}/{namespace}', - name: 'LargestPagesResult', - defaults: ['namespace' => 'all'] - )] - public function resultsAction(LargestPagesRepository $largestPagesRepo): Response - { - $ret = [ - 'xtPage' => 'LargestPages', - 'xtTitle' => $this->project->getDomain(), - 'lp' => $this->getLargestPages($largestPagesRepo), - ]; + #[Route( + path: '/largestpages/{project}/{namespace}', + name: 'LargestPagesResult', + defaults: [ 'namespace' => 'all' ] + )] + /** + * Display the largest pages on the requested project. + * @codeCoverageIgnore + */ + public function resultsAction( LargestPagesRepository $largestPagesRepo ): Response { + $ret = [ + 'xtPage' => 'LargestPages', + 'xtTitle' => $this->project->getDomain(), + 'lp' => $this->getLargestPages( $largestPagesRepo ), + ]; - return $this->getFormattedResponse('largestPages/result', $ret); - } + return $this->getFormattedResponse( 'largestPages/result', $ret ); + } - /************************ API endpoints ************************/ + /************************ API endpoints */ - /** - * Get the largest pages on a project. - * @OA\Tag(name="Project API") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Parameter(ref="#/components/parameters/Namespace") - * @OA\Parameter(name="include_pattern", in="query", description="Include only titles that match this pattern. - Either a regular expression (starts/ends with a forward slash), - or a wildcard pattern with `%` as the wildcard symbol." - * ) - * @OA\Parameter(name="exclude_pattern", in="query", description="Exclude titles that match this pattern. - Either a regular expression (starts/ends with a forward slash), - or a wildcard pattern with `%` as the wildcard symbol." - * ) - * @OA\Response( - * response=200, - * description="List of largest pages for the project.", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="namespace", ref="#/components/parameters/Namespace/schema"), - * @OA\Property(property="include_pattern", example="/Foo|Bar/"), - * @OA\Property(property="exclude_pattern", example="%baz"), - * @OA\Property(property="pages", type="array", @OA\Items(type="object"), example={{ - * "rank": 1, - * "page_title": "Foo", - * "length": 50000 - * }, { - * "rank": 2, - * "page_title": "Bar", - * "length": 30000 - * }}), - * @OA\Property(property="elapsed_time", ref="#/components/schemas/elapsed_time") - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - * @codeCoverageIgnore - */ - #[Route( - path: "/api/project/largest_pages/{project}/{namespace}", - name: "ProjectApiLargestPages", - defaults: ["namespace" => "all"], - methods: ["GET"] - )] - public function resultsApiAction(LargestPagesRepository $largestPagesRepo): JsonResponse - { - $this->recordApiUsage('project/largest_pages'); - $lp = $this->getLargestPages($largestPagesRepo); + #[OA\Tag( name: "Project API" )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Parameter( ref: "#/components/parameters/Namespace" )] + #[OA\Parameter( + name: "include_pattern", + description: "Include only titles that match this pattern. Either a regular expression " . + "(starts/ends with a forward slash), or a wildcard pattern with `%` as the wildcard symbol.", + in: "query" + )] + #[OA\Parameter( + name: "exclude_pattern", + description: "Exclude titles that match this pattern. Either a regular expression " . + "(starts/ends with a forward slash), or a wildcard pattern with `%` as the wildcard symbol.", + in: "query" + )] + #[OA\Response( + response: 200, + description: "List of largest pages for the project.", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "namespace", ref: "#/components/parameters/Namespace/schema" ), + new OA\Property( property: "include_pattern", example: "/Foo|Bar/" ), + new OA\Property( property: "exclude_pattern", example: "%baz" ), + new OA\Property( + property: "pages", + type: "array", + items: new OA\Items( type: "object" ), + example: [ + [ "rank" => 1, "page_title" => "Foo", "length" => 50000 ], + [ "rank" => 2, "page_title" => "Bar", "length" => 30000 ], + ] + ), + new OA\Property( property: "elapsed_time", ref: "#/components/schemas/elapsed_time" ), + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[OA\Response( ref: "#/components/responses/503", response: 503 )] + #[OA\Response( ref: "#/components/responses/504", response: 504 )] + #[Route( + path: "/api/project/largest_pages/{project}/{namespace}", + name: "ProjectApiLargestPages", + defaults: [ "namespace" => "all" ], + methods: [ "GET" ] + )] + /** + * Get the largest pages on a project. + * @codeCoverageIgnore + */ + public function resultsApiAction( LargestPagesRepository $largestPagesRepo ): JsonResponse { + $this->recordApiUsage( 'project/largest_pages' ); + $lp = $this->getLargestPages( $largestPagesRepo ); - $pages = []; - foreach ($lp->getResults() as $index => $page) { - $pages[] = [ - 'rank' => $index + 1, - 'page_title' => $page->getTitle(true), - 'length' => $page->getLength(), - ]; - } + $pages = []; + foreach ( $lp->getResults() as $index => $page ) { + $pages[] = [ + 'rank' => $index + 1, + 'page_title' => $page->getTitle( true ), + 'length' => $page->getLength(), + ]; + } - return $this->getFormattedApiResponse([ - 'pages' => $pages, - ]); - } + return $this->getFormattedApiResponse( [ + 'pages' => $pages, + ] ); + } } diff --git a/src/Controller/MetaController.php b/src/Controller/MetaController.php index 80beb6734..9eaca1660 100644 --- a/src/Controller/MetaController.php +++ b/src/Controller/MetaController.php @@ -1,6 +1,6 @@ params['start']) && isset($this->params['end'])) { - return $this->redirectToRoute('MetaResult', $this->params); - } - - return $this->render('meta/index.html.twig', [ - 'xtPage' => 'Meta', - 'xtPageTitle' => 'tool-meta', - 'xtSubtitle' => 'tool-meta-desc', - ]); - } - - /** - * Display the results. - * @codeCoverageIgnore - */ - #[Route( - "/meta/{start}/{end}/{legacy}", - name: "MetaResult", - requirements: [ - "start" => "\d{4}-\d{2}-\d{2}", - "end" => "\d{4}-\d{2}-\d{2}", - ] - )] - public function resultAction(ManagerRegistry $managerRegistry, bool $legacy = false): Response - { - $db = $legacy ? 'toolsdb' : 'default'; - $table = $legacy ? 's51187__metadata.xtools_timeline' : 'usage_timeline'; - $client = $managerRegistry->getConnection($db); - - $toolUsage = $this->getToolUsageStats($client, $table); - $apiUsage = $this->getApiUsageStats($client); - - return $this->render('meta/result.html.twig', [ - 'xtPage' => 'Meta', - 'start' => $this->start, - 'end' => $this->end, - 'toolUsage' => $toolUsage, - 'apiUsage' => $apiUsage, - ]); - } - - /** - * Get usage statistics of the core tools. - * @param object $client - * @param string $table Table to query. - * @return array - * @codeCoverageIgnore - */ - private function getToolUsageStats(object $client, string $table): array - { - $start = date('Y-m-d', $this->start); - $end = date('Y-m-d', $this->end); - $data = $client->executeQuery("SELECT * FROM $table WHERE date >= :start AND date <= :end", [ - 'start' => $start, - 'end' => $end, - ])->fetchAllAssociative(); - - // Create array of totals, along with formatted timeline data as needed by Chart.js - $totals = []; - $dateLabels = []; - $timeline = []; - $startObj = new DateTime($start); - $endObj = new DateTime($end); - $numDays = (int) $endObj->diff($startObj)->format("%a"); - $grandSum = 0; - - // Generate array of date labels - for ($dateObj = new DateTime($start); $dateObj <= $endObj; $dateObj->modify('+1 day')) { - $dateLabels[] = $dateObj->format('Y-m-d'); - } - - foreach ($data as $entry) { - if (!isset($totals[$entry['tool']])) { - $totals[$entry['tool']] = (int) $entry['count']; - - // Create arrays for each tool, filled with zeros for each date in the timeline - $timeline[$entry['tool']] = array_fill(0, $numDays, 0); - } else { - $totals[$entry['tool']] += (int) $entry['count']; - } - - $date = new DateTime($entry['date']); - $dateIndex = (int) $date->diff($startObj)->format("%a"); - $timeline[$entry['tool']][$dateIndex] = (int) $entry['count']; - - $grandSum += $entry['count']; - } - arsort($totals); - - return [ - 'totals' => $totals, - 'grandSum' => $grandSum, - 'dateLabels' => $dateLabels, - 'timeline' => $timeline, - ]; - } - - /** - * Get usage statistics of the API. - * @param object $client - * @return array - * @codeCoverageIgnore - */ - private function getApiUsageStats(object $client): array - { - $start = date('Y-m-d', $this->start); - $end = date('Y-m-d', $this->end); - $data = $client->executeQuery("SELECT * FROM usage_api_timeline WHERE date >= :start AND date <= :end", [ - 'start' => $start, - 'end' => $end, - ])->fetchAllAssociative(); - - // Create array of totals, along with formatted timeline data as needed by Chart.js - $totals = []; - $dateLabels = []; - $timeline = []; - $startObj = new DateTime($start); - $endObj = new DateTime($end); - $numDays = (int) $endObj->diff($startObj)->format("%a"); - $grandSum = 0; - - // Generate array of date labels - for ($dateObj = new DateTime($start); $dateObj <= $endObj; $dateObj->modify('+1 day')) { - $dateLabels[] = $dateObj->format('Y-m-d'); - } - - foreach ($data as $entry) { - if (!isset($totals[$entry['endpoint']])) { - $totals[$entry['endpoint']] = (int) $entry['count']; - - // Create arrays for each endpoint, filled with zeros for each date in the timeline - $timeline[$entry['endpoint']] = array_fill(0, $numDays, 0); - } else { - $totals[$entry['endpoint']] += (int) $entry['count']; - } - - $date = new DateTime($entry['date']); - $dateIndex = (int) $date->diff($startObj)->format("%a"); - $timeline[$entry['endpoint']][$dateIndex] = (int) $entry['count']; - - $grandSum += $entry['count']; - } - arsort($totals); - - return [ - 'totals' => $totals, - 'grandSum' => $grandSum, - 'dateLabels' => $dateLabels, - 'timeline' => $timeline, - ]; - } - - /** - * Record usage of a particular XTools tool. This is called automatically - * in base.html.twig via JavaScript so that it is done asynchronously. - * @param Request $request - * @param ParameterBagInterface $parameterBag - * @param ManagerRegistry $managerRegistry - * @param bool $singleWiki - * @param string $tool Internal name of tool. - * @param string $project Project domain such as en.wikipedia.org - * @param string $token Unique token for this request, so we don't have people meddling with these statistics. - * @return JsonResponse - * @codeCoverageIgnore - */ - #[Route("/meta/usage/{tool}/{project}/{token}", name: "RecordMetaUsage")] - public function recordUsageAction( - Request $request, - ParameterBagInterface $parameterBag, - ManagerRegistry $managerRegistry, - bool $singleWiki, - string $tool, - string $project, - string $token - ): Response { - $response = new JsonResponse(); - - // Validate method and token. - if ('PUT' !== $request->getMethod() || !$this->isCsrfTokenValid('intention', $token)) { - $response->setStatusCode(Response::HTTP_FORBIDDEN); - $response->setContent(json_encode([ - 'error' => 'This endpoint is for internal use only.', - ])); - return $response; - } - - // Don't update counts for tools that aren't enabled - $configKey = 'enable.'.ucfirst($tool); - if (!$parameterBag->has($configKey) || !$parameterBag->get($configKey)) { - $response->setStatusCode(Response::HTTP_FORBIDDEN); - $response->setContent(json_encode([ - 'error' => 'This tool is disabled', - ])); - return $response; - } - - /** @var Connection $conn */ - $conn = $managerRegistry->getConnection('default'); - $date = date('Y-m-d'); - - // Tool name needs to be lowercase. - $tool = strtolower($tool); - - $sql = "INSERT INTO usage_timeline +class MetaController extends XtoolsController { + /** + * @inheritDoc + * @codeCoverageIgnore + */ + public function getIndexRoute(): string { + return 'Meta'; + } + + #[Route( "/meta", name: "meta" )] + #[Route( "/meta", name: "Meta" )] + #[Route( "/meta/index.php", name: "MetaIndexPhp" )] + /** + * Display the form. + */ + public function indexAction(): Response { + if ( isset( $this->params['start'] ) && isset( $this->params['end'] ) ) { + return $this->redirectToRoute( 'MetaResult', $this->params ); + } + + return $this->render( 'meta/index.html.twig', [ + 'xtPage' => 'Meta', + 'xtPageTitle' => 'tool-meta', + 'xtSubtitle' => 'tool-meta-desc', + ] ); + } + + #[Route( + "/meta/{start}/{end}/{legacy}", + name: "MetaResult", + requirements: [ + "start" => "\d{4}-\d{2}-\d{2}", + "end" => "\d{4}-\d{2}-\d{2}", + ] + )] + /** + * Display the results. + * @codeCoverageIgnore + */ + public function resultAction( ManagerRegistry $managerRegistry, bool $legacy = false ): Response { + $db = $legacy ? 'toolsdb' : 'default'; + $table = $legacy ? 's51187__metadata.xtools_timeline' : 'usage_timeline'; + $client = $managerRegistry->getConnection( $db ); + + $toolUsage = $this->getToolUsageStats( $client, $table ); + $apiUsage = $this->getApiUsageStats( $client ); + + return $this->render( 'meta/result.html.twig', [ + 'xtPage' => 'Meta', + 'start' => $this->start, + 'end' => $this->end, + 'toolUsage' => $toolUsage, + 'apiUsage' => $apiUsage, + ] ); + } + + /** + * Get usage statistics of the core tools. + * @codeCoverageIgnore + */ + private function getToolUsageStats( object $client, string $table ): array { + $start = date( 'Y-m-d', $this->start ); + $end = date( 'Y-m-d', $this->end ); + $data = $client->executeQuery( "SELECT * FROM $table WHERE date >= :start AND date <= :end", [ + 'start' => $start, + 'end' => $end, + ] )->fetchAllAssociative(); + + // Create array of totals, along with formatted timeline data as needed by Chart.js + $totals = []; + $dateLabels = []; + $timeline = []; + $startObj = new DateTime( $start ); + $endObj = new DateTime( $end ); + $numDays = (int)$endObj->diff( $startObj )->format( "%a" ); + $grandSum = 0; + + // Generate array of date labels + for ( $dateObj = new DateTime( $start ); $dateObj <= $endObj; $dateObj->modify( '+1 day' ) ) { + $dateLabels[] = $dateObj->format( 'Y-m-d' ); + } + + foreach ( $data as $entry ) { + if ( !isset( $totals[$entry['tool']] ) ) { + $totals[$entry['tool']] = (int)$entry['count']; + + // Create arrays for each tool, filled with zeros for each date in the timeline + $timeline[$entry['tool']] = array_fill( 0, $numDays, 0 ); + } else { + $totals[$entry['tool']] += (int)$entry['count']; + } + + $date = new DateTime( $entry['date'] ); + $dateIndex = (int)$date->diff( $startObj )->format( "%a" ); + $timeline[$entry['tool']][$dateIndex] = (int)$entry['count']; + + $grandSum += $entry['count']; + } + arsort( $totals ); + + return [ + 'totals' => $totals, + 'grandSum' => $grandSum, + 'dateLabels' => $dateLabels, + 'timeline' => $timeline, + ]; + } + + /** + * Get usage statistics of the API. + * @codeCoverageIgnore + */ + private function getApiUsageStats( object $client ): array { + $start = date( 'Y-m-d', $this->start ); + $end = date( 'Y-m-d', $this->end ); + $data = $client->executeQuery( "SELECT * FROM usage_api_timeline WHERE date >= :start AND date <= :end", [ + 'start' => $start, + 'end' => $end, + ] )->fetchAllAssociative(); + + // Create array of totals, along with formatted timeline data as needed by Chart.js + $totals = []; + $dateLabels = []; + $timeline = []; + $startObj = new DateTime( $start ); + $endObj = new DateTime( $end ); + $numDays = (int)$endObj->diff( $startObj )->format( "%a" ); + $grandSum = 0; + + // Generate array of date labels + for ( $dateObj = new DateTime( $start ); $dateObj <= $endObj; $dateObj->modify( '+1 day' ) ) { + $dateLabels[] = $dateObj->format( 'Y-m-d' ); + } + + foreach ( $data as $entry ) { + if ( !isset( $totals[$entry['endpoint']] ) ) { + $totals[$entry['endpoint']] = (int)$entry['count']; + + // Create arrays for each endpoint, filled with zeros for each date in the timeline + $timeline[$entry['endpoint']] = array_fill( 0, $numDays, 0 ); + } else { + $totals[$entry['endpoint']] += (int)$entry['count']; + } + + $date = new DateTime( $entry['date'] ); + $dateIndex = (int)$date->diff( $startObj )->format( "%a" ); + $timeline[$entry['endpoint']][$dateIndex] = (int)$entry['count']; + + $grandSum += $entry['count']; + } + arsort( $totals ); + + return [ + 'totals' => $totals, + 'grandSum' => $grandSum, + 'dateLabels' => $dateLabels, + 'timeline' => $timeline, + ]; + } + + #[Route( "/meta/usage/{tool}/{project}/{token}", name: "RecordMetaUsage" )] + /** + * Record usage of a particular XTools tool. This is called automatically + * in base.html.twig via JavaScript so that it is done asynchronously. + * @param Request $request + * @param ParameterBagInterface $parameterBag + * @param ManagerRegistry $managerRegistry + * @param bool $singleWiki + * @param string $tool Internal name of tool. + * @param string $project Project domain such as en.wikipedia.org + * @param string $token Unique token for this request, so we don't have people meddling with these statistics. + * @return JsonResponse + * @codeCoverageIgnore + */ + public function recordUsageAction( + Request $request, + ParameterBagInterface $parameterBag, + ManagerRegistry $managerRegistry, + bool $singleWiki, + string $tool, + string $project, + string $token + ): Response { + $response = new JsonResponse(); + + // Validate method and token. + if ( $request->getMethod() !== 'PUT' || !$this->isCsrfTokenValid( 'intention', $token ) ) { + $response->setStatusCode( Response::HTTP_FORBIDDEN ); + $response->setContent( json_encode( [ + 'error' => 'This endpoint is for internal use only.', + ] ) ); + return $response; + } + + // Don't update counts for tools that aren't enabled + $configKey = 'enable.' . ucfirst( $tool ); + if ( !$parameterBag->has( $configKey ) || !$parameterBag->get( $configKey ) ) { + $response->setStatusCode( Response::HTTP_FORBIDDEN ); + $response->setContent( json_encode( [ + 'error' => 'This tool is disabled', + ] ) ); + return $response; + } + + /** @var Connection $conn */ + $conn = $managerRegistry->getConnection( 'default' ); + $date = date( 'Y-m-d' ); + + // Tool name needs to be lowercase. + $tool = strtolower( $tool ); + + $sql = "INSERT INTO usage_timeline VALUES(NULL, :date, :tool, 1) ON DUPLICATE KEY UPDATE `count` = `count` + 1"; - $conn->executeStatement($sql, [ - 'date' => $date, - 'tool' => $tool, - ]); - - // Update per-project usage, if applicable - if (!$singleWiki) { - $sql = "INSERT INTO usage_projects + $conn->executeStatement( $sql, [ + 'date' => $date, + 'tool' => $tool, + ] ); + + // Update per-project usage, if applicable + if ( !$singleWiki ) { + $sql = "INSERT INTO usage_projects VALUES(NULL, :tool, :project, 1) ON DUPLICATE KEY UPDATE `count` = `count` + 1"; - $conn->executeStatement($sql, [ - 'tool' => $tool, - 'project' => $project, - ]); - } - - $response->setStatusCode(Response::HTTP_NO_CONTENT); - $response->setContent(json_encode([])); - return $response; - } + $conn->executeStatement( $sql, [ + 'tool' => $tool, + 'project' => $project, + ] ); + } + + $response->setStatusCode( Response::HTTP_NO_CONTENT ); + $response->setContent( json_encode( [] ) ); + return $response; + } } diff --git a/src/Controller/PageInfoController.php b/src/Controller/PageInfoController.php index 654b5f7d0..1df2fa983 100644 --- a/src/Controller/PageInfoController.php +++ b/src/Controller/PageInfoController.php @@ -1,6 +1,6 @@ params['project']) && isset($this->params['page'])) { - return $this->redirectToRoute('PageInfoResult', $this->params); - } - - return $this->render('pageInfo/index.html.twig', array_merge([ - 'xtPage' => 'PageInfo', - 'xtPageTitle' => 'tool-pageinfo', - 'xtSubtitle' => 'tool-pageinfo-desc', - - // Defaults that will get overridden if in $params. - 'start' => '', - 'end' => '', - 'page' => '', - ], $this->params, ['project' => $this->project])); - } - - /** - * Setup the PageInfo instance and its Repository. - * @param PageInfoRepository $pageInfoRepo - * @param AutomatedEditsHelper $autoEditsHelper - * @codeCoverageIgnore - */ - private function setupPageInfo( - PageInfoRepository $pageInfoRepo, - AutomatedEditsHelper $autoEditsHelper - ): void { - if (isset($this->pageInfo)) { - return; - } - - $this->pageInfo = new PageInfo( - $pageInfoRepo, - $this->i18n, - $autoEditsHelper, - $this->page, - $this->start, - $this->end - ); - } - - /** - * Generate PageInfo gadget script for use on-wiki. This automatically points the - * script to this installation's API. - * - * @link https://www.mediawiki.org/wiki/XTools/PageInfo_gadget - * @codeCoverageIgnore - */ - #[Route('/pageinfo-gadget.js', name: 'PageInfoGadget')] - public function gadgetAction(): Response - { - $rendered = $this->renderView('pageInfo/pageinfo.js.twig'); - $response = new Response($rendered); - $response->headers->set('Content-Type', 'text/javascript'); - return $response; - } - - /** - * Display the results in given date range. - * @codeCoverageIgnore - */ - #[Route( - '/pageinfo/{project}/{page}/{start}/{end}', - name: 'PageInfoResult', - requirements: [ - 'page' => '(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?)?$', - 'start' => '|\d{4}-\d{2}-\d{2}', - 'end' => '|\d{4}-\d{2}-\d{2}', - ], - defaults: [ - 'start' => false, - 'end' => false, - ] - )] - #[Route( - '/articleinfo/{project}/{page}/{start}/{end}', - name: 'PageInfoResultLegacy', - requirements: [ - 'page' => '(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?)?$', - 'start' => '|\d{4}-\d{2}-\d{2}', - 'end' => '|\d{4}-\d{2}-\d{2}', - ], - defaults: [ - 'start' => false, - 'end' => false, - ] - )] - public function resultAction( - PageInfoRepository $pageInfoRepo, - AutomatedEditsHelper $autoEditsHelper - ): Response { - if (!$this->isDateRangeValid($this->page, $this->start, $this->end)) { - $this->addFlashMessage('notice', 'date-range-outside-revisions'); - - return $this->redirectToRoute('PageInfo', [ - 'project' => $this->request->get('project'), - ]); - } - - $this->setupPageInfo($pageInfoRepo, $autoEditsHelper); - $this->pageInfo->prepareData(); - - $maxRevisions = $this->getParameter('app.max_page_revisions'); - - // Show message if we hit the max revisions. - if ($this->pageInfo->tooManyRevisions()) { - $this->addFlashMessage('notice', 'too-many-revisions', [ - $this->i18n->numberFormat($maxRevisions), - $maxRevisions, - ]); - } - - // For when there is very old data (2001 era) which may cause miscalculations. - if ($this->pageInfo->getFirstEdit()->getYear() < 2003) { - $this->addFlashMessage('warning', 'old-page-notice'); - } - - // When all username info has been hidden (see T303724). - if (0 === $this->pageInfo->getNumEditors()) { - $this->addFlashMessage('warning', 'error-usernames-missing'); - } elseif ($this->pageInfo->numDeletedRevisions()) { - $link = new Markup( - $this->renderView('flashes/deleted_data.html.twig', [ - 'numRevs' => $this->pageInfo->numDeletedRevisions(), - ]), - 'UTF-8' - ); - $this->addFlashMessage( - 'warning', - $link, - [$this->pageInfo->numDeletedRevisions(), $link] - ); - } - - $ret = [ - 'xtPage' => 'PageInfo', - 'xtTitle' => $this->page->getTitle(), - 'project' => $this->project, - 'editorlimit' => (int)$this->request->query->get('editorlimit', 20), - 'botlimit' => $this->request->query->get('botlimit', 10), - 'pageviewsOffset' => 60, - 'ai' => $this->pageInfo, - 'showAuthorship' => Authorship::isSupportedPage($this->page) && $this->pageInfo->getNumEditors() > 0, - ]; - - // Output the relevant format template. - return $this->getFormattedResponse('pageInfo/result', $ret); - } - - /** - * Check if there were any revisions of given page in given date range. - */ - private function isDateRangeValid(Page $page, false|int $start, false|int $end): bool - { - return $page->getNumRevisions(null, $start, $end) > 0; - } - - /************************ API endpoints ************************/ - - /** - * Get basic information about a page. - * @OA\Get(description="Get basic information about the history of a page. - See also the [pageviews](https://w.wiki/6o9k) and [edit data](https://w.wiki/6o9m) REST APIs.") - * @OA\Tag(name="Page API") - * @OA\ExternalDocumentation(url="https://www.mediawiki.org/wiki/XTools/API/Page#Page_info") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Parameter(ref="#/components/parameters/Page") - * @OA\Parameter(name="format", in="query", @OA\Schema(default="json", type="string", enum={"json","html"})) - * @OA\Response( - * response=200, - * description="Basic information about the page.", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="page", ref="#/components/parameters/Page/schema"), - * @OA\Property(property="watchers", type="integer"), - * @OA\Property(property="pageviews", type="integer"), - * @OA\Property(property="pageviews_offset", type="integer"), - * @OA\Property(property="revisions", type="integer"), - * @OA\Property(property="editors", type="integer"), - * @OA\Property(property="minor_edits", type="integer"), - * @OA\Property(property="creator", type="string", example="Jimbo Wales"), - * @OA\Property(property="creator_editcount", type="integer"), - * @OA\Property(property="created_at", type="date"), - * @OA\Property(property="created_rev_id", type="integer"), - * @OA\Property(property="modified_at", type="date"), - * @OA\Property(property="secs_since_last_edit", type="integer"), - * @OA\Property(property="modified_rev_id", type="integer"), - * @OA\Property(property="assessment", type="object", example={ - * "value":"FA", - * "color": "#9CBDFF", - * "category": "Category:FA-Class articles", - * "badge": "https://upload.wikimedia.org/wikipedia/commons/b/bc/Featured_article_star.svg" - * }), - * @OA\Property(property="elapsed_time", ref="#/components/schemas/elapsed_time") - * ), - * @OA\XmlContent(format="text/html") - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - * See PageInfoControllerTest::testPageInfoApi() - * @codeCoverageIgnore - */ - #[Route( - '/api/page/pageinfo/{project}/{page}', - name: 'PageApiPageInfo', - requirements: ['page' => '.+'], - methods: ['GET'] - )] - #[Route( - '/api/page/articleinfo/{project}/{page}', - name: 'PageApiPageInfoLegacy', - requirements: ['page' => '.+'], - methods: ['GET'] - )] - public function pageInfoApiAction( - PageInfoRepository $pageInfoRepo, - AutomatedEditsHelper $autoEditsHelper - ): Response|JsonResponse { - $this->recordApiUsage('page/pageinfo'); - - $this->setupPageInfo($pageInfoRepo, $autoEditsHelper); - $data = []; - - try { - $data = $this->pageInfo->getPageInfoApiData($this->project, $this->page); - } catch (ServerException) { - // The Wikimedia action API can fail for any number of reasons. To our users - // any ServerException means the data could not be fetched, so we capture it here - // to avoid the flood of automated emails when the API goes down, etc. - $data['error'] = $this->i18n->msg('api-error', [$this->project->getDomain()]); - } - - if ('html' === $this->request->query->get('format')) { - return $this->getApiHtmlResponse($this->project, $this->page, $data); - } - - return $this->getFormattedApiResponse($data); - } - - /** - * Get the Response for the HTML output of the PageInfo API action. - * @param Project $project - * @param Page $page - * @param string[] $data The pre-fetched data. - * @return Response - * @codeCoverageIgnore - */ - private function getApiHtmlResponse(Project $project, Page $page, array $data): Response - { - $response = $this->render('pageInfo/api.html.twig', [ - 'project' => $project, - 'page' => $page, - 'data' => $data, - ]); - - // All /api routes by default respond with a JSON content type. - $response->headers->set('Content-Type', 'text/html'); - // T381941 - $response->setVary(['Origin']); - - // This endpoint is hit constantly and user could be browsing the same page over - // and over (popular noticeboard, for instance), so offload brief caching to browser. - $response->setClientTtl(350); - - return $response; - } - - /** - * Get prose statistics for the given page. - * @OA\Tag(name="Page API") - * @OA\ExternalDocumentation(url="https://www.mediawiki.org/wiki/XTools/Page_History#Prose") - * @OA\Get(description="Get statistics about the [prose](https://en.wiktionary.org/wiki/prose) (characters, - word count, etc.) and referencing of a page. ([more info](https://w.wiki/6oAF))") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Parameter(ref="#/components/parameters/Page", @OA\Schema(example="Metallica")) - * @OA\Response( - * response=200, - * description="Prose stats", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="page", ref="#/components/parameters/Page/schema"), - * @OA\Property(property="bytes", type="integer"), - * @OA\Property(property="characters", type="integer"), - * @OA\Property(property="words", type="integer"), - * @OA\Property(property="references", type="integer"), - * @OA\Property(property="unique_references", type="integer"), - * @OA\Property(property="sections", type="integer"), - * @OA\Property(property="elapsed_time", ref="#/components/schemas/elapsed_time") - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @codeCoverageIgnore - */ - #[Route( - '/api/page/prose/{project}/{page}', - name: 'PageApiProse', - requirements: ['page' => '.+'], - methods: ['GET'] - )] - public function proseStatsApiAction( - PageInfoRepository $pageInfoRepo, - AutomatedEditsHelper $autoEditsHelper - ): JsonResponse { - $responseCode = Response::HTTP_OK; - $this->recordApiUsage('page/prose'); - $this->setupPageInfo($pageInfoRepo, $autoEditsHelper); - $ret = $this->pageInfo->getProseStats(); - if (null === $ret) { - $this->addFlashMessage('error', 'api-error-wikimedia'); - $responseCode = Response::HTTP_BAD_GATEWAY; - $ret = []; - } - return $this->getFormattedApiResponse($ret, $responseCode); - } - - /** - * Get the page assessments of one or more pages, along with various related metadata. - * @OA\Tag(name="Page API") - * @OA\Get(description="Get [assessment data](https://w.wiki/6oAM) of the given pages, including the overall - quality classifications, along with a list of the WikiProjects and their classifications and importance levels.") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Parameter(ref="#/components/parameters/Pages") - * @OA\Parameter(name="classonly", in="query", @OA\Schema(type="boolean"), - * description="Return only the overall quality assessment instead of for each applicable WikiProject." - * ) - * @OA\Response( - * response=200, - * description="Assessmnet data", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="pages", type="object", - * @OA\Property(property="Page title", type="object", - * @OA\Property(property="assessment", ref="#/components/schemas/PageAssessment"), - * @OA\Property(property="wikiprojects", type="object", - * @OA\Property(property="name of WikiProject", - * ref="#/components/schemas/PageAssessmentWikiProject" - * ) - * ) - * ) - * ), - * @OA\Property(property="elapsed_time", ref="#/components/schemas/elapsed_time") - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @codeCoverageIgnore - */ - #[Route( - '/api/page/assessments/{project}/{pages}', - name: 'PageApiAssessments', - requirements: ['pages' => '.+'], - methods: ['GET'] - )] - public function assessmentsApiAction(string $pages): JsonResponse - { - $this->recordApiUsage('page/assessments'); - - $pages = explode('|', $pages); - $out = [ - 'pages' => [], - ]; - - foreach ($pages as $pageTitle) { - try { - $page = $this->validatePage($pageTitle); - $assessments = $page->getProject() - ->getPageAssessments() - ->getAssessments($page); - - $out['pages'][$page->getTitle()] = $this->getBoolVal('classonly') - ? $assessments['assessment'] - : $assessments; - } catch (XtoolsHttpException $e) { - $out['pages'][$pageTitle] = false; - } - } - - return $this->getFormattedApiResponse($out); - } - - /** - * Get number of in and outgoing links, external links, and redirects to the given page. - * @OA\Tag(name="Page API") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Parameter(ref="#/components/parameters/Page") - * @OA\Response( - * response=200, - * description="Counts of in and outgoing links, external links, and redirects.", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="page", ref="#/components/parameters/Page/schema"), - * @OA\Property(property="links_ext_count", type="integer"), - * @OA\Property(property="links_out_count", type="integer"), - * @OA\Property(property="links_in_count", type="integer"), - * @OA\Property(property="redirects_count", type="integer"), - * @OA\Property(property="elapsed_time", ref="#/components/schemas/elapsed_time") - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - * @codeCoverageIgnore - */ - #[Route( - '/api/page/links/{project}/{page}', - name: 'PageApiLinks', - requirements: ['page' => '.+'], - methods: ['GET'] - )] - public function linksApiAction(): JsonResponse - { - $this->recordApiUsage('page/links'); - return $this->getFormattedApiResponse($this->page->countLinksAndRedirects()); - } - - /** - * Get the top editors (by number of edits) of a page. - * @OA\Tag(name="Page API") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Parameter(ref="#/components/parameters/Page") - * @OA\Parameter(ref="#/components/parameters/Start") - * @OA\Parameter(ref="#/components/parameters/End") - * @OA\Parameter(ref="#/components/parameters/Limit") - * @OA\Parameter(name="nobots", in="query", - * description="Exclude bots from the results.", @OA\Schema(type="boolean") - * ) - * @OA\Response( - * response=200, - * description="List of the top editors, sorted by how many edits they've made to the page.", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="page", ref="#/components/parameters/Page/schema"), - * @OA\Property(property="start", ref="#/components/parameters/Start/schema"), - * @OA\Property(property="end", ref="#/components/parameters/End/schema"), - * @OA\Property(property="limit", ref="#/components/parameters/Limit/schema"), - * @OA\Property(property="top_editors", type="array", @OA\Items(type="object"), example={ - * { - * "rank": 1, - * "username": "Jimbo Wales", - * "count": 50, - * "minor": 15, - * "first_edit": { - * "id": 12345, - * "timestamp": "2020-01-01T12:59:59Z" - * }, - * "last_edit": { - * "id": 54321, - * "timestamp": "2020-01-20T12:59:59Z" - * } - * } - * }), - * @OA\Property(property="elapsed_time", ref="#/components/schemas/elapsed_time") - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - * @codeCoverageIgnore - */ - #[Route( - '/api/page/top_editors/{project}/{page}/{start}/{end}/{limit}', - name: 'PageApiTopEditors', - requirements: [ - 'page' => '(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?(?:\/(\d+))?)?$', - 'start' => '|\d{4}-\d{2}-\d{2}', - 'end' => '|\d{4}-\d{2}-\d{2}', - 'limit' => '\d+', - ], - defaults: [ - 'start' => false, - 'end' => false, - 'limit' => 20, - ], - methods: ['GET'] - )] - public function topEditorsApiAction( - PageInfoRepository $pageInfoRepo, - AutomatedEditsHelper $autoEditsHelper - ): JsonResponse { - $this->recordApiUsage('page/top_editors'); - - $this->setupPageInfo($pageInfoRepo, $autoEditsHelper); - $topEditors = $this->pageInfo->getTopEditorsByEditCount( - (int)$this->limit, - $this->getBoolVal('nobots') - ); - - return $this->getFormattedApiResponse([ - 'top_editors' => $topEditors, - ]); - } - - /** - * Get data about bots that have edited a page. - * @OA\Tag(name="Page API") - * @OA\Get(description="List bots that have edited a page, with edit counts and whether the account - is still in the `bot` user group.") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Parameter(ref="#/components/parameters/Page") - * @OA\Parameter(ref="#/components/parameters/Start") - * @OA\Parameter(ref="#/components/parameters/End") - * @OA\Response( - * response=200, - * description="List of bots", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="page", ref="#/components/parameters/Page/schema"), - * @OA\Property(property="start", ref="#/components/parameters/Start/schema"), - * @OA\Property(property="end", ref="#/components/parameters/End/schema"), - * @OA\Property(property="bots", type="object", - * @OA\Property(property="Page title", type="object", - * @OA\Property(property="count", type="integer", description="Number of edits to the page."), - * @OA\Property(property="current", type="boolean", - * description="Whether the account currently has the bot flag" - * ) - * ) - * ), - * @OA\Property(property="elapsed_time", ref="#/components/schemas/elapsed_time") - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - * @codeCoverageIgnore - */ - #[Route( - '/api/page/bot_data/{project}/{page}/{start}/{end}', - name: 'PageApiBotData', - requirements: [ - 'page' => '(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?)?$', - 'start' => '|\d{4}-\d{2}-\d{2}', - 'end' => '|\d{4}-\d{2}-\d{2}', - ], - defaults: [ - 'start' => false, - 'end' => false, - ], - methods: ['GET'] - )] - public function botDataApiAction( - PageInfoRepository $pageInfoRepo, - AutomatedEditsHelper $autoEditsHelper - ): JsonResponse { - $this->recordApiUsage('page/bot_data'); - - $this->setupPageInfo($pageInfoRepo, $autoEditsHelper); - $bots = $this->pageInfo->getBots(); - - return $this->getFormattedApiResponse([ - 'bots' => $bots, - ]); - } - - /** - * Get counts of (semi-)automated tools that were used to edit the page. - * @OA\Tag(name="Page API") - * @OA\Get(description="Get counts of the number of times known (semi-)automated tools were used to edit the page.") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Parameter(ref="#/components/parameters/Page") - * @OA\Parameter(ref="#/components/parameters/Start") - * @OA\Parameter(ref="#/components/parameters/End") - * @OA\Response( - * response=200, - * description="List of tools", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="page", ref="#/components/parameters/Page/schema"), - * @OA\Property(property="start", ref="#/components/parameters/Start/schema"), - * @OA\Property(property="end", ref="#/components/parameters/End/schema"), - * @OA\Property(property="automated_tools", ref="#/components/schemas/AutomatedTools"), - * @OA\Property(property="elapsed_time", ref="#/components/schemas/elapsed_time") - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - * @codeCoverageIgnore - */ - #[Route( - '/api/page/automated_edits/{project}/{page}/{start}/{end}', - name: 'PageApiAutoEdits', - requirements: [ - 'page' => '(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?)?$', - 'start' => '|\d{4}-\d{2}-\d{2}', - 'end' => '|\d{4}-\d{2}-\d{2}', - ], - defaults: [ - 'start' => false, - 'end' => false, - ], - methods: ['GET'] - )] - public function getAutoEdits( - PageInfoRepository $pageInfoRepo, - AutomatedEditsHelper $autoEditsHelper - ): JsonResponse { - $this->recordApiUsage('page/auto_edits'); - - $this->setupPageInfo($pageInfoRepo, $autoEditsHelper); - return $this->getFormattedApiResponse([ - 'automated_tools' => $this->pageInfo->getAutoEditsCounts(), - ]); - } +class PageInfoController extends XtoolsController { + protected PageInfo $pageInfo; + + /** + * @inheritDoc + * @codeCoverageIgnore + */ + public function getIndexRoute(): string { + return 'PageInfo'; + } + + #[Route( '/pageinfo', name: 'PageInfo' )] + #[Route( '/pageinfo/{project}', name: 'PageInfoProject' )] + #[Route( '/articleinfo', name: 'PageInfoLegacy' )] + #[Route( '/articleinfo/index.php', name: 'PageInfoLegacyPhp' )] + /** + * The search form. + */ + public function indexAction(): Response { + if ( isset( $this->params['project'] ) && isset( $this->params['page'] ) ) { + return $this->redirectToRoute( 'PageInfoResult', $this->params ); + } + + return $this->render( 'pageInfo/index.html.twig', array_merge( [ + 'xtPage' => 'PageInfo', + 'xtPageTitle' => 'tool-pageinfo', + 'xtSubtitle' => 'tool-pageinfo-desc', + + // Defaults that will get overridden if in $params. + 'start' => '', + 'end' => '', + 'page' => '', + ], $this->params, [ 'project' => $this->project ] ) ); + } + + /** + * Setup the PageInfo instance and its Repository. + * @param PageInfoRepository $pageInfoRepo + * @param AutomatedEditsHelper $autoEditsHelper + * @codeCoverageIgnore + */ + private function setupPageInfo( + PageInfoRepository $pageInfoRepo, + AutomatedEditsHelper $autoEditsHelper + ): void { + if ( isset( $this->pageInfo ) ) { + return; + } + + $this->pageInfo = new PageInfo( + $pageInfoRepo, + $this->i18n, + $autoEditsHelper, + $this->page, + $this->start, + $this->end + ); + } + + #[Route( '/pageinfo-gadget.js', name: 'PageInfoGadget' )] + /** + * Generate PageInfo gadget script for use on-wiki. This automatically points the + * script to this installation's API. + * + * @link https://www.mediawiki.org/wiki/XTools/PageInfo_gadget + * @codeCoverageIgnore + */ + public function gadgetAction(): Response { + $rendered = $this->renderView( 'pageInfo/pageinfo.js.twig' ); + $response = new Response( $rendered ); + $response->headers->set( 'Content-Type', 'text/javascript' ); + return $response; + } + + #[Route( + '/pageinfo/{project}/{page}/{start}/{end}', + name: 'PageInfoResult', + requirements: [ + 'page' => '(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?)?$', + 'start' => '|\d{4}-\d{2}-\d{2}', + 'end' => '|\d{4}-\d{2}-\d{2}', + ], + defaults: [ + 'start' => false, + 'end' => false, + ] + )] + #[Route( + '/articleinfo/{project}/{page}/{start}/{end}', + name: 'PageInfoResultLegacy', + requirements: [ + 'page' => '(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?)?$', + 'start' => '|\d{4}-\d{2}-\d{2}', + 'end' => '|\d{4}-\d{2}-\d{2}', + ], + defaults: [ + 'start' => false, + 'end' => false, + ] + )] + /** + * Display the results in given date range. + * @codeCoverageIgnore + */ + public function resultAction( + PageInfoRepository $pageInfoRepo, + AutomatedEditsHelper $autoEditsHelper + ): Response { + if ( !$this->isDateRangeValid( $this->page, $this->start, $this->end ) ) { + $this->addFlashMessage( 'notice', 'date-range-outside-revisions' ); + + return $this->redirectToRoute( 'PageInfo', [ + 'project' => $this->request->get( 'project' ), + ] ); + } + + $this->setupPageInfo( $pageInfoRepo, $autoEditsHelper ); + $this->pageInfo->prepareData(); + + $maxRevisions = $this->getParameter( 'app.max_page_revisions' ); + + // Show message if we hit the max revisions. + if ( $this->pageInfo->tooManyRevisions() ) { + $this->addFlashMessage( 'notice', 'too-many-revisions', [ + $this->i18n->numberFormat( $maxRevisions ), + $maxRevisions, + ] ); + } + + // For when there is very old data (2001 era) which may cause miscalculations. + if ( $this->pageInfo->getFirstEdit()->getYear() < 2003 ) { + $this->addFlashMessage( 'warning', 'old-page-notice' ); + } + + // When all username info has been hidden (see T303724). + if ( $this->pageInfo->getNumEditors() === 0 ) { + $this->addFlashMessage( 'warning', 'error-usernames-missing' ); + } elseif ( $this->pageInfo->numDeletedRevisions() ) { + $link = new Markup( + $this->renderView( 'flashes/deleted_data.html.twig', [ + 'numRevs' => $this->pageInfo->numDeletedRevisions(), + ] ), + 'UTF-8' + ); + $this->addFlashMessage( + 'warning', + $link, + [ $this->pageInfo->numDeletedRevisions(), $link ] + ); + } + + $ret = [ + 'xtPage' => 'PageInfo', + 'xtTitle' => $this->page->getTitle(), + 'project' => $this->project, + 'editorlimit' => (int)$this->request->query->get( 'editorlimit', 20 ), + 'botlimit' => $this->request->query->get( 'botlimit', 10 ), + 'pageviewsOffset' => 60, + 'ai' => $this->pageInfo, + 'showAuthorship' => Authorship::isSupportedPage( $this->page ) && $this->pageInfo->getNumEditors() > 0, + ]; + + // Output the relevant format template. + return $this->getFormattedResponse( 'pageInfo/result', $ret ); + } + + /** + * Check if there were any revisions of given page in given date range. + */ + private function isDateRangeValid( Page $page, false|int $start, false|int $end ): bool { + return $page->getNumRevisions( null, $start, $end ) > 0; + } + + /************************ API endpoints */ + + #[OA\Get( description: "Get basic information about a page." )] + #[OA\Tag( name: "Page API" )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Parameter( ref: "#/components/parameters/Page" )] + #[OA\Parameter( + name: "format", + in: "query", + schema: new OA\Schema( + type: "string", + default: "json", + enum: [ "json", "html" ] + ) + )] + #[OA\Response( + response: 200, + description: "Basic information about the page.", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "page", ref: "#/components/parameters/Page/schema" ), + new OA\Property( property: "watchers", type: "integer" ), + new OA\Property( property: "pageviews", type: "integer" ), + new OA\Property( property: "pageviews_offset", type: "integer" ), + new OA\Property( property: "revisions", type: "integer" ), + new OA\Property( property: "editors", type: "integer" ), + new OA\Property( property: "minor_edits", type: "integer" ), + new OA\Property( property: "creator", type: "string", example: "Jimbo Wales" ), + new OA\Property( property: "creator_editcount", type: "integer" ), + new OA\Property( property: "created_at", type: "date" ), + new OA\Property( property: "created_rev_id", type: "integer" ), + new OA\Property( property: "modified_at", type: "date" ), + new OA\Property( property: "secs_since_last_edit", type: "integer" ), + new OA\Property( property: "modified_rev_id", type: "integer" ), + new OA\Property( + property: "assessment", + type: "object", + example: [ + "value" => "FA", + "color" => "#9CBDFF", + "category" => "Category:FA-Class articles", + "badge" => "https://upload.wikimedia.org/wikipedia/commons/b/bc/Featured_article_star.svg" + ] + ), + new OA\Property( property: "elapsed_time", ref: "#/components/schemas/elapsed_time" ), + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[OA\Response( ref: "#/components/responses/503", response: 503 )] + #[OA\Response( ref: "#/components/responses/504", response: 504 )] + #[Route( + '/api/page/pageinfo/{project}/{page}', + name: 'PageApiPageInfo', + requirements: [ 'page' => '.+' ], + methods: [ 'GET' ] + )] + #[Route( + '/api/page/articleinfo/{project}/{page}', + name: 'PageApiPageInfoLegacy', + requirements: [ 'page' => '.+' ], + methods: [ 'GET' ] + )] + /** + * Get basic information about a page. + * See also the [pageviews](https://w.wiki/6o9k) and [edit data](https://w.wiki/6o9m) REST APIs. + * @codeCoverageIgnore + */ + public function pageInfoApiAction( + PageInfoRepository $pageInfoRepo, + AutomatedEditsHelper $autoEditsHelper + ): Response|JsonResponse { + $this->recordApiUsage( 'page/pageinfo' ); + + $this->setupPageInfo( $pageInfoRepo, $autoEditsHelper ); + $data = []; + + try { + $data = $this->pageInfo->getPageInfoApiData( $this->project, $this->page ); + } catch ( ServerException ) { + // The Wikimedia action API can fail for any number of reasons. To our users + // any ServerException means the data could not be fetched, so we capture it here + // to avoid the flood of automated emails when the API goes down, etc. + $data['error'] = $this->i18n->msg( 'api-error', [ $this->project->getDomain() ] ); + } + + if ( $this->request->query->get( 'format' ) === 'html' ) { + return $this->getApiHtmlResponse( $this->project, $this->page, $data ); + } + + return $this->getFormattedApiResponse( $data ); + } + + /** + * Get the Response for the HTML output of the PageInfo API action. + * @param Project $project + * @param Page $page + * @param string[] $data The pre-fetched data. + * @return Response + * @codeCoverageIgnore + */ + private function getApiHtmlResponse( Project $project, Page $page, array $data ): Response { + $response = $this->render( 'pageInfo/api.html.twig', [ + 'project' => $project, + 'page' => $page, + 'data' => $data, + ] ); + + // All /api routes by default respond with a JSON content type. + $response->headers->set( 'Content-Type', 'text/html' ); + // T381941 + $response->setVary( [ 'Origin' ] ); + + // This endpoint is hit constantly and user could be browsing the same page over + // and over (popular noticeboard, for instance), so offload brief caching to browser. + $response->setClientTtl( 350 ); + + return $response; + } + + #[OA\Tag( name: "Page API" )] + #[OA\Get( description: + "Get statistics about the [prose](https://en.wiktionary.org/wiki/prose) (characters, " . + "word count, etc.) and referencing of a page. ([more info](https://w.wiki/6oAF))" + )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Parameter( ref: "#/components/parameters/Page", schema: new OA\Schema( example: "Metallica" ) )] + #[OA\Response( + response: 200, + description: "Prose stats", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "page", ref: "#/components/parameters/Page/schema" ), + new OA\Property( property: "bytes", type: "integer" ), + new OA\Property( property: "characters", type: "integer" ), + new OA\Property( property: "words", type: "integer" ), + new OA\Property( property: "references", type: "integer" ), + new OA\Property( property: "unique_references", type: "integer" ), + new OA\Property( property: "sections", type: "integer" ), + new OA\Property( property: "elapsed_time", ref: "#/components/schemas/elapsed_time" ), + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[Route( + '/api/page/prose/{project}/{page}', + name: 'PageApiProse', + requirements: [ 'page' => '.+' ], + methods: [ 'GET' ] + )] + /** + * Get prose statistics for the given page. + * @codeCoverageIgnore + */ + public function proseStatsApiAction( + PageInfoRepository $pageInfoRepo, + AutomatedEditsHelper $autoEditsHelper + ): JsonResponse { + $responseCode = Response::HTTP_OK; + $this->recordApiUsage( 'page/prose' ); + $this->setupPageInfo( $pageInfoRepo, $autoEditsHelper ); + $ret = $this->pageInfo->getProseStats(); + if ( $ret === null ) { + $this->addFlashMessage( 'error', 'api-error-wikimedia' ); + $responseCode = Response::HTTP_BAD_GATEWAY; + $ret = []; + } + return $this->getFormattedApiResponse( $ret, $responseCode ); + } + + #[OA\Tag( name: "Page API" )] + #[OA\Get( description: + "Get [assessment data](https://w.wiki/6oAM) of the given pages, including the overall quality " . + "classifications, along with a list of the WikiProjects and their classifications and importance levels." + )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Parameter( ref: "#/components/parameters/Pages" )] + #[OA\Parameter( + name: "classonly", + description: "Return only the overall quality assessment instead of for each applicable WikiProject.", + in: "query", + schema: new OA\Schema( type: "boolean" ) + )] + #[OA\Response( + response: 200, + description: "Assessment data", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "pages", properties: [ + new OA\Property( property: "Page title", type: "object" ), + new OA\Property( property: "assessment", ref: "#/components/schemas/PageAssessment" ), + new OA\Property( + property: "wikiprojects", + ref: "#/components/schemas/PageAssessmentWikiProject", + type: "object" + ) + ], type: "object" ), + new OA\Property( property: "elapsed_time", ref: "#/components/schemas/elapsed_time" ), + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[Route( + '/api/page/assessments/{project}/{pages}', + name: 'PageApiAssessments', + requirements: [ 'pages' => '.+' ], + methods: [ 'GET' ] + )] + /** + * Get the page assessments of one or more pages, along with various related metadata. + * @codeCoverageIgnore + */ + public function assessmentsApiAction( string $pages ): JsonResponse { + $this->recordApiUsage( 'page/assessments' ); + + $pages = explode( '|', $pages ); + $out = [ + 'pages' => [], + ]; + + foreach ( $pages as $pageTitle ) { + try { + $page = $this->validatePage( $pageTitle ); + $assessments = $page->getProject() + ->getPageAssessments() + ->getAssessments( $page ); + + $out['pages'][$page->getTitle()] = $this->getBoolVal( 'classonly' ) + ? $assessments['assessment'] + : $assessments; + } catch ( XtoolsHttpException $e ) { + $out['pages'][$pageTitle] = false; + } + } + + return $this->getFormattedApiResponse( $out ); + } + + #[OA\Tag( name: "Page API" )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Parameter( ref: "#/components/parameters/Page" )] + #[OA\Response( + response: 200, + description: "Counts of in and outgoing links, external links, and redirects.", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "page", ref: "#/components/parameters/Page/schema" ), + new OA\Property( property: "links_ext_count", type: "integer" ), + new OA\Property( property: "links_out_count", type: "integer" ), + new OA\Property( property: "links_in_count", type: "integer" ), + new OA\Property( property: "redirects_count", type: "integer" ), + new OA\Property( property: "elapsed_time", ref: "#/components/schemas/elapsed_time" ), + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[OA\Response( ref: "#/components/responses/503", response: 503 )] + #[OA\Response( ref: "#/components/responses/504", response: 504 )] + #[Route( + '/api/page/links/{project}/{page}', + name: 'PageApiLinks', + requirements: [ 'page' => '.+' ], + methods: [ 'GET' ] + )] + /** + * Get number of in and outgoing links, external links, and redirects to the given page. + * @codeCoverageIgnore + */ + public function linksApiAction(): JsonResponse { + $this->recordApiUsage( 'page/links' ); + return $this->getFormattedApiResponse( $this->page->countLinksAndRedirects() ); + } + + #[OA\Tag( name: "Page API" )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Parameter( ref: "#/components/parameters/Page" )] + #[OA\Parameter( ref: "#/components/parameters/Start" )] + #[OA\Parameter( ref: "#/components/parameters/End" )] + #[OA\Parameter( ref: "#/components/parameters/Limit" )] + #[OA\Parameter( + name: "nobots", + description: "Exclude bots from the results.", + in: "query", + schema: new OA\Schema( type: "boolean" ) + )] + #[OA\Response( + response: 200, + description: "List of the top editors, sorted by how many edits they've made to the page.", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "page", ref: "#/components/parameters/Page/schema" ), + new OA\Property( property: "start", ref: "#/components/parameters/Start/schema" ), + new OA\Property( property: "end", ref: "#/components/parameters/End/schema" ), + new OA\Property( property: "limit", ref: "#/components/parameters/Limit/schema" ), + new OA\Property( + property: "top_editors", + type: "array", + items: new OA\Items( type: "object" ), + example: [ + [ + "rank" => 1, + "username" => "Jimbo Wales", + "count" => 50, + "minor" => 15, + "first_edit" => [ + "id" => 12345, + "timestamp" => "2020-01-01T12:59:59Z", + ], + "last_edit" => [ + "id" => 54321, + "timestamp" => "2020-01-20T12:59:59Z", + ], + ], + ] + ), + new OA\Property( property: "elapsed_time", ref: "#/components/schemas/elapsed_time" ), + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[OA\Response( ref: "#/components/responses/503", response: 503 )] + #[OA\Response( ref: "#/components/responses/504", response: 504 )] + #[Route( + '/api/page/top_editors/{project}/{page}/{start}/{end}/{limit}', + name: 'PageApiTopEditors', + requirements: [ + 'page' => '(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?(?:\/(\d+))?)?$', + 'start' => '|\d{4}-\d{2}-\d{2}', + 'end' => '|\d{4}-\d{2}-\d{2}', + 'limit' => '\d+', + ], + defaults: [ + 'start' => false, + 'end' => false, + 'limit' => 20, + ], + methods: [ 'GET' ] + )] + /** + * Get the top editors (by number of edits) of a page. + * @codeCoverageIgnore + */ + public function topEditorsApiAction( + PageInfoRepository $pageInfoRepo, + AutomatedEditsHelper $autoEditsHelper + ): JsonResponse { + $this->recordApiUsage( 'page/top_editors' ); + + $this->setupPageInfo( $pageInfoRepo, $autoEditsHelper ); + $topEditors = $this->pageInfo->getTopEditorsByEditCount( + (int)$this->limit, + $this->getBoolVal( 'nobots' ) + ); + + return $this->getFormattedApiResponse( [ + 'top_editors' => $topEditors, + ] ); + } + + #[OA\Tag( name: "Page API" )] + #[OA\Get( description: + "List bots that have edited a page, with edit counts and whether the account is still in the `bot` user group." + )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Parameter( ref: "#/components/parameters/Page" )] + #[OA\Parameter( ref: "#/components/parameters/Start" )] + #[OA\Parameter( ref: "#/components/parameters/End" )] + #[OA\Response( + response: 200, + description: "List of bots", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "page", ref: "#/components/parameters/Page/schema" ), + new OA\Property( property: "start", ref: "#/components/parameters/Start/schema" ), + new OA\Property( property: "end", ref: "#/components/parameters/End/schema" ), + new OA\Property( + property: "bots", + properties: [ + new OA\Property( + property: "Page title", + properties: [ + new OA\Property( + property: "count", + description: "Number of edits to the page.", + type: "integer" + ), + new OA\Property( + property: "current", + description: "Whether the account currently has the bot flag", + type: "boolean" + ), + ], + type: "object" + ), + ], + type: "object" + ), + new OA\Property( property: "elapsed_time", ref: "#/components/schemas/elapsed_time" ), + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[OA\Response( ref: "#/components/responses/503", response: 503 )] + #[OA\Response( ref: "#/components/responses/504", response: 504 )] + #[Route( + '/api/page/bot_data/{project}/{page}/{start}/{end}', + name: 'PageApiBotData', + requirements: [ + 'page' => '(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?)?$', + 'start' => '|\d{4}-\d{2}-\d{2}', + 'end' => '|\d{4}-\d{2}-\d{2}', + ], + defaults: [ + 'start' => false, + 'end' => false, + ], + methods: [ 'GET' ] + )] + /** + * Get data about bots that have edited a page. + * @codeCoverageIgnore + */ + public function botDataApiAction( + PageInfoRepository $pageInfoRepo, + AutomatedEditsHelper $autoEditsHelper + ): JsonResponse { + $this->recordApiUsage( 'page/bot_data' ); + + $this->setupPageInfo( $pageInfoRepo, $autoEditsHelper ); + $bots = $this->pageInfo->getBots(); + + return $this->getFormattedApiResponse( [ + 'bots' => $bots, + ] ); + } + + #[OA\Tag( name: "Page API" )] + #[OA\Get( description: + "Get counts of the number of times known (semi-)automated tools were used to edit the page." + )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Parameter( ref: "#/components/parameters/Page" )] + #[OA\Parameter( ref: "#/components/parameters/Start" )] + #[OA\Parameter( ref: "#/components/parameters/End" )] + #[OA\Response( + response: 200, + description: "List of tools", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "page", ref: "#/components/parameters/Page/schema" ), + new OA\Property( property: "start", ref: "#/components/parameters/Start/schema" ), + new OA\Property( property: "end", ref: "#/components/parameters/End/schema" ), + new OA\Property( property: "automated_tools", ref: "#/components/schemas/AutomatedTools" ), + new OA\Property( property: "elapsed_time", ref: "#/components/schemas/elapsed_time" ), + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[OA\Response( ref: "#/components/responses/503", response: 503 )] + #[OA\Response( ref: "#/components/responses/504", response: 504 )] + #[Route( + '/api/page/automated_edits/{project}/{page}/{start}/{end}', + name: 'PageApiAutoEdits', + requirements: [ + 'page' => '(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?)?$', + 'start' => '|\d{4}-\d{2}-\d{2}', + 'end' => '|\d{4}-\d{2}-\d{2}', + ], + defaults: [ + 'start' => false, + 'end' => false, + ], + methods: [ 'GET' ] + )] + /** + * Get counts of (semi-)automated tools that were used to edit the page. + * @codeCoverageIgnore + */ + public function getAutoEdits( + PageInfoRepository $pageInfoRepo, + AutomatedEditsHelper $autoEditsHelper + ): JsonResponse { + $this->recordApiUsage( 'page/auto_edits' ); + + $this->setupPageInfo( $pageInfoRepo, $autoEditsHelper ); + return $this->getFormattedApiResponse( [ + 'automated_tools' => $this->pageInfo->getAutoEditsCounts(), + ] ); + } } diff --git a/src/Controller/PagesController.php b/src/Controller/PagesController.php index c3b45fe28..5cca32dad 100644 --- a/src/Controller/PagesController.php +++ b/src/Controller/PagesController.php @@ -1,6 +1,6 @@ getIndexRoute(); - } - - /** - * @inheritDoc - * @codeCoverageIgnore - */ - public function tooHighEditCountActionAllowlist(): array - { - return ['countPagesApi']; - } - - /** - * Display the form. - */ - #[Route('/pages', name: 'Pages')] - #[Route('/pages/index.php', name: 'PagesIndexPhp')] - #[Route('/pages/{project}', name: 'PagesProject')] - public function indexAction(): Response - { - // Redirect if at minimum project and username are given. - if (isset($this->params['project']) && isset($this->params['username'])) { - return $this->redirectToRoute('PagesResult', $this->params); - } - - // Otherwise fall through. - return $this->render('pages/index.html.twig', array_merge([ - 'xtPageTitle' => 'tool-pages', - 'xtSubtitle' => 'tool-pages-desc', - 'xtPage' => 'Pages', - - // Defaults that will get overridden if in $params. - 'username' => '', - 'namespace' => 0, - 'redirects' => 'noredirects', - 'deleted' => 'all', - 'start' => '', - 'end' => '', - ], $this->params, ['project' => $this->project])); - } - - /** - * Every action in this controller (other than 'index') calls this first. - * @param PagesRepository $pagesRepo - * @param string $redirects One of the Pages::REDIR_ constants. - * @param string $deleted One of the Pages::DEL_ constants. - * @return Pages - * @codeCoverageIgnore - */ - protected function setUpPages(PagesRepository $pagesRepo, string $redirects, string $deleted): Pages - { - if ($this->user->isIpRange()) { - $this->params['username'] = $this->user->getUsername(); - $this->throwXtoolsException($this->getIndexRoute(), 'error-ip-range-unsupported'); - } - - return new Pages( - $pagesRepo, - $this->project, - $this->user, - $this->namespace, - $redirects, - $deleted, - $this->start, - $this->end, - $this->offset - ); - } - - /** - * Display the results. - * @codeCoverageIgnore - */ - #[Route( - '/pages/{project}/{username}/{namespace}/{redirects}/{deleted}/{start}/{end}/{offset}', - name: 'PagesResult', - requirements: [ - 'username' => '(ipr-.+\/\d+[^\/])|([^\/]+)', - 'namespace' => '|all|\d+', - 'redirects' => '|[^/]+', - 'deleted' => '|all|live|deleted', - 'start' => '|\d{4}-\d{2}-\d{2}', - 'end' => '|\d{4}-\d{2}-\d{2}', - 'offset' => '|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?', - ], - defaults: [ - 'namespace' => 0, - 'start' => false, - 'end' => false, - 'offset' => false, - ] - )] - public function resultAction( - PagesRepository $pagesRepo, - string $redirects = Pages::REDIR_NONE, - string $deleted = Pages::DEL_ALL - ): RedirectResponse|Response { - // Check for legacy values for 'redirects', and redirect - // back with correct values if need be. This could be refactored - // out to XtoolsController, but this is the only tool in the suite - // that deals with redirects, so we'll keep it confined here. - $validRedirects = ['', Pages::REDIR_NONE, Pages::REDIR_ONLY, Pages::REDIR_ALL]; - if ('none' === $redirects || !in_array($redirects, $validRedirects)) { - return $this->redirectToRoute('PagesResult', array_merge($this->params, [ - 'redirects' => Pages::REDIR_NONE, - 'deleted' => $deleted, - 'offset' => $this->offset, - ])); - } - - $pages = $this->setUpPages($pagesRepo, $redirects, $deleted); - $pages->prepareData(); - - $ret = [ - 'xtPage' => 'Pages', - 'xtTitle' => $this->user->getUsername(), - 'summaryColumns' => $pages->getSummaryColumns(), - 'pages' => $pages, - ]; - - if ('PagePile' === $this->request->query->get('format')) { - return $this->getPagepileResult($this->project, $pages); - } - - // Output the relevant format template. - return $this->getFormattedResponse('pages/result', $ret); - } - - /** - * Create a PagePile for the given pages, and get a Redirect to that PagePile. - * @throws HttpException - * @see https://pagepile.toolforge.org - * @codeCoverageIgnore - */ - private function getPagepileResult(Project $project, Pages $pages): RedirectResponse - { - $namespaces = $project->getNamespaces(); - $pageTitles = []; - - foreach (array_values($pages->getResults()) as $pagesData) { - foreach ($pagesData as $page) { - if (0 === (int)$page['namespace']) { - $pageTitles[] = $page['page_title']; - } else { - $pageTitles[] = ( - $namespaces[$page['namespace']] ?? $this->i18n->msg('unknown') - ).':'.$page['page_title']; - } - } - } - - $pileId = $this->createPagePile($project, $pageTitles); - - return new RedirectResponse( - "https://pagepile.toolforge.org/api.php?id=$pileId&action=get_data&format=html&doit1" - ); - } - - /** - * Create a PagePile with the given titles. - * @return int The PagePile ID. - * @throws HttpException - * @see https://pagepile.toolforge.org/ - * @codeCoverageIgnore - */ - private function createPagePile(Project $project, array $pageTitles): int - { - $url = 'https://pagepile.toolforge.org/api.php'; - - try { - $res = $this->guzzle->request('GET', $url, ['query' => [ - 'action' => 'create_pile_with_data', - 'wiki' => $project->getDatabaseName(), - 'data' => implode("\n", $pageTitles), - ]]); - } catch (ClientException) { - throw new HttpException( - 414, - 'error-pagepile-too-large' - ); - } - - $ret = json_decode($res->getBody()->getContents(), true); - - if (!isset($ret['status']) || 'OK' !== $ret['status']) { - throw new HttpException( - 500, - 'Failed to create PagePile. There may be an issue with the PagePile API.' - ); - } - - return $ret['pile']['id']; - } - - /************************ API endpoints ************************/ - - /** - * Count the number of pages created by a user. - * @OA\Tag(name="User API") - * @OA\Get(description="Get the number of pages created by a user, keyed by namespace.") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Parameter(ref="#/components/parameters/UsernameOrSingleIp") - * @OA\Parameter(ref="#/components/parameters/Namespace") - * @OA\Parameter(ref="#/components/parameters/Redirects") - * @OA\Parameter(ref="#/components/parameters/Deleted") - * @OA\Parameter(ref="#/components/parameters/Start") - * @OA\Parameter(ref="#/components/parameters/End") - * @OA\Response( - * response=200, - * description="Page counts", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="username", ref="#/components/parameters/UsernameOrSingleIp/schema"), - * @OA\Property(property="namespace", ref="#/components/schemas/Namespace"), - * @OA\Property(property="redirects", ref="#/components/parameters/Redirects/schema"), - * @OA\Property(property="deleted", ref="#components/parameters/Deleted/schema"), - * @OA\Property(property="start", ref="#components/parameters/Start/schema"), - * @OA\Property(property="end", ref="#components/parameters/End/schema"), - * @OA\Property(property="counts", type="object", example={ - * "0": { - * "count": 5, - * "total_length": 500, - * "avg_length": 100 - * }, - * "2": { - * "count": 1, - * "total_length": 200, - * "avg_length": 200 - * } - * }), - * @OA\Property(property="elapsed_time", ref="#/components/schemas/elapsed_time") - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=501, ref="#/components/responses/501") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - * @codeCoverageIgnore - */ - #[Route( - '/api/user/pages_count/{project}/{username}/{namespace}/{redirects}/{deleted}/{start}/{end}', - name: 'UserApiPagesCount', - requirements: [ - 'username' => '(ipr-.+\/\d+[^\/])|([^\/]+)', - 'namespace' => '|all|\d+', - 'redirects' => '|noredirects|onlyredirects|all', - 'deleted' => '|all|live|deleted', - 'start' => '|\d{4}-\d{2}-\d{2}', - 'end' => '|\d{4}-\d{2}-\d{2}', - ], - defaults: [ - 'namespace' => 0, - 'redirects' => Pages::REDIR_NONE, - 'deleted' => Pages::DEL_ALL, - 'start' => false, - 'end' => false, - ], - methods: ['GET'] - )] - public function countPagesApiAction( - PagesRepository $pagesRepo, - string $redirects = Pages::REDIR_NONE, - string $deleted = Pages::DEL_ALL - ): JsonResponse { - $this->recordApiUsage('user/pages_count'); - - $pages = $this->setUpPages($pagesRepo, $redirects, $deleted); - $counts = $pages->getCounts(); - - return $this->getFormattedApiResponse(['counts' => (object)$counts]); - } - - /** - * Get the pages created by by a user. - * @OA\Tag(name="User API") - * @OA\Get(description="Get pages created by a user, keyed by namespace.") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Parameter(ref="#/components/parameters/UsernameOrSingleIp") - * @OA\Parameter(ref="#/components/parameters/Namespace") - * @OA\Parameter(ref="#/components/parameters/Redirects") - * @OA\Parameter(ref="#/components/parameters/Deleted") - * @OA\Parameter(ref="#/components/parameters/Start") - * @OA\Parameter(ref="#/components/parameters/End") - * @OA\Parameter(ref="#/components/parameters/Offset") - * @OA\Parameter(name="format", in="query", - * @OA\Schema(default="json", type="string", enum={"json","wikitext","pagepile","csv","tsv"}) - * ) - * @OA\Response( - * response=200, - * description="Pages created", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="username", ref="#/components/parameters/UsernameOrSingleIp/schema"), - * @OA\Property(property="namespace", ref="#/components/schemas/Namespace"), - * @OA\Property(property="redirects", ref="#/components/parameters/Redirects/schema"), - * @OA\Property(property="deleted", ref="#components/parameters/Deleted/schema"), - * @OA\Property(property="start", ref="#components/parameters/Start/schema"), - * @OA\Property(property="end", ref="#components/parameters/End/schema"), - * @OA\Property(property="pages", type="object", - * @OA\Property(property="namespace ID", ref="#/components/schemas/PageCreation") - * ), - * @OA\Property(property="elapsed_time", ref="#/components/schemas/elapsed_time") - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=501, ref="#/components/responses/501") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - * @codeCoverageIgnore - */ - #[Route( - '/api/user/pages/{project}/{username}/{namespace}/{redirects}/{deleted}/{start}/{end}/{offset}', - name: 'UserApiPagesCreated', - requirements: [ - 'username' => '(ipr-.+\/\d+[^\/])|([^\/]+)', - 'namespace' => '|all|\d+', - 'redirects' => '|noredirects|onlyredirects|all', - 'deleted' => '|all|live|deleted', - 'start' => '|\d{4}-\d{2}-\d{2}', - 'end' => '|\d{4}-\d{2}-\d{2}', - 'offset' => '|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?', - ], - defaults: [ - 'namespace' => 0, - 'redirects' => Pages::REDIR_NONE, - 'deleted' => Pages::DEL_ALL, - 'start' => false, - 'end' => false, - 'offset' => false, - ], - methods: ['GET'] - )] - public function getPagesApiAction( - PagesRepository $pagesRepo, - string $redirects = Pages::REDIR_NONE, - string $deleted = Pages::DEL_ALL - ): JsonResponse { - $this->recordApiUsage('user/pages'); - - $pages = $this->setUpPages($pagesRepo, $redirects, $deleted); - $ret = ['pages' => $pages->getResults()]; - - if ($pages->getNumResults() === $pages->resultsPerPage()) { - $ret['continue'] = $pages->getLastTimestamp(); - } - - return $this->getFormattedApiResponse($ret); - } - - /** - * Get the deletion summary to be shown when hovering over the "Deleted" text in the UI. - * @codeCoverageIgnore - * @internal - */ - #[Route( - '/api/pages/deletion_summary/{project}/{namespace}/{pageTitle}/{timestamp}', - name: 'PagesApiDeletionSummary', - methods: ['GET'] - )] - public function getDeletionSummaryApiAction( - PagesRepository $pagesRepo, - int $namespace, - string $pageTitle, - string $timestamp - ): JsonResponse { - // Redirect/deleted options actually don't matter here. - $pages = $this->setUpPages($pagesRepo, Pages::REDIR_NONE, Pages::DEL_ALL); - return $this->getFormattedApiResponse([ - 'summary' => $pages->getDeletionSummary($namespace, $pageTitle, $timestamp), - ]); - } +class PagesController extends XtoolsController { + /** + * Get the name of the tool's index route. + * This is also the name of the associated model. + * @inheritDoc + * @codeCoverageIgnore + */ + public function getIndexRoute(): string { + return 'Pages'; + } + + /** + * @inheritDoc + * @codeCoverageIgnore + */ + public function tooHighEditCountRoute(): string { + return $this->getIndexRoute(); + } + + /** + * @inheritDoc + * @codeCoverageIgnore + */ + public function tooHighEditCountActionAllowlist(): array { + return [ 'countPagesApi' ]; + } + + /** + * Display the form. + */ + #[Route( '/pages', name: 'Pages' )] + #[Route( '/pages/index.php', name: 'PagesIndexPhp' )] + #[Route( '/pages/{project}', name: 'PagesProject' )] + public function indexAction(): Response { + // Redirect if at minimum project and username are given. + if ( isset( $this->params['project'] ) && isset( $this->params['username'] ) ) { + return $this->redirectToRoute( 'PagesResult', $this->params ); + } + + // Otherwise fall through. + return $this->render( 'pages/index.html.twig', array_merge( [ + 'xtPageTitle' => 'tool-pages', + 'xtSubtitle' => 'tool-pages-desc', + 'xtPage' => 'Pages', + + // Defaults that will get overridden if in $params. + 'username' => '', + 'namespace' => 0, + 'redirects' => 'noredirects', + 'deleted' => 'all', + 'start' => '', + 'end' => '', + ], $this->params, [ 'project' => $this->project ] ) ); + } + + /** + * Every action in this controller (other than 'index') calls this first. + * @param PagesRepository $pagesRepo + * @param string $redirects One of the Pages::REDIR_ constants. + * @param string $deleted One of the Pages::DEL_ constants. + * @return Pages + * @codeCoverageIgnore + */ + protected function setUpPages( PagesRepository $pagesRepo, string $redirects, string $deleted ): Pages { + if ( $this->user->isIpRange() ) { + $this->params['username'] = $this->user->getUsername(); + $this->throwXtoolsException( $this->getIndexRoute(), 'error-ip-range-unsupported' ); + } + + return new Pages( + $pagesRepo, + $this->project, + $this->user, + $this->namespace, + $redirects, + $deleted, + $this->start, + $this->end, + $this->offset + ); + } + + #[Route( + '/pages/{project}/{username}/{namespace}/{redirects}/{deleted}/{start}/{end}/{offset}', + name: 'PagesResult', + requirements: [ + 'username' => '(ipr-.+\/\d+[^\/])|([^\/]+)', + 'namespace' => '|all|\d+', + 'redirects' => '|[^/]+', + 'deleted' => '|all|live|deleted', + 'start' => '|\d{4}-\d{2}-\d{2}', + 'end' => '|\d{4}-\d{2}-\d{2}', + 'offset' => '|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?', + ], + defaults: [ + 'namespace' => 0, + 'start' => false, + 'end' => false, + 'offset' => false, + ] + )] + /** + * Display the results. + * @codeCoverageIgnore + */ + public function resultAction( + PagesRepository $pagesRepo, + string $redirects = Pages::REDIR_NONE, + string $deleted = Pages::DEL_ALL + ): RedirectResponse|Response { + // Check for legacy values for 'redirects', and redirect + // back with correct values if need be. This could be refactored + // out to XtoolsController, but this is the only tool in the suite + // that deals with redirects, so we'll keep it confined here. + $validRedirects = [ '', Pages::REDIR_NONE, Pages::REDIR_ONLY, Pages::REDIR_ALL ]; + if ( $redirects === 'none' || !in_array( $redirects, $validRedirects ) ) { + return $this->redirectToRoute( 'PagesResult', array_merge( $this->params, [ + 'redirects' => Pages::REDIR_NONE, + 'deleted' => $deleted, + 'offset' => $this->offset, + ] ) ); + } + + $pages = $this->setUpPages( $pagesRepo, $redirects, $deleted ); + $pages->prepareData(); + + $ret = [ + 'xtPage' => 'Pages', + 'xtTitle' => $this->user->getUsername(), + 'summaryColumns' => $pages->getSummaryColumns(), + 'pages' => $pages, + ]; + + if ( $this->request->query->get( 'format' ) === 'PagePile' ) { + return $this->getPagepileResult( $this->project, $pages ); + } + + // Output the relevant format template. + return $this->getFormattedResponse( 'pages/result', $ret ); + } + + /** + * Create a PagePile for the given pages, and get a Redirect to that PagePile. + * @throws HttpException + * @see https://pagepile.toolforge.org + * @codeCoverageIgnore + */ + private function getPagepileResult( Project $project, Pages $pages ): RedirectResponse { + $namespaces = $project->getNamespaces(); + $pageTitles = []; + + foreach ( array_values( $pages->getResults() ) as $pagesData ) { + foreach ( $pagesData as $page ) { + if ( (int)$page['namespace'] === 0 ) { + $pageTitles[] = $page['page_title']; + } else { + $pageTitles[] = ( + $namespaces[$page['namespace']] ?? $this->i18n->msg( 'unknown' ) + ) . ':' . $page['page_title']; + } + } + } + + $pileId = $this->createPagePile( $project, $pageTitles ); + + return new RedirectResponse( + "https://pagepile.toolforge.org/api.php?id=$pileId&action=get_data&format=html&doit1" + ); + } + + /** + * Create a PagePile with the given titles. + * @return int The PagePile ID. + * @throws HttpException + * @see https://pagepile.toolforge.org/ + * @codeCoverageIgnore + */ + private function createPagePile( Project $project, array $pageTitles ): int { + $url = 'https://pagepile.toolforge.org/api.php'; + + try { + $res = $this->guzzle->request( 'GET', $url, [ 'query' => [ + 'action' => 'create_pile_with_data', + 'wiki' => $project->getDatabaseName(), + 'data' => implode( "\n", $pageTitles ), + ] ] ); + } catch ( ClientException ) { + throw new HttpException( + 414, + 'error-pagepile-too-large' + ); + } + + $ret = json_decode( $res->getBody()->getContents(), true ); + + if ( !isset( $ret['status'] ) || $ret['status'] !== 'OK' ) { + throw new HttpException( + 500, + 'Failed to create PagePile. There may be an issue with the PagePile API.' + ); + } + + return $ret['pile']['id']; + } + + /************************ API endpoints */ + + #[OA\Tag( name: "User API" )] + #[OA\Get( description: "Get the number of pages created by a user, keyed by namespace." )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Parameter( ref: "#/components/parameters/UsernameOrSingleIp" )] + #[OA\Parameter( ref: "#/components/parameters/Namespace" )] + #[OA\Parameter( ref: "#/components/parameters/Redirects" )] + #[OA\Parameter( ref: "#/components/parameters/Deleted" )] + #[OA\Parameter( ref: "#/components/parameters/Start" )] + #[OA\Parameter( ref: "#/components/parameters/End" )] + #[OA\Response( + response: 200, + description: "Page counts", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "username", ref: "#/components/parameters/UsernameOrSingleIp/schema" ), + new OA\Property( property: "namespace", ref: "#/components/schemas/Namespace" ), + new OA\Property( property: "redirects", ref: "#/components/parameters/Redirects/schema" ), + new OA\Property( property: "deleted", ref: "#/components/parameters/Deleted/schema" ), + new OA\Property( property: "start", ref: "#/components/parameters/Start/schema" ), + new OA\Property( property: "end", ref: "#/components/parameters/End/schema" ), + new OA\Property( + property: "counts", + type: "object", + example: [ + "0" => [ + "count" => 5, + "total_length" => 500, + "avg_length" => 100 + ], + "2" => [ + "count" => 1, + "total_length" => 200, + "avg_length" => 200 + ] + ] + ), + new OA\Property( property: "elapsed_time", ref: "#/components/schemas/elapsed_time" ) + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[OA\Response( ref: "#/components/responses/501", response: 501 )] + #[OA\Response( ref: "#/components/responses/503", response: 503 )] + #[OA\Response( ref: "#/components/responses/504", response: 504 )] + #[Route( + '/api/user/pages_count/{project}/{username}/{namespace}/{redirects}/{deleted}/{start}/{end}', + name: 'UserApiPagesCount', + requirements: [ + 'username' => '(ipr-.+\/\d+[^\/])|([^\/]+)', + 'namespace' => '|all|\d+', + 'redirects' => '|noredirects|onlyredirects|all', + 'deleted' => '|all|live|deleted', + 'start' => '|\d{4}-\d{2}-\d{2}', + 'end' => '|\d{4}-\d{2}-\d{2}', + ], + defaults: [ + 'namespace' => 0, + 'redirects' => Pages::REDIR_NONE, + 'deleted' => Pages::DEL_ALL, + 'start' => false, + 'end' => false, + ], + methods: [ 'GET' ] + )] + /** + * Count the number of pages created by a user. + * @codeCoverageIgnore + */ + public function countPagesApiAction( + PagesRepository $pagesRepo, + string $redirects = Pages::REDIR_NONE, + string $deleted = Pages::DEL_ALL + ): JsonResponse { + $this->recordApiUsage( 'user/pages_count' ); + + $pages = $this->setUpPages( $pagesRepo, $redirects, $deleted ); + $counts = $pages->getCounts(); + + return $this->getFormattedApiResponse( [ 'counts' => (object)$counts ] ); + } + + #[OA\Tag( name: "User API" )] + #[OA\Get( description: "Get pages created by a user, keyed by namespace." )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Parameter( ref: "#/components/parameters/UsernameOrSingleIp" )] + #[OA\Parameter( ref: "#/components/parameters/Namespace" )] + #[OA\Parameter( ref: "#/components/parameters/Redirects" )] + #[OA\Parameter( ref: "#/components/parameters/Deleted" )] + #[OA\Parameter( ref: "#/components/parameters/Start" )] + #[OA\Parameter( ref: "#/components/parameters/End" )] + #[OA\Parameter( ref: "#/components/parameters/Offset" )] + #[OA\Parameter( + name: "format", + in: "query", + schema: new OA\Schema( + type: "string", + default: "json", + enum: [ "json", "wikitext", "pagepile", "csv", "tsv" ] + ) + )] + #[OA\Response( + response: 200, + description: "Pages created", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "username", ref: "#/components/parameters/UsernameOrSingleIp/schema" ), + new OA\Property( property: "namespace", ref: "#/components/schemas/Namespace" ), + new OA\Property( property: "redirects", ref: "#/components/parameters/Redirects/schema" ), + new OA\Property( property: "deleted", ref: "#/components/parameters/Deleted/schema" ), + new OA\Property( property: "start", ref: "#/components/parameters/Start/schema" ), + new OA\Property( property: "end", ref: "#/components/parameters/End/schema" ), + new OA\Property( + property: "pages", + properties: [ + new OA\Property( property: "namespace ID", ref: "#/components/schemas/PageCreation" ) + ], + type: "object" + ), + new OA\Property( property: "elapsed_time", ref: "#/components/schemas/elapsed_time" ) + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[OA\Response( ref: "#/components/responses/501", response: 501 )] + #[OA\Response( ref: "#/components/responses/503", response: 503 )] + #[OA\Response( ref: "#/components/responses/504", response: 504 )] + #[Route( + '/api/user/pages/{project}/{username}/{namespace}/{redirects}/{deleted}/{start}/{end}/{offset}', + name: 'UserApiPagesCreated', + requirements: [ + 'username' => '(ipr-.+\/\d+[^\/])|([^\/]+)', + 'namespace' => '|all|\d+', + 'redirects' => '|noredirects|onlyredirects|all', + 'deleted' => '|all|live|deleted', + 'start' => '|\d{4}-\d{2}-\d{2}', + 'end' => '|\d{4}-\d{2}-\d{2}', + 'offset' => '|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?', + ], + defaults: [ + 'namespace' => 0, + 'redirects' => Pages::REDIR_NONE, + 'deleted' => Pages::DEL_ALL, + 'start' => false, + 'end' => false, + 'offset' => false, + ], + methods: [ 'GET' ] + )] + /** + * Get the pages created by a user. + * @codeCoverageIgnore + */ + public function getPagesApiAction( + PagesRepository $pagesRepo, + string $redirects = Pages::REDIR_NONE, + string $deleted = Pages::DEL_ALL + ): JsonResponse { + $this->recordApiUsage( 'user/pages' ); + + $pages = $this->setUpPages( $pagesRepo, $redirects, $deleted ); + $ret = [ 'pages' => $pages->getResults() ]; + + if ( $pages->getNumResults() === $pages->resultsPerPage() ) { + $ret['continue'] = $pages->getLastTimestamp(); + } + + return $this->getFormattedApiResponse( $ret ); + } + + #[Route( + '/api/pages/deletion_summary/{project}/{namespace}/{pageTitle}/{timestamp}', + name: 'PagesApiDeletionSummary', + methods: [ 'GET' ] + )] + /** + * Get the deletion summary to be shown when hovering over the "Deleted" text in the UI. + * @codeCoverageIgnore + * @internal + */ + public function getDeletionSummaryApiAction( + PagesRepository $pagesRepo, + int $namespace, + string $pageTitle, + string $timestamp + ): JsonResponse { + // Redirect/deleted options actually don't matter here. + $pages = $this->setUpPages( $pagesRepo, Pages::REDIR_NONE, Pages::DEL_ALL ); + return $this->getFormattedApiResponse( [ + 'summary' => $pages->getDeletionSummary( $namespace, $pageTitle, $timestamp ), + ] ); + } } diff --git a/src/Controller/QuoteController.php b/src/Controller/QuoteController.php index 27923358c..dc7da34bd 100644 --- a/src/Controller/QuoteController.php +++ b/src/Controller/QuoteController.php @@ -1,10 +1,10 @@ request->query->get('id')) { - return $this->redirectToRoute( - 'QuoteID', - ['id' => $this->request->query->get('id')] - ); - } + #[Route( "/bash", name: "Bash" )] + #[Route( "/quote", name: "Quote" )] + #[Route( "/bash/base.php", name: "BashBase" )] + /** + * Method for rendering the Bash Main Form. This method redirects if valid parameters are found, + * making it a valid form endpoint as well. + */ + public function indexAction(): Response { + // Check to see if the quote is a param. If so, + // redirect to the proper route. + if ( $this->request->query->get( 'id' ) != '' ) { + return $this->redirectToRoute( + 'QuoteID', + [ 'id' => $this->request->query->get( 'id' ) ] + ); + } - // Otherwise render the form. - return $this->render( - 'quote/index.html.twig', - [ - 'xtPage' => 'Quote', - 'xtPageTitle' => 'tool-quote', - 'xtSubtitle' => 'tool-quote-desc', - ] - ); - } + // Otherwise render the form. + return $this->render( + 'quote/index.html.twig', + [ + 'xtPage' => 'Quote', + 'xtPageTitle' => 'tool-quote', + 'xtSubtitle' => 'tool-quote-desc', + ] + ); + } - /** - * Method for rendering a random quote. This should redirect to the /quote/{id} path below. - */ - #[Route("/quote/random", name: "QuoteRandom")] - #[Route("/bash/random", name: "BashRandom")] - public function randomQuoteAction(): RedirectResponse - { - // Choose a random quote by ID. If we can't find the quotes, return back to - // the main form with a flash notice. - try { - $id = rand(1, sizeof($this->getParameter('quotes'))); - } catch (InvalidParameterException $e) { - $this->addFlashMessage('notice', 'noquotes'); - return $this->redirectToRoute('Quote'); - } + #[Route( "/quote/random", name: "QuoteRandom" )] + #[Route( "/bash/random", name: "BashRandom" )] + /** + * Method for rendering a random quote. This should redirect to the /quote/{id} path below. + */ + public function randomQuoteAction(): RedirectResponse { + // Choose a random quote by ID. If we can't find the quotes, return back to + // the main form with a flash notice. + try { + $id = rand( 1, count( $this->getParameter( 'quotes' ) ) ); + } catch ( InvalidParameterException $e ) { + $this->addFlashMessage( 'notice', 'noquotes' ); + return $this->redirectToRoute( 'Quote' ); + } - return $this->redirectToRoute('QuoteID', ['id' => $id]); - } + return $this->redirectToRoute( 'QuoteID', [ 'id' => $id ] ); + } - /** - * Method to show all quotes. - */ - #[Route("/quote/all", name: "QuoteAll")] - #[Route("/bash/all", name: "BashAll")] - public function quoteAllAction(): Response - { - // Load up an array of all the quotes. - // if we can't find the quotes, return back to the main form with - // a flash notice. - try { - $quotes = $this->getParameter('quotes'); - } catch (InvalidParameterException $e) { - $this->addFlashMessage('notice', 'noquotes'); - return $this->redirectToRoute('Quote'); - } + #[Route( "/quote/all", name: "QuoteAll" )] + #[Route( "/bash/all", name: "BashAll" )] + /** + * Method to show all quotes. + */ + public function quoteAllAction(): Response { + // Load up an array of all the quotes. + // if we can't find the quotes, return back to the main form with + // a flash notice. + try { + $quotes = $this->getParameter( 'quotes' ); + } catch ( InvalidParameterException $e ) { + $this->addFlashMessage( 'notice', 'noquotes' ); + return $this->redirectToRoute( 'Quote' ); + } - // Render the page. - return $this->render( - 'quote/all.html.twig', - [ - 'xtPage' => 'Quote', - 'quotes' => $quotes, - ] - ); - } + // Render the page. + return $this->render( + 'quote/all.html.twig', + [ + 'xtPage' => 'Quote', + 'quotes' => $quotes, + ] + ); + } - /** - * Method to render a single quote. - */ - #[Route("/quote/{id}", name: "QuoteID", requirements: ["id" => "\d+"])] - #[Route("/bash/{id}", name: "BashID", requirements: ["id" => "\d+"])] - public function quoteAction(int $id): Response - { - // Get the singular quote. - // If we can't find the quotes, return back to the main form with a flash notice. - try { - if (isset($this->getParameter('quotes')[$id])) { - $text = $this->getParameter('quotes')[$id]; - } else { - throw new InvalidParameterException("Quote doesn't exist'"); - } - } catch (InvalidParameterException $e) { - $this->addFlashMessage('notice', 'noquotes'); - return $this->redirectToRoute('Quote'); - } + #[Route( "/quote/{id}", name: "QuoteID", requirements: [ "id" => "\d+" ] )] + #[Route( "/bash/{id}", name: "BashID", requirements: [ "id" => "\d+" ] )] + /** + * Method to render a single quote. + */ + public function quoteAction( int $id ): Response { + // Get the singular quote. + // If we can't find the quotes, return back to the main form with a flash notice. + try { + if ( isset( $this->getParameter( 'quotes' )[$id] ) ) { + $text = $this->getParameter( 'quotes' )[$id]; + } else { + throw new InvalidParameterException( "Quote doesn't exist'" ); + } + } catch ( InvalidParameterException $e ) { + $this->addFlashMessage( 'notice', 'noquotes' ); + return $this->redirectToRoute( 'Quote' ); + } - // If the text is undefined, that quote doesn't exist. - // Redirect back to the main form. - if (!isset($text)) { - $this->addFlashMessage('notice', 'noquotes'); - return $this->redirectToRoute('Quote'); - } + // If the text is undefined, that quote doesn't exist. + // Redirect back to the main form. + if ( !isset( $text ) ) { + $this->addFlashMessage( 'notice', 'noquotes' ); + return $this->redirectToRoute( 'Quote' ); + } - // Show the quote. - return $this->render( - 'quote/view.html.twig', - [ - 'xtPage' => 'Quote', - 'text' => $text, - 'id' => $id, - ] - ); - } + // Show the quote. + return $this->render( + 'quote/view.html.twig', + [ + 'xtPage' => 'Quote', + 'text' => $text, + 'id' => $id, + ] + ); + } - /************************ API endpoints ************************/ + /************************ API endpoints */ - /** - * Get random quote. - * @OA\Tag(name="Quote API") - * @OA\Get(description="Get a random quote. The quotes are sourced from [developer quips](https://w.wiki/6rpo) - and [IRC quotes](https://meta.wikimedia.org/wiki/IRC/Quotes/archives).") - * @OA\Response( - * response=200, - * description="Quote keyed by ID.", - * @OA\JsonContent( - * @OA\Property(property="", type="string") - * ) - * ) - * @codeCoverageIgnore - */ - #[Route("/api/quote/random", name: "QuoteApiRandom", methods: ["GET"])] - public function randomQuoteApiAction(): JsonResponse - { - $this->validateIsEnabled(); + #[OA\Tag( name: "Quote API" )] + #[OA\Get( + description: "Get a random quote. The quotes are sourced from [developer quips](https://w.wiki/6rpo) " . + "and [IRC quotes](https://meta.wikimedia.org/wiki/IRC/Quotes/archives).", + responses: [ + new OA\Response( + response: 200, + description: "Quote keyed by ID.", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "", type: "string" ) + ] + ) + ) + ] + )] + #[Route( "/api/quote/random", name: "QuoteApiRandom", methods: [ "GET" ] )] + /** + * Get random quote. + * @codeCoverageIgnore + */ + public function randomQuoteApiAction(): JsonResponse { + $this->validateIsEnabled(); - $this->recordApiUsage('quote/random'); - $quotes = $this->getParameter('quotes'); - $id = array_rand($quotes); + $this->recordApiUsage( 'quote/random' ); + $quotes = $this->getParameter( 'quotes' ); + $id = array_rand( $quotes ); - return new JsonResponse( - [$id => $quotes[$id]], - Response::HTTP_OK - ); - } + return new JsonResponse( + [ $id => $quotes[$id] ], + Response::HTTP_OK + ); + } - /** - * Get all quotes. - * @OA\Tag(name="Quote API") - * @OA\Get(description="Get a list of all quotes, sourced from [developer quips](https://w.wiki/6rpo) - and [IRC quotes](https://meta.wikimedia.org/wiki/IRC/Quotes/archives).") - * @OA\Response( - * response=200, - * description="All quotes, keyed by ID.", - * @OA\JsonContent( - * @OA\Property(property="", type="string") - * ) - * ) - * @codeCoverageIgnore - */ - #[Route("/api/quote/all", name: "QuoteApiAll", methods: ["GET"])] - public function allQuotesApiAction(): JsonResponse - { - $this->validateIsEnabled(); + #[OA\Tag( name: "Quote API" )] + #[OA\Get( + description: "Get a list of all quotes, sourced from [developer quips](https://w.wiki/6rpo) and " . + "[IRC quotes](https://meta.wikimedia.org/wiki/IRC/Quotes/archives).", + responses: [ + new OA\Response( + response: 200, + description: "All quotes, keyed by ID.", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "", type: "string" ) + ] + ) + ) + ] + )] + #[Route( "/api/quote/all", name: "QuoteApiAll", methods: [ "GET" ] )] + /** + * Get all quotes. + * @codeCoverageIgnore + */ + public function allQuotesApiAction(): JsonResponse { + $this->validateIsEnabled(); - $this->recordApiUsage('quote/all'); - $quotes = $this->getParameter('quotes'); - $numberedQuotes = []; + $this->recordApiUsage( 'quote/all' ); + $quotes = $this->getParameter( 'quotes' ); + $numberedQuotes = []; - // Number the quotes, since they somehow have significance. - foreach ($quotes as $index => $quote) { - $numberedQuotes[(string)($index + 1)] = $quote; - } + // Number the quotes, since they somehow have significance. + foreach ( $quotes as $index => $quote ) { + $numberedQuotes[(string)( $index + 1 )] = $quote; + } - return new JsonResponse($numberedQuotes, Response::HTTP_OK); - } + return new JsonResponse( $numberedQuotes, Response::HTTP_OK ); + } - /** - * Get the quote with the given ID. - * @OA\Tag(name="Quote API") - * @OA\Get(description="Get a quote with the given ID.") - * @OA\Parameter(name="id", in="path", required="true", @OA\Schema(type="integer", minimum=0)) - * @OA\Response( - * response=200, - * description="Quote keyed by ID.", - * @OA\JsonContent( - * @OA\Property(property="", type="string") - * ) - * ) - * @codeCoverageIgnore - */ - #[Route("/api/quote/{id}", name: "QuoteApiQuote", requirements: ["id" => "\d+"], methods: ["GET"])] - public function singleQuotesApiAction(int $id): JsonResponse - { - $this->validateIsEnabled(); + #[OA\Tag( name: "Quote API" )] + #[OA\Get( + description: "Get a quote with the given ID.", + responses: [ + new OA\Response( + response: 200, + description: "Quote keyed by ID.", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "", type: "string" ) + ] + ) + ) + ] + )] + #[OA\Parameter( + name: "id", + in: "path", + required: true, + schema: new OA\Schema( type: "integer", minimum: 0 ) + )] + #[Route( "/api/quote/{id}", name: "QuoteApiQuote", requirements: [ "id" => "\d+" ], methods: [ "GET" ] )] + /** + * Get the quote with the given ID. + * @codeCoverageIgnore + */ + public function singleQuotesApiAction( int $id ): JsonResponse { + $this->validateIsEnabled(); - $this->recordApiUsage('quote/id'); - $quotes = $this->getParameter('quotes'); + $this->recordApiUsage( 'quote/id' ); + $quotes = $this->getParameter( 'quotes' ); - if (!isset($quotes[$id])) { - return new JsonResponse( - [ - 'error' => [ - 'code' => Response::HTTP_NOT_FOUND, - 'message' => 'No quote found with ID '.$id, - ], - ], - Response::HTTP_NOT_FOUND - ); - } + if ( !isset( $quotes[$id] ) ) { + return new JsonResponse( + [ + 'error' => [ + 'code' => Response::HTTP_NOT_FOUND, + 'message' => 'No quote found with ID ' . $id, + ], + ], + Response::HTTP_NOT_FOUND + ); + } - return new JsonResponse([ - $id => $quotes[$id], - ], Response::HTTP_OK); - } + return new JsonResponse( [ + $id => $quotes[$id], + ], Response::HTTP_OK ); + } - /** - * Validate that the Quote tool is enabled, and throw a 404 if it is not. - * This is normally done by DisabledToolSubscriber but we have special logic here, because for Labs we want to - * show the quote in the footer but not expose the web interface. - * @throws NotFoundHttpException - */ - private function validateIsEnabled(): void - { - $isLabs = $this->getParameter('app.is_wmf'); - if (!$isLabs && !$this->getParameter('enable.Quote')) { - throw $this->createNotFoundException('This tool is disabled'); - } - } + /** + * Validate that the Quote tool is enabled, and throw a 404 if it is not. + * This is normally done by DisabledToolSubscriber but we have special logic here, because for Labs we want to + * show the quote in the footer but not expose the web interface. + * @throws NotFoundHttpException + */ + private function validateIsEnabled(): void { + $isLabs = $this->getParameter( 'app.is_wmf' ); + if ( !$isLabs && !$this->getParameter( 'enable.Quote' ) ) { + throw $this->createNotFoundException( 'This tool is disabled' ); + } + } } diff --git a/src/Controller/SimpleEditCounterController.php b/src/Controller/SimpleEditCounterController.php index 7f5413369..b1e83f746 100644 --- a/src/Controller/SimpleEditCounterController.php +++ b/src/Controller/SimpleEditCounterController.php @@ -1,12 +1,12 @@ params['project']) && isset($this->params['username'])) { - return $this->redirectToRoute('SimpleEditCounterResult', $this->params); - } + #[Route( path: '/sc', name: 'SimpleEditCounter' )] + #[Route( path: '/sc/index.php', name: 'SimpleEditCounterIndexPhp' )] + #[Route( path: '/sc/{project}', name: 'SimpleEditCounterProject' )] + /** + * The Simple Edit Counter search form. + */ + public function indexAction(): Response { + // Redirect if project and username are given. + if ( isset( $this->params['project'] ) && isset( $this->params['username'] ) ) { + return $this->redirectToRoute( 'SimpleEditCounterResult', $this->params ); + } - // Show the form. - return $this->render('simpleEditCounter/index.html.twig', array_merge([ - 'xtPageTitle' => 'tool-simpleeditcounter', - 'xtSubtitle' => 'tool-simpleeditcounter-desc', - 'xtPage' => 'SimpleEditCounter', + // Show the form. + return $this->render( 'simpleEditCounter/index.html.twig', array_merge( [ + 'xtPageTitle' => 'tool-simpleeditcounter', + 'xtSubtitle' => 'tool-simpleeditcounter-desc', + 'xtPage' => 'SimpleEditCounter', - // Defaults that will get overridden if in $params. - 'namespace' => 'all', - 'start' => '', - 'end' => '', - ], $this->params, ['project' => $this->project])); - } + // Defaults that will get overridden if in $params. + 'namespace' => 'all', + 'start' => '', + 'end' => '', + ], $this->params, [ 'project' => $this->project ] ) ); + } - private function prepareSimpleEditCounter(SimpleEditCounterRepository $simpleEditCounterRepo): SimpleEditCounter - { - $sec = new SimpleEditCounter( - $simpleEditCounterRepo, - $this->project, - $this->user, - $this->namespace, - $this->start, - $this->end - ); - $sec->prepareData(); + private function prepareSimpleEditCounter( SimpleEditCounterRepository $simpleEditCounterRepo ): SimpleEditCounter { + $sec = new SimpleEditCounter( + $simpleEditCounterRepo, + $this->project, + $this->user, + $this->namespace, + $this->start, + $this->end + ); + $sec->prepareData(); - if ($sec->isLimited()) { - $this->addFlash('warning', $this->i18n->msg('simple-counter-limited-results')); - } + if ( $sec->isLimited() ) { + $this->addFlash( 'warning', $this->i18n->msg( 'simple-counter-limited-results' ) ); + } - return $sec; - } + return $sec; + } - /** - * Display the results. - * @codeCoverageIgnore - */ - #[Route( - '/sc/{project}/{username}/{namespace}/{start}/{end}', - name: 'SimpleEditCounterResult', - requirements: [ - 'username' => '(ipr-.+\/\d+[^\/])|([^\/]+)', - 'namespace' => '|all|\d+', - 'start' => '|\d{4}-\d{2}-\d{2}', - 'end' => '|\d{4}-\d{2}-\d{2}', - ], - defaults: [ - 'start' => false, - 'end' => false, - 'namespace' => 'all', - ] - )] - public function resultAction(SimpleEditCounterRepository $simpleEditCounterRepo): Response - { - $sec = $this->prepareSimpleEditCounter($simpleEditCounterRepo); + #[Route( + '/sc/{project}/{username}/{namespace}/{start}/{end}', + name: 'SimpleEditCounterResult', + requirements: [ + 'username' => '(ipr-.+\/\d+[^\/])|([^\/]+)', + 'namespace' => '|all|\d+', + 'start' => '|\d{4}-\d{2}-\d{2}', + 'end' => '|\d{4}-\d{2}-\d{2}', + ], + defaults: [ + 'start' => false, + 'end' => false, + 'namespace' => 'all', + ] + )] + /** + * Display the results. + * @codeCoverageIgnore + */ + public function resultAction( SimpleEditCounterRepository $simpleEditCounterRepo ): Response { + $sec = $this->prepareSimpleEditCounter( $simpleEditCounterRepo ); - return $this->getFormattedResponse('simpleEditCounter/result', [ - 'xtPage' => 'SimpleEditCounter', - 'xtTitle' => $this->user->getUsername(), - 'sec' => $sec, - ]); - } + return $this->getFormattedResponse( 'simpleEditCounter/result', [ + 'xtPage' => 'SimpleEditCounter', + 'xtTitle' => $this->user->getUsername(), + 'sec' => $sec, + ] ); + } - /************************ API endpoints ************************/ + /************************ API endpoints */ - /** - * API endpoint for the Simple Edit Counter. - * @OA\Tag(name="User API") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Parameter(ref="#/components/parameters/UsernameOrIp") - * @OA\Parameter(ref="#/components/parameters/Namespace") - * @OA\Parameter(ref="#/components/parameters/Start") - * @OA\Parameter(ref="#/components/parameters/End") - * @OA\Response( - * response=200, - * description="Simple edit count, along with user groups and global user groups.", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="username", ref="#/components/parameters/UsernameOrIp/schema"), - * @OA\Property(property="namespace", ref="#/components/parameters/Namespace/schema"), - * @OA\Property(property="start", ref="#components/parameters/Start/schema"), - * @OA\Property(property="end", ref="#components/parameters/End/schema"), - * @OA\Property(property="user_id", type="integer"), - * @OA\Property(property="live_edit_count", type="integer"), - * @OA\Property(property="deleted_edit_count", type="integer"), - * @OA\Property(property="user_groups", type="array", @OA\Items(type="string")), - * @OA\Property(property="global_user_groups", type="array", @OA\Items(type="string")), - * @OA\Property(property="elapsed_time", ref="#/components/schemas/elapsed_time") - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - * @codeCoverageIgnore - */ - #[Route( - '/api/user/simple_editcount/{project}/{username}/{namespace}/{start}/{end}', - name: 'SimpleEditCounterApi', - requirements: [ - 'username' => '(ipr-.+\/\d+[^\/])|([^\/]+)', - 'namespace' => '|all|\d+', - 'start' => '|\d{4}-\d{2}-\d{2}', - 'end' => '|\d{4}-\d{2}-\d{2}', - ], - defaults: [ - 'start' => false, - 'end' => false, - 'namespace' => 'all', - ], - methods: ['GET'] - )] - public function simpleEditCounterApiAction(SimpleEditCounterRepository $simpleEditCounterRepository): JsonResponse - { - $this->recordApiUsage('user/simple_editcount'); - $sec = $this->prepareSimpleEditCounter($simpleEditCounterRepository); - $data = $sec->getData(); - if ($this->user->isIpRange()) { - unset($data['deleted_edit_count']); - } - return $this->getFormattedApiResponse($data); - } + #[OA\Tag( name: "User API" )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Parameter( ref: "#/components/parameters/UsernameOrIp" )] + #[OA\Parameter( ref: "#/components/parameters/Namespace" )] + #[OA\Parameter( ref: "#/components/parameters/Start" )] + #[OA\Parameter( ref: "#/components/parameters/End" )] + #[OA\Response( + response: 200, + description: "Simple edit count, along with user groups and global user groups.", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "username", ref: "#/components/parameters/UsernameOrIp/schema" ), + new OA\Property( property: "namespace", ref: "#/components/parameters/Namespace/schema" ), + new OA\Property( property: "start", ref: "#/components/parameters/Start/schema" ), + new OA\Property( property: "end", ref: "#/components/parameters/End/schema" ), + new OA\Property( property: "user_id", type: "integer" ), + new OA\Property( property: "live_edit_count", type: "integer" ), + new OA\Property( property: "deleted_edit_count", type: "integer" ), + new OA\Property( property: "user_groups", type: "array", items: new OA\Items( type: "string" ) ), + new OA\Property( property: "global_user_groups", type: "array", items: new OA\Items( type: "string" ) ), + new OA\Property( property: "elapsed_time", ref: "#/components/schemas/elapsed_time" ), + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[OA\Response( ref: "#/components/responses/503", response: 503 )] + #[OA\Response( ref: "#/components/responses/504", response: 504 )] + #[Route( + '/api/user/simple_editcount/{project}/{username}/{namespace}/{start}/{end}', + name: 'SimpleEditCounterApi', + requirements: [ + 'username' => '(ipr-.+\/\d+[^\/])|([^\/]+)', + 'namespace' => '|all|\d+', + 'start' => '|\d{4}-\d{2}-\d{2}', + 'end' => '|\d{4}-\d{2}-\d{2}', + ], + defaults: [ + 'start' => false, + 'end' => false, + 'namespace' => 'all', + ], + methods: [ 'GET' ] + )] + /** + * API endpoint for the Simple Edit Counter. + * @codeCoverageIgnore + */ + public function simpleEditCounterApiAction( SimpleEditCounterRepository $simpleEditCounterRepo ): JsonResponse { + $this->recordApiUsage( 'user/simple_editcount' ); + $sec = $this->prepareSimpleEditCounter( $simpleEditCounterRepo ); + $data = $sec->getData(); + if ( $this->user->isIpRange() ) { + unset( $data['deleted_edit_count'] ); + } + return $this->getFormattedApiResponse( $data ); + } } diff --git a/src/Controller/TopEditsController.php b/src/Controller/TopEditsController.php index d1515552d..3c7222f41 100644 --- a/src/Controller/TopEditsController.php +++ b/src/Controller/TopEditsController.php @@ -1,6 +1,6 @@ getIndexRoute(); - } + /** + * @inheritDoc + * @codeCoverageIgnore + */ + public function tooHighEditCountRoute(): string { + return $this->getIndexRoute(); + } - /** - * The Top Edits by page action is exempt from the edit count limitation. - * @inheritDoc - * @codeCoverageIgnore - */ - public function tooHighEditCountActionAllowlist(): array - { - return ['singlePageTopEdits']; - } + /** + * The Top Edits by page action is exempt from the edit count limitation. + * @inheritDoc + * @codeCoverageIgnore + */ + public function tooHighEditCountActionAllowlist(): array { + return [ 'singlePageTopEdits' ]; + } - /** - * @inheritDoc - * @codeCoverageIgnore - */ - public function restrictedApiActions(): array - { - return ['namespaceTopEditsUserApi']; - } + /** + * @inheritDoc + * @codeCoverageIgnore + */ + public function restrictedApiActions(): array { + return [ 'namespaceTopEditsUserApi' ]; + } - /** - * Display the form. - */ - #[Route('/topedits', name: 'topedits')] - #[Route('/topedits', name: 'TopEdits')] - #[Route('/topedits/index.php', name: 'TopEditsIndex')] - #[Route('/topedits/{project}', name: 'TopEditsProject')] - public function indexAction(): Response - { - // Redirect if at minimum project and username are provided. - if (isset($this->params['project']) && isset($this->params['username'])) { - if (empty($this->params['page'])) { - return $this->redirectToRoute('TopEditsResultNamespace', $this->params); - } - return $this->redirectToRoute('TopEditsResultPage', $this->params); - } + #[Route( '/topedits', name: 'topedits' )] + #[Route( '/topedits', name: 'TopEdits' )] + #[Route( '/topedits/index.php', name: 'TopEditsIndex' )] + #[Route( '/topedits/{project}', name: 'TopEditsProject' )] + /** + * Display the form. + */ + public function indexAction(): Response { + // Redirect if at minimum project and username are provided. + if ( isset( $this->params['project'] ) && isset( $this->params['username'] ) ) { + if ( empty( $this->params['page'] ) ) { + return $this->redirectToRoute( 'TopEditsResultNamespace', $this->params ); + } + return $this->redirectToRoute( 'TopEditsResultPage', $this->params ); + } - return $this->render('topedits/index.html.twig', array_merge([ - 'xtPageTitle' => 'tool-topedits', - 'xtSubtitle' => 'tool-topedits-desc', - 'xtPage' => 'TopEdits', + return $this->render( 'topedits/index.html.twig', array_merge( [ + 'xtPageTitle' => 'tool-topedits', + 'xtSubtitle' => 'tool-topedits-desc', + 'xtPage' => 'TopEdits', - // Defaults that will get overriden if in $params. - 'namespace' => 0, - 'page' => '', - 'username' => '', - 'start' => '', - 'end' => '', - ], $this->params, ['project' => $this->project])); - } + // Defaults that will get overriden if in $params. + 'namespace' => 0, + 'page' => '', + 'username' => '', + 'start' => '', + 'end' => '', + ], $this->params, [ 'project' => $this->project ] ) ); + } - /** - * Every action in this controller (other than 'index') calls this first. - * @param TopEditsRepository $topEditsRepo - * @param AutomatedEditsHelper $autoEditsHelper - * @return TopEdits - * @codeCoverageIgnore - */ - public function setUpTopEdits(TopEditsRepository $topEditsRepo, AutomatedEditsHelper $autoEditsHelper): TopEdits - { - return new TopEdits( - $topEditsRepo, - $autoEditsHelper, - $this->project, - $this->user, - $this->page, - $this->namespace, - $this->start, - $this->end, - $this->limit, - (int)$this->request->query->get('pagination', 0) - ); - } + /** + * Every action in this controller (other than 'index') calls this first. + * @param TopEditsRepository $topEditsRepo + * @param AutomatedEditsHelper $autoEditsHelper + * @return TopEdits + * @codeCoverageIgnore + */ + public function setUpTopEdits( TopEditsRepository $topEditsRepo, AutomatedEditsHelper $autoEditsHelper ): TopEdits { + return new TopEdits( + $topEditsRepo, + $autoEditsHelper, + $this->project, + $this->user, + $this->page, + $this->namespace, + $this->start, + $this->end, + $this->limit, + (int)$this->request->query->get( 'pagination', 0 ) + ); + } - /** - * List top edits by this user for all pages in a particular namespace. - * @codeCoverageIgnore - */ - #[Route( - '/topedits/{project}/{username}/{namespace}/{start}/{end}', - name: 'TopEditsResultNamespace', - requirements: [ - 'username' => '(ipr-.+\/\d+[^\/])|([^\/]+)', - 'namespace' => '|all|\d+', - 'start' => '|\d{4}-\d{2}-\d{2}', - 'end' => '|\d{4}-\d{2}-\d{2}', - ], - defaults: ['namespace' => 'all', 'start' => false, 'end' => false] - )] - public function namespaceTopEditsAction( - TopEditsRepository $topEditsRepo, - AutomatedEditsHelper $autoEditsHelper - ): Response { - // Max number of rows per namespace to show. `null` here will use the TopEdits default. - $this->limit = $this->isSubRequest ? 10 : ($this->params['limit'] ?? null); + #[Route( + '/topedits/{project}/{username}/{namespace}/{start}/{end}', + name: 'TopEditsResultNamespace', + requirements: [ + 'username' => '(ipr-.+\/\d+[^\/])|([^\/]+)', + 'namespace' => '|all|\d+', + 'start' => '|\d{4}-\d{2}-\d{2}', + 'end' => '|\d{4}-\d{2}-\d{2}', + ], + defaults: [ 'namespace' => 'all', 'start' => false, 'end' => false ] + )] + /** + * List top edits by this user for all pages in a particular namespace. + * @codeCoverageIgnore + */ + public function namespaceTopEditsAction( + TopEditsRepository $topEditsRepo, + AutomatedEditsHelper $autoEditsHelper + ): Response { + // Max number of rows per namespace to show. `null` here will use the TopEdits default. + $this->limit = $this->isSubRequest ? 10 : ( $this->params['limit'] ?? null ); - $topEdits = $this->setUpTopEdits($topEditsRepo, $autoEditsHelper); - $topEdits->prepareData(); + $topEdits = $this->setUpTopEdits( $topEditsRepo, $autoEditsHelper ); + $topEdits->prepareData(); - $ret = [ - 'xtPage' => 'TopEdits', - 'xtTitle' => $this->user->getUsername(), - 'te' => $topEdits, - 'is_sub_request' => $this->isSubRequest, - ]; + $ret = [ + 'xtPage' => 'TopEdits', + 'xtTitle' => $this->user->getUsername(), + 'te' => $topEdits, + 'is_sub_request' => $this->isSubRequest, + ]; - // Output the relevant format template. - return $this->getFormattedResponse('topedits/result_namespace', $ret); - } + // Output the relevant format template. + return $this->getFormattedResponse( 'topedits/result_namespace', $ret ); + } - /** - * List top edits by this user for a particular page. - * @codeCoverageIgnore - * @todo Add pagination. - */ - #[Route( - '/topedits/{project}/{username}/{namespace}/{page}/{start}/{end}', - name: 'TopEditsResultPage', - requirements: [ - 'username' => '(ipr-.+\/\d+[^\/])|([^\/]+)', - 'namespace' => '|all|\d+', - 'page' => '(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?)?$', - 'start' => '|\d{4}-\d{2}-\d{2}', - 'end' => '|\d{4}-\d{2}-\d{2}', - ], - defaults: ['namespace' => 'all', 'start' => false, 'end' => false] - )] - public function singlePageTopEditsAction( - TopEditsRepository $topEditsRepo, - AutomatedEditsHelper $autoEditsHelper - ): Response { - $topEdits = $this->setUpTopEdits($topEditsRepo, $autoEditsHelper); - $topEdits->prepareData(); + #[Route( + '/topedits/{project}/{username}/{namespace}/{page}/{start}/{end}', + name: 'TopEditsResultPage', + requirements: [ + 'username' => '(ipr-.+\/\d+[^\/])|([^\/]+)', + 'namespace' => '|all|\d+', + 'page' => '(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?)?$', + 'start' => '|\d{4}-\d{2}-\d{2}', + 'end' => '|\d{4}-\d{2}-\d{2}', + ], + defaults: [ 'namespace' => 'all', 'start' => false, 'end' => false ] + )] + /** + * List top edits by this user for a particular page. + * @codeCoverageIgnore + * @todo Add pagination. + */ + public function singlePageTopEditsAction( + TopEditsRepository $topEditsRepo, + AutomatedEditsHelper $autoEditsHelper + ): Response { + $topEdits = $this->setUpTopEdits( $topEditsRepo, $autoEditsHelper ); + $topEdits->prepareData(); - // Send all to the template. - return $this->getFormattedResponse('topedits/result_page', [ - 'xtPage' => 'TopEdits', - 'xtTitle' => $this->user->getUsername() . ' - ' . $this->page->getTitle(), - 'te' => $topEdits, - ]); - } + // Send all to the template. + return $this->getFormattedResponse( 'topedits/result_page', [ + 'xtPage' => 'TopEdits', + 'xtTitle' => $this->user->getUsername() . ' - ' . $this->page->getTitle(), + 'te' => $topEdits, + ] ); + } - /************************ API endpoints ************************/ + /************************ API endpoints */ - /** - * Get the most-edited pages by a user. - * @OA\Tag(name="User API") - * @OA\Get(description="List the most-edited pages by a user in one or all namespaces.") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Parameter(ref="#/components/parameters/UsernameOrIp") - * @OA\Parameter(ref="#/components/parameters/Namespace") - * @OA\Parameter(ref="#/components/parameters/Start") - * @OA\Parameter(ref="#/components/parameters/End") - * @OA\Parameter(ref="#/components/parameters/Pagination") - * @OA\Response( - * response=200, - * description="Most-edited pages, keyed by namespace.", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="username", ref="#/components/parameters/UsernameOrIp/schema"), - * @OA\Property(property="namespace", ref="#/components/schemas/Namespace"), - * @OA\Property(property="start", ref="#/components/parameters/Start/schema"), - * @OA\Property(property="end", ref="#/components/parameters/End/schema"), - * @OA\Property(property="top_edits", type="object", - * @OA\Property(property="namespace ID", - * @OA\Property(property="namespace", ref="#/components/schemas/Namespace"), - * @OA\Property(property="page_title", ref="#/components/schemas/Page/properties/page_title"), - * @OA\Property(property="full_page_title", - * ref="#/components/schemas/Page/properties/full_page_title"), - * @OA\Property(property="redirect", ref="#/components/schemas/Page/properties/redirect"), - * @OA\Property(property="count", type="integer"), - * @OA\Property(property="assessment", ref="#/components/schemas/PageAssessment") - * ) - * ) - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=501, ref="#/components/responses/501") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - * @codeCoverageIgnore - */ - #[Route( - '/api/user/top_edits/{project}/{username}/{namespace}/{start}/{end}', - name: 'UserApiTopEditsNamespace', - requirements: [ - 'username' => '(ipr-.+\/\d+[^\/])|([^\/]+)', - 'namespace' => '|all|\d+', - 'start' => '|\d{4}-\d{2}-\d{2}', - 'end' => '|\d{4}-\d{2}-\d{2}', - ], - defaults: ['namespace' => 'all', 'start' => false, 'end' => false], - methods: ['GET'] - )] - public function namespaceTopEditsUserApiAction( - TopEditsRepository $topEditsRepo, - AutomatedEditsHelper $autoEditsHelper - ): JsonResponse { - $this->recordApiUsage('user/topedits'); + #[ + OA\Tag( name: "User API" ), + OA\Get( description: "List the most-edited pages by a user in one or all namespaces." ), + OA\Parameter( ref: "#/components/parameters/Project" ), + OA\Parameter( ref: "#/components/parameters/UsernameOrIp" ), + OA\Parameter( ref: "#/components/parameters/Namespace" ), + OA\Parameter( ref: "#/components/parameters/Start" ), + OA\Parameter( ref: "#/components/parameters/End" ), + OA\Parameter( ref: "#/components/parameters/Pagination" ), + OA\Response( + response: 200, + description: "Most-edited pages, keyed by namespace.", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "username", ref: "#/components/parameters/UsernameOrIp/schema" ), + new OA\Property( property: "namespace", ref: "#/components/schemas/Namespace" ), + new OA\Property( property: "start", ref: "#/components/parameters/Start/schema" ), + new OA\Property( property: "end", ref: "#/components/parameters/End/schema" ), + new OA\Property( + property: "top_edits", + properties: [ + new OA\Property( property: "namespace ID" ), + new OA\Property( property: "namespace", ref: "#/components/schemas/Namespace" ), + new OA\Property( + property: "page_title", ref: "#/components/schemas/Page/properties/page_title" + ), + new OA\Property( + property: "full_page_title", ref: "#/components/schemas/Page/properties/full_page_title" + ), + new OA\Property( + property: "redirect", ref: "#/components/schemas/Page/properties/redirect" + ), + new OA\Property( property: "count", type: "integer" ), + new OA\Property( property: "assessment", ref: "#/components/schemas/PageAssessment" ), + ], + type: "object" + ), + ] + ) + ), + OA\Response( ref: "#/components/responses/404", response: 404 ), + OA\Response( ref: "#/components/responses/501", response: 501 ), + OA\Response( ref: "#/components/responses/503", response: 503 ), + OA\Response( ref: "#/components/responses/504", response: 504 ) + ] + #[Route( + '/api/user/top_edits/{project}/{username}/{namespace}/{start}/{end}', + name: 'UserApiTopEditsNamespace', + requirements: [ + 'username' => '(ipr-.+\/\d+[^\/])|([^\/]+)', + 'namespace' => '|all|\d+', + 'start' => '|\d{4}-\d{2}-\d{2}', + 'end' => '|\d{4}-\d{2}-\d{2}', + ], + defaults: [ 'namespace' => 'all', 'start' => false, 'end' => false ], + methods: [ 'GET' ] + )] + /** + * Get the most-edited pages by a user. + * @codeCoverageIgnore + */ + public function namespaceTopEditsUserApiAction( + TopEditsRepository $topEditsRepo, + AutomatedEditsHelper $autoEditsHelper + ): JsonResponse { + $this->recordApiUsage( 'user/topedits' ); - $topEdits = $this->setUpTopEdits($topEditsRepo, $autoEditsHelper); - $topEdits->prepareData(); + $topEdits = $this->setUpTopEdits( $topEditsRepo, $autoEditsHelper ); + $topEdits->prepareData(); - return $this->getFormattedApiResponse([ - 'top_edits' => (object)$topEdits->getTopEdits(), - ]); - } + return $this->getFormattedApiResponse( [ + 'top_edits' => (object)$topEdits->getTopEdits(), + ] ); + } - /** - * Get the all edits made by a user to a specific page. - * @OA\Tag(name="User API") - * @OA\Get(description="Get all edits made by a user to a specific page.") - * @OA\Parameter(ref="#/components/parameters/Project") - * @OA\Parameter(ref="#/components/parameters/UsernameOrIp") - * @OA\Parameter(ref="#/components/parameters/Namespace") - * @OA\Parameter(ref="#/components/parameters/PageWithoutNamespace") - * @OA\Parameter(ref="#/components/parameters/Start") - * @OA\Parameter(ref="#/components/parameters/End") - * @OA\Parameter(ref="#/components/parameters/Pagination") - * @OA\Response( - * response=200, - * description="Edits to the page", - * @OA\JsonContent( - * @OA\Property(property="project", ref="#/components/parameters/Project/schema"), - * @OA\Property(property="username", ref="#/components/parameters/UsernameOrIp/schema"), - * @OA\Property(property="namespace", ref="#/components/schemas/Namespace"), - * @OA\Property(property="start", ref="#/components/parameters/Start/schema"), - * @OA\Property(property="end", ref="#/components/parameters/End/schema"), - * @OA\Property(property="top_edits", type="object", - * @OA\Property(property="namespace ID", - * @OA\Property(property="namespace", ref="#/components/schemas/Namespace"), - * @OA\Property(property="page_title", ref="#/components/schemas/Page/properties/page_title"), - * @OA\Property(property="full_page_title", - * ref="#/components/schemas/Page/properties/full_page_title"), - * @OA\Property(property="redirect", ref="#/components/schemas/Page/properties/redirect"), - * @OA\Property(property="count", type="integer"), - * @OA\Property(property="assessment", ref="#/components/schemas/PageAssessment") - * ) - * ) - * ) - * ) - * @OA\Response(response=404, ref="#/components/responses/404") - * @OA\Response(response=501, ref="#/components/responses/501") - * @OA\Response(response=503, ref="#/components/responses/503") - * @OA\Response(response=504, ref="#/components/responses/504") - * @codeCoverageIgnore - * @todo Add pagination. - */ - #[Route( - '/api/user/top_edits/{project}/{username}/{namespace}/{page}/{start}/{end}', - name: 'UserApiTopEditsPage', - requirements: [ - 'username' => '(ipr-.+\/\d+[^\/])|([^\/]+)', - 'namespace' => '|all|\d+', - 'page' => '(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?)?$', - 'start' => '|\d{4}-\d{2}-\d{2}', - 'end' => '|\d{4}-\d{2}-\d{2}', - ], - defaults: ['namespace' => 'all', 'start' => false, 'end' => false], - methods: ['GET'] - )] - public function singlePageTopEditsUserApiAction( - TopEditsRepository $topEditsRepo, - AutomatedEditsHelper $autoEditsHelper - ): JsonResponse { - $this->recordApiUsage('user/topedits'); + #[OA\Tag( name: "User API" )] + #[OA\Get( description: "Get all edits made by a user to a specific page." )] + #[OA\Parameter( ref: "#/components/parameters/Project" )] + #[OA\Parameter( ref: "#/components/parameters/UsernameOrIp" )] + #[OA\Parameter( ref: "#/components/parameters/Namespace" )] + #[OA\Parameter( ref: "#/components/parameters/PageWithoutNamespace" )] + #[OA\Parameter( ref: "#/components/parameters/Start" )] + #[OA\Parameter( ref: "#/components/parameters/End" )] + #[OA\Parameter( ref: "#/components/parameters/Pagination" )] + #[OA\Response( + response: 200, + description: "Edits to the page", + content: new OA\JsonContent( + properties: [ + new OA\Property( property: "project", ref: "#/components/parameters/Project/schema" ), + new OA\Property( property: "username", ref: "#/components/parameters/UsernameOrIp/schema" ), + new OA\Property( property: "namespace", ref: "#/components/schemas/Namespace" ), + new OA\Property( property: "start", ref: "#/components/parameters/Start/schema" ), + new OA\Property( property: "end", ref: "#/components/parameters/End/schema" ), + new OA\Property( + property: "top_edits", + properties: [ + new OA\Property( property: "namespace ID" ), + new OA\Property( property: "namespace", ref: "#/components/schemas/Namespace" ), + new OA\Property( + property: "page_title", ref: "#/components/schemas/Page/properties/page_title" + ), + new OA\Property( + property: "full_page_title", ref: "#/components/schemas/Page/properties/full_page_title" + ), + new OA\Property( property: "redirect", ref: "#/components/schemas/Page/properties/redirect" ), + new OA\Property( property: "count", type: "integer" ), + new OA\Property( property: "assessment", ref: "#/components/schemas/PageAssessment" ), + ], + type: "object" + ), + ] + ) + )] + #[OA\Response( ref: "#/components/responses/404", response: 404 )] + #[OA\Response( ref: "#/components/responses/501", response: 501 )] + #[OA\Response( ref: "#/components/responses/503", response: 503 )] + #[OA\Response( ref: "#/components/responses/504", response: 504 )] + #[Route( + '/api/user/top_edits/{project}/{username}/{namespace}/{page}/{start}/{end}', + name: 'UserApiTopEditsPage', + requirements: [ + 'username' => '(ipr-.+\/\d+[^\/])|([^\/]+)', + 'namespace' => '|all|\d+', + 'page' => '(.+?)(?!\/(?:|\d{4}-\d{2}-\d{2})(?:\/(|\d{4}-\d{2}-\d{2}))?)?$', + 'start' => '|\d{4}-\d{2}-\d{2}', + 'end' => '|\d{4}-\d{2}-\d{2}', + ], + defaults: [ 'namespace' => 'all', 'start' => false, 'end' => false ], + methods: [ 'GET' ] + )] + /** + * Get the all edits made by a user to a specific page. + * @todo Add pagination. + * @codeCoverageIgnore + */ + public function singlePageTopEditsUserApiAction( + TopEditsRepository $topEditsRepo, + AutomatedEditsHelper $autoEditsHelper + ): JsonResponse { + $this->recordApiUsage( 'user/topedits' ); - $topEdits = $this->setUpTopEdits($topEditsRepo, $autoEditsHelper); - $topEdits->prepareData(); + $topEdits = $this->setUpTopEdits( $topEditsRepo, $autoEditsHelper ); + $topEdits->prepareData(); - return $this->getFormattedApiResponse([ - 'top_edits' => array_map(function (Edit $edit) { - return $edit->getForJson(); - }, $topEdits->getTopEdits()), - ]); - } + return $this->getFormattedApiResponse( [ + 'top_edits' => array_map( static function ( Edit $edit ) { + return $edit->getForJson(); + }, $topEdits->getTopEdits() ), + ] ); + } } diff --git a/src/Controller/XtoolsController.php b/src/Controller/XtoolsController.php index 6e8a36167..f5ff3e2d9 100644 --- a/src/Controller/XtoolsController.php +++ b/src/Controller/XtoolsController.php @@ -1,6 +1,6 @@ null, - ]; - - /** OVERRIDABLE METHODS */ - - /** - * Require the tool's index route (initial form) be defined here. This should also - * be the name of the associated model, if present. - * @return string - */ - abstract protected function getIndexRoute(): string; - - /** - * Override this to activate the 'too high edit count' functionality. The return value - * should represent the route name that we should be redirected to if the requested user - * has too high of an edit count. - * @return string|null Name of route to redirect to. - */ - protected function tooHighEditCountRoute(): ?string - { - return null; - } - - /** - * Override this to specify which actions - * @return string[] - */ - protected function tooHighEditCountActionAllowlist(): array - { - return []; - } - - /** - * Override to restrict a tool's access to only the specified projects, instead of any valid project. - * @return string[] Domain or DB names. - */ - protected function supportedProjects(): array - { - return []; - } - - /** - * Override this to set which API actions for the controller require the - * target user to opt in to the restricted statistics. - * @see https://www.mediawiki.org/wiki/XTools/Edit_Counter#restricted_stats - * @return array - */ - protected function restrictedApiActions(): array - { - return []; - } - - /** - * Override to set the maximum number of days allowed for the given date range. - * This will be used as the default date span unless $this->defaultDays() is overridden. - * @see XtoolsController::getUnixFromDateParams() - * @return int|null - */ - public function maxDays(): ?int - { - return null; - } - - /** - * Override to set default days from current day, to use as the start date if none was provided. - * If this is null and $this->maxDays() is non-null, the latter will be used as the default. - * @return int|null - */ - protected function defaultDays(): ?int - { - return null; - } - - /** - * Override to set the maximum number of results to show per page, default 5000. - * @return int - */ - protected function maxLimit(): int - { - return 5000; - } - - /** - * XtoolsController constructor. - * @param ContainerInterface $container - * @param RequestStack $requestStack - * @param ManagerRegistry $managerRegistry - * @param CacheItemPoolInterface $cache - * @param Client $guzzle - * @param I18nHelper $i18n - * @param ProjectRepository $projectRepo - * @param UserRepository $userRepo - * @param PageRepository $pageRepo - * @param Environment $twig - * @param bool $isWMF - * @param string $defaultProject - */ - public function __construct( - ContainerInterface $container, - RequestStack $requestStack, - protected ManagerRegistry $managerRegistry, - protected CacheItemPoolInterface $cache, - protected Client $guzzle, - protected I18nHelper $i18n, - protected ProjectRepository $projectRepo, - protected UserRepository $userRepo, - protected PageRepository $pageRepo, - protected Environment $twig, - /** @var bool Whether this is a WMF installation. */ - protected bool $isWMF, - /** @var string The configured default project. */ - protected string $defaultProject, - ) { - $this->container = $container; - $this->request = $requestStack->getCurrentRequest(); - $this->params = $this->parseQueryParams(); - - // Parse out the name of the controller and action. - $pattern = "#::([a-zA-Z]*)Action#"; - $matches = []; - // The blank string here only happens in the unit tests, where the request may not be made to an action. - preg_match($pattern, $this->request->get('_controller') ?? '', $matches); - $this->controllerAction = $matches[1] ?? ''; - - // Whether the action is an API action. - $this->isApi = 'Api' === substr($this->controllerAction, -3) || 'recordUsage' === $this->controllerAction; - - // Whether we're making a subrequest (the view makes a request to another action). - $this->isSubRequest = $this->request->get('htmlonly') - || null !== $requestStack->getParentRequest(); - - // Disallow AJAX (unless it's an API or subrequest). - $this->checkIfAjax(); - - // Load user options from cookies. - $this->loadCookies(); - - // Set the class-level properties based on params. - if (false !== strpos(strtolower($this->controllerAction), 'index')) { - // Index pages should only set the project, and no other class properties. - $this->setProject($this->getProjectFromQuery()); - - // ...except for transforming IP ranges. Because Symfony routes are separated by slashes, we need a way to - // indicate a CIDR range because otherwise i.e. the path /sc/enwiki/192.168.0.0/24 could be interpreted as - // the Simple Edit Counter for 192.168.0.0 in the namespace with ID 24. So we prefix ranges with 'ipr-'. - // Further IP range handling logic is in the User class, i.e. see User::__construct, User::isIpRange. - if (isset($this->params['username']) && IPUtils::isValidRange($this->params['username'])) { - $this->params['username'] = 'ipr-'.$this->params['username']; - } - } else { - $this->setProperties(); // Includes the project. - } - - // Check if the request is to a restricted API endpoint, where the target user has to opt-in to statistics. - $this->checkRestrictedApiEndpoint(); - } - - /** - * Check if the request is AJAX, and disallow it unless they're using the API or if it's a subrequest. - */ - private function checkIfAjax(): void - { - if ($this->request->isXmlHttpRequest() && !$this->isApi && !$this->isSubRequest) { - throw new HttpException( - Response::HTTP_FORBIDDEN, - $this->i18n->msg('error-automation', ['https://www.mediawiki.org/Special:MyLanguage/XTools/API']) - ); - } - } - - /** - * Check if the request is to a restricted API endpoint, and throw an exception if the target user hasn't opted-in. - * @throws XtoolsHttpException - */ - private function checkRestrictedApiEndpoint(): void - { - $restrictedAction = in_array($this->controllerAction, $this->restrictedApiActions()); - - if ($this->isApi && $restrictedAction && !$this->project->userHasOptedIn($this->user)) { - throw new XtoolsHttpException( - $this->i18n->msg('not-opted-in', [ - $this->getOptedInPage()->getTitle(), - $this->i18n->msg('not-opted-in-link') . - ' ', - $this->i18n->msg('not-opted-in-login'), - ]), - '', - $this->params, - true, - Response::HTTP_UNAUTHORIZED - ); - } - } - - /** - * Get the path to the opt-in page for restricted statistics. - * @return Page - */ - protected function getOptedInPage(): Page - { - return new Page($this->pageRepo, $this->project, $this->project->userOptInPage($this->user)); - } - - /*********** - * COOKIES * - ***********/ - - /** - * Load user preferences from the associated cookies. - */ - private function loadCookies(): void - { - // Not done for subrequests. - if ($this->isSubRequest) { - return; - } - - foreach (array_keys($this->cookies) as $name) { - $this->cookies[$name] = $this->request->cookies->get($name); - } - } - - /** - * Set cookies on the given Response. - * @param Response $response - */ - private function setCookies(Response $response): void - { - // Not done for subrequests. - if ($this->isSubRequest) { - return; - } - - foreach ($this->cookies as $name => $value) { - $response->headers->setCookie( - Cookie::create($name, $value) - ); - } - } - - /** - * Sets the project, with the domain in $this->cookies['XtoolsProject'] that will - * later get set on the Response headers in self::getFormattedResponse(). - * @param Project $project - */ - private function setProject(Project $project): void - { - $this->project = $project; - $this->cookies['XtoolsProject'] = $project->getDomain(); - } - - /**************************** - * SETTING CLASS PROPERTIES * - ****************************/ - - /** - * Normalize all common parameters used by the controllers and set class properties. - */ - private function setProperties(): void - { - $this->namespace = $this->params['namespace'] ?? null; - - // Offset is given as ISO timestamp and is stored as a UNIX timestamp (or false). - if (isset($this->params['offset'])) { - $this->offset = strtotime($this->params['offset']); - } - - // Limit needs to be an int. - if (isset($this->params['limit'])) { - // Normalize. - $this->params['limit'] = min(max(1, (int)$this->params['limit']), $this->maxLimit()); - $this->limit = $this->params['limit']; - } - - if (isset($this->params['project'])) { - $this->setProject($this->validateProject($this->params['project'])); - } elseif (null !== $this->cookies['XtoolsProject']) { - // Set from cookie. - $this->setProject( - $this->validateProject($this->cookies['XtoolsProject']) - ); - } - - if (isset($this->params['username'])) { - $this->user = $this->validateUser($this->params['username']); - } - if (isset($this->params['page'])) { - $this->page = $this->getPageFromNsAndTitle($this->namespace, $this->params['page']); - } - - $this->setDates(); - } - - /** - * Set class properties for dates, if such params were passed in. - */ - private function setDates(): void - { - $start = $this->params['start'] ?? false; - $end = $this->params['end'] ?? false; - if ($start || $end || null !== $this->maxDays()) { - [$this->start, $this->end] = $this->getUnixFromDateParams($start, $end); - - // Set $this->params accordingly too, so that for instance API responses will include it. - $this->params['start'] = is_int($this->start) ? date('Y-m-d', $this->start) : false; - $this->params['end'] = is_int($this->end) ? date('Y-m-d', $this->end) : false; - } - } - - /** - * Construct a fully qualified page title given the namespace and title. - * @param int|string $ns Namespace ID. - * @param string $title Page title. - * @param bool $rawTitle Return only the title (and not a Page). - * @return Page|string - */ - protected function getPageFromNsAndTitle($ns, string $title, bool $rawTitle = false) - { - if (0 === (int)$ns) { - return $rawTitle ? $title : $this->validatePage($title); - } - - // Prepend namespace and strip out duplicates. - $nsName = $this->project->getNamespaces()[$ns] ?? $this->i18n->msg('unknown'); - $title = $nsName.':'.preg_replace('/^'.$nsName.':/', '', $title); - return $rawTitle ? $title : $this->validatePage($title); - } - - /** - * Get a Project instance from the project string, using defaults if the given project string is invalid. - * @return Project - */ - public function getProjectFromQuery(): Project - { - // Set default project so we can populate the namespace selector on index pages. - // Defaults to project stored in cookie, otherwise project specified in parameters.yml. - if (isset($this->params['project'])) { - $project = $this->params['project']; - } elseif (null !== $this->cookies['XtoolsProject']) { - $project = $this->cookies['XtoolsProject']; - } else { - $project = $this->defaultProject; - } - - $projectData = $this->projectRepo->getProject($project); - - // Revert back to defaults if we've established the given project was invalid. - if (!$projectData->exists()) { - $projectData = $this->projectRepo->getProject($this->defaultProject); - } - - return $projectData; - } - - /************************* - * GETTERS / VALIDATIONS * - *************************/ - - /** - * Validate the given project, returning a Project if it is valid or false otherwise. - * @param string $projectQuery Project domain or database name. - * @return Project - * @throws XtoolsHttpException - */ - public function validateProject(string $projectQuery): Project - { - $project = $this->projectRepo->getProject($projectQuery); - - // Check if it is an explicitly allowed project for the current tool. - if ($this->supportedProjects() && !in_array($project->getDomain(), $this->supportedProjects())) { - $this->throwXtoolsException( - $this->getIndexRoute(), - 'error-authorship-unsupported-project', - [$this->params['project']], - 'project' - ); - } - - if (!$project->exists()) { - $this->throwXtoolsException( - $this->getIndexRoute(), - 'invalid-project', - [$this->params['project']], - 'project' - ); - } - - return $project; - } - - /** - * Validate the given user, returning a User or Redirect if they don't exist. - * @param string $username - * @return User - * @throws XtoolsHttpException - */ - public function validateUser(string $username): User - { - $user = new User($this->userRepo, $username); - - // Allow querying for any IP, currently with no edit count limitation... - // Once T188677 is resolved IPs will be affected by the EXPLAIN results. - if ($user->isIP()) { - // Validate CIDR limits. - if (!$user->isQueryableRange()) { - $limit = $user->isIPv6() ? User::MAX_IPV6_CIDR : User::MAX_IPV4_CIDR; - $this->throwXtoolsException($this->getIndexRoute(), 'ip-range-too-wide', [$limit], 'username'); - } - return $user; - } - - // Check against centralauth for global tools. - $isGlobalTool = str_contains($this->request->get('_controller', ''), 'Global'); - if ($isGlobalTool && !$user->existsGlobally()) { - $this->throwXtoolsException($this->getIndexRoute(), 'user-not-found', [], 'username'); - } elseif (!$isGlobalTool && isset($this->project) && !$user->existsOnProject($this->project)) { - // Don't continue if the user doesn't exist. - $this->throwXtoolsException($this->getIndexRoute(), 'user-not-found', [], 'username'); - } - - if (isset($this->project) && $user->hasManyEdits($this->project)) { - $this->handleHasManyEdits($user); - } - - return $user; - } - - private function handleHasManyEdits(User $user): void - { - $originalParams = $this->params; - $actionAllowlisted = in_array($this->controllerAction, $this->tooHighEditCountActionAllowlist()); - - // Reject users with a crazy high edit count. - if ($this->tooHighEditCountRoute() && - !$actionAllowlisted && - $user->hasTooManyEdits($this->project) - ) { - /** TODO: Somehow get this to use self::throwXtoolsException */ - - // If redirecting to a different controller, show an informative message accordingly. - if ($this->tooHighEditCountRoute() !== $this->getIndexRoute()) { - // FIXME: This is currently only done for Edit Counter, redirecting to Simple Edit Counter, - // so this bit is hardcoded. We need to instead give the i18n key of the route. - $redirMsg = $this->i18n->msg('too-many-edits-redir', [ - $this->i18n->msg('tool-simpleeditcounter'), - ]); - $msg = $this->i18n->msg('too-many-edits', [ - $this->i18n->numberFormat($user->maxEdits()), - ]).'. '.$redirMsg; - $this->addFlashMessage('danger', $msg); - } else { - $this->addFlashMessage('danger', 'too-many-edits', [ - $this->i18n->numberFormat($user->maxEdits()), - ]); - - // Redirecting back to index, so remove username (otherwise we'd get a redirect loop). - unset($this->params['username']); - } - - // Clear flash bag for API responses, since they get intercepted in ExceptionListener - // and would otherwise be shown in subsequent requests. - if ($this->isApi) { - $this->getFlashBag()?->clear(); - } - - throw new XtoolsHttpException( - $this->i18n->msg('too-many-edits', [ $user->maxEdits() ]), - $this->generateUrl($this->tooHighEditCountRoute(), $this->params), - $originalParams, - $this->isApi, - Response::HTTP_NOT_IMPLEMENTED - ); - } - - // Require login for users with a semi-crazy high edit count. - // For now, this only effects HTML requests and not the API. - if (!$this->isApi && !$actionAllowlisted && !$this->request->getSession()->get('logged_in_user')) { - throw new AccessDeniedHttpException('error-login-required'); - } - } - - /** - * Get a Page instance from the given page title, and validate that it exists. - * @param string $pageTitle - * @return Page - * @throws XtoolsHttpException - */ - public function validatePage(string $pageTitle): Page - { - $page = new Page($this->pageRepo, $this->project, $pageTitle); - - if (!$page->exists()) { - $this->throwXtoolsException( - $this->getIndexRoute(), - 'no-result', - [$this->params['page'] ?? null], - 'page' - ); - } - - return $page; - } - - /** - * Throw an XtoolsHttpException, which the given error message and redirects to specified action. - * @param string $redirectAction Name of action to redirect to. - * @param string $message i18n key of error message. Shown in API responses. - * If no message with this key exists, $message is shown as-is. - * @param array $messageParams - * @param string|null $invalidParam This will be removed from $this->params. Omit if you don't want this to happen. - * @throws XtoolsHttpException - */ - public function throwXtoolsException( - string $redirectAction, - string $message, - array $messageParams = [], - ?string $invalidParam = null - ): void { - $this->addFlashMessage('danger', $message, $messageParams); - $originalParams = $this->params; - - // Remove invalid parameter if it was given. - if (is_string($invalidParam)) { - unset($this->params[$invalidParam]); - } - - // We sometimes are redirecting to the index page, so also remove project (otherwise we'd get a redirect loop). - /** - * FIXME: Index pages should have a 'nosubmit' parameter to prevent submission. - * Then we don't even need to remove $invalidParam. - * Better, we should show the error on the results page, with no results. - */ - unset($this->params['project']); - - // Throw exception which will redirect to $redirectAction. - throw new XtoolsHttpException( - $this->i18n->msgIfExists($message, $messageParams), - $this->generateUrl($redirectAction, $this->params), - $originalParams, - $this->isApi - ); - } - - /****************** - * PARSING PARAMS * - ******************/ - - /** - * Get all standardized parameters from the Request, either via URL query string or routing. - * @return string[] - */ - public function getParams(): array - { - $paramsToCheck = [ - 'project', - 'username', - 'namespace', - 'page', - 'categories', - 'group', - 'redirects', - 'deleted', - 'start', - 'end', - 'offset', - 'limit', - 'format', - 'tool', - 'tools', - 'q', - 'include_pattern', - 'exclude_pattern', - 'classonly', - - // Legacy parameters. - 'user', - 'name', - 'article', - 'wiki', - 'wikifam', - 'lang', - 'wikilang', - 'begin', - ]; - - /** @var string[] $params Each parameter that was detected along with its value. */ - $params = []; - - foreach ($paramsToCheck as $param) { - // Pull in either from URL query string or route. - $value = $this->request->query->get($param) ?: $this->request->get($param); - - // Only store if value is given ('namespace' or 'username' could be '0'). - if (null !== $value && '' !== $value) { - $params[$param] = rawurldecode((string)$value); - } - } - - return $params; - } - - /** - * Parse out common parameters from the request. These include the 'project', 'username', 'namespace' and 'page', - * along with their legacy counterparts (e.g. 'lang' and 'wiki'). - * @return string[] Normalized parameters (no legacy params). - */ - public function parseQueryParams(): array - { - $params = $this->getParams(); - - // Covert any legacy parameters, if present. - $params = $this->convertLegacyParams($params); - - // Remove blank values. - return array_filter($params, function ($param) { - // 'namespace' or 'username' could be '0'. - return null !== $param && '' !== $param; - }); - } - - /** - * Get Unix timestamps from given start and end string parameters. This also makes $start $maxDays() before - * $end if not present, and makes $end the current time if not present. - * The date range will not exceed $this->maxDays() days, if this public class property is set. - * @param int|string|false $start Unix timestamp or string accepted by strtotime. - * @param int|string|false $end Unix timestamp or string accepted by strtotime. - * @return int[] Start and end date as UTC timestamps. - */ - public function getUnixFromDateParams($start, $end): array - { - $today = strtotime('today midnight'); - - // start time should not be in the future. - $startTime = min( - is_int($start) ? $start : strtotime((string)$start), - $today - ); - - // end time defaults to now, and will not be in the future. - $endTime = min( - (is_int($end) ? $end : strtotime((string)$end)) ?: $today, - $today - ); - - // Default to $this->defaultDays() or $this->maxDays() before end time if start is not present. - $daysOffset = $this->defaultDays() ?? $this->maxDays(); - if (false === $startTime && $daysOffset) { - $startTime = strtotime("-$daysOffset days", $endTime); - } - - // Default to $this->defaultDays() or $this->maxDays() after start time if end is not present. - if (false === $end && $daysOffset) { - $endTime = min( - strtotime("+$daysOffset days", $startTime), - $today - ); - } - - // Reverse if start date is after end date. - if ($startTime > $endTime && false !== $startTime && false !== $end) { - $newEndTime = $startTime; - $startTime = $endTime; - $endTime = $newEndTime; - } - - // Finally, don't let the date range exceed $this->maxDays(). - $startObj = DateTime::createFromFormat('U', (string)$startTime); - $endObj = DateTime::createFromFormat('U', (string)$endTime); - if ($this->maxDays() && $startObj->diff($endObj)->days > $this->maxDays()) { - // Show warnings that the date range was truncated. - $this->addFlashMessage('warning', 'date-range-too-wide', [$this->maxDays()]); - - $startTime = strtotime('-' . $this->maxDays() . ' days', $endTime); - } - - return [$startTime, $endTime]; - } - - /** - * Given the params hash, normalize any legacy parameters to their modern equivalent. - * @param string[] $params - * @return string[] - */ - private function convertLegacyParams(array $params): array - { - $paramMap = [ - 'user' => 'username', - 'name' => 'username', - 'article' => 'page', - 'begin' => 'start', - - // Copy super legacy project params to legacy so we can concatenate below. - 'wikifam' => 'wiki', - 'wikilang' => 'lang', - ]; - - // Copy legacy parameters to modern equivalent. - foreach ($paramMap as $legacy => $modern) { - if (isset($params[$legacy])) { - $params[$modern] = $params[$legacy]; - unset($params[$legacy]); - } - } - - // Separate parameters for language and wiki. - if (isset($params['wiki']) && isset($params['lang'])) { - // 'wikifam' may be like '.wikipedia.org', vs just 'wikipedia', - // so we must remove leading periods and trailing .org's. - $params['project'] = $params['lang'].'.'.rtrim(ltrim($params['wiki'], '.'), '.org').'.org'; - unset($params['wiki']); - unset($params['lang']); - } - - return $params; - } - - /************************ - * FORMATTING RESPONSES * - ************************/ - - /** - * Get the rendered template for the requested format. This method also updates the cookies. - * @param string $templatePath Path to template without format, - * such as '/editCounter/latest_global'. - * @param array $ret Data that should be passed to the view. - * @return Response - * @codeCoverageIgnore - */ - public function getFormattedResponse(string $templatePath, array $ret): Response - { - $format = $this->request->query->get('format', 'html'); - if ('' == $format) { - // The default above doesn't work when the 'format' parameter is blank. - $format = 'html'; - } - - // Merge in common default parameters, giving $ret (from the caller) the priority. - $ret = array_merge([ - 'project' => $this->project, - 'user' => $this->user, - 'page' => $this->page ?? null, - 'namespace' => $this->namespace, - 'start' => $this->start, - 'end' => $this->end, - ], $ret); - - $formatMap = [ - 'wikitext' => 'text/plain', - 'csv' => 'text/csv', - 'tsv' => 'text/tab-separated-values', - 'json' => 'application/json', - ]; - - $response = new Response(); - - // Set cookies. Note this must be done before rendering the view, as the view may invoke subrequests. - $this->setCookies($response); - - // If requested format does not exist, assume HTML. - if (false === $this->twig->getLoader()->exists("$templatePath.$format.twig")) { - $format = 'html'; - } - - $response = $this->render("$templatePath.$format.twig", $ret, $response); - - $contentType = $formatMap[$format] ?? 'text/html'; - $response->headers->set('Content-Type', $contentType); - - if (in_array($format, ['csv', 'tsv'])) { - $filename = $this->getFilenameForRequest(); - $response->headers->set( - 'Content-Disposition', - "attachment; filename=\"{$filename}.$format\"" - ); - } - - return $response; - } - - /** - * Returns given filename from the current Request, with problematic characters filtered out. - * @return string - */ - private function getFilenameForRequest(): string - { - $filename = trim($this->request->getPathInfo(), '/'); - return trim(preg_replace('/[-\/\\:;*?|<>%#"]+/', '-', $filename)); - } - - /** - * Return a JsonResponse object pre-supplied with the requested params. - * @param array $data - * @param int $responseCode - * @return JsonResponse - */ - public function getFormattedApiResponse(array $data, int $responseCode = Response::HTTP_OK): JsonResponse - { - $response = new JsonResponse(); - $response->setEncodingOptions(JSON_NUMERIC_CHECK); - $response->setStatusCode($responseCode); - - // Normalize display of IP ranges (they are prefixed with 'ipr-' in the params). - if ($this->user && $this->user->isIpRange()) { - $this->params['username'] = $this->user->getUsername(); - } - - $ret = array_merge($this->params, [ - // In some controllers, $this->params['project'] may be overridden with a Project object. - 'project' => $this->project->getDomain(), - ], $data); - - // Merge in flash messages, putting them at the top. - $flashes = $this->getFlashBag()?->peekAll() ?? []; - $ret = array_merge($flashes, $ret); - - // Flashes now can be cleared after merging into the response. - $this->getFlashBag()?->clear(); - - // Normalize path param values. - $ret = self::normalizeApiProperties($ret); - - $response->setData($ret); - - return $response; - } - - /** - * Normalize the response data, adding in the elapsed_time. - * @param array $params - * @return array - */ - public static function normalizeApiProperties(array $params): array - { - foreach ($params as $param => $value) { - if (false === $value) { - // False values must be empty params. - unset($params[$param]); - } elseif (is_string($value) && false !== strpos($value, '|')) { - // Any pipe-separated values should be returned as an array. - $params[$param] = explode('|', $value); - } elseif ($value instanceof DateTime) { - // Convert DateTime objects to ISO 8601 strings. - $params[$param] = $value->format('Y-m-d\TH:i:s\Z'); - } - } - - $elapsedTime = round( - microtime(true) - $_SERVER['REQUEST_TIME_FLOAT'], - 3 - ); - return array_merge($params, ['elapsed_time' => $elapsedTime]); - } - - /** - * Parse a boolean value from the query string, treating 'false' and '0' as false. - * @param string $param - * @return bool - */ - public function getBoolVal(string $param): bool - { - return isset($this->params[$param]) && - !in_array($this->params[$param], ['false', '0']); - } - - /** - * Used to standardized the format of API responses that contain revisions. - * Adds a 'full_page_title' key and value to each entry in $data. - * If there are as many entries in $data as there are $this->limit, pagination is assumed - * and a 'continue' key is added to the end of the response body. - * @param string $key Key accessing the list of revisions in $data. - * @param array $out Whatever data needs to appear above the $data in the response body. - * @param array $data The data set itself. - * @return array - */ - public function addFullPageTitlesAndContinue(string $key, array $out, array $data): array - { - // Add full_page_title (in addition to the existing page_title and namespace keys). - $out[$key] = array_map(function ($rev) { - return array_merge([ - 'full_page_title' => $this->getPageFromNsAndTitle( - (int)$rev['namespace'], - $rev['page_title'], - true - ), - ], $rev); - }, $data); - - // Check if pagination is needed. - if (count($out[$key]) === $this->limit && count($out[$key]) > 0) { - // Use the timestamp of the last Edit as the value for the 'continue' return key, - // which can be used as a value for 'offset' in order to paginate results. - $timestamp = array_slice($out[$key], -1, 1)[0]['timestamp']; - $out['continue'] = (new DateTime($timestamp))->format('Y-m-d\TH:i:s\Z'); - } - - return $out; - } - - /********* - * OTHER * - *********/ - - /** - * Record usage of an API endpoint. - * @param string $endpoint - * @codeCoverageIgnore - */ - public function recordApiUsage(string $endpoint): void - { - /** @var Connection $conn */ - $conn = $this->managerRegistry->getConnection('default'); - $date = date('Y-m-d'); - - // Increment count in timeline - try { - $sql = "INSERT INTO usage_api_timeline +abstract class XtoolsController extends AbstractController { + /** OTHER CLASS PROPERTIES */ + + /** @var Request The request object. */ + protected Request $request; + + /** @var string Name of the action within the child controller that is being executed. */ + protected string $controllerAction; + + /** @var array Hash of params parsed from the Request. */ + protected array $params; + + /** @var bool Whether this is a request to an API action. */ + protected bool $isApi; + + /** @var Project Relevant Project parsed from the Request. */ + protected Project $project; + + /** @var User|null Relevant User parsed from the Request. */ + protected ?User $user = null; + + /** @var Page|null Relevant Page parsed from the Request. */ + protected ?Page $page = null; + + /** @var int|false Start date parsed from the Request. */ + protected int|false $start = false; + + /** @var int|false End date parsed from the Request. */ + protected int|false $end = false; + + /** @var int|string|null Namespace parsed from the Request, ID as int or 'all' for all namespaces. */ + protected int|string|null $namespace; + + /** @var int|false Unix timestamp. Pagination offset that substitutes for $end. */ + protected int|false $offset = false; + + /** @var int|null Number of results to return. */ + protected ?int $limit = 50; + + /** @var bool Is the current request a subrequest? */ + protected bool $isSubRequest; + + /** + * Stores user preferences such default project. + * This may get altered from the Request and updated in the Response. + * @var array + */ + protected array $cookies = [ + 'XtoolsProject' => null, + ]; + + /** OVERRIDABLE METHODS */ + + /** + * Require the tool's index route (initial form) be defined here. This should also + * be the name of the associated model, if present. + * @return string + */ + abstract protected function getIndexRoute(): string; + + /** + * Override this to activate the 'too high edit count' functionality. The return value + * should represent the route name that we should be redirected to if the requested user + * has too high of an edit count. + * @return string|null Name of route to redirect to. + */ + protected function tooHighEditCountRoute(): ?string { + return null; + } + + /** + * Override this to specify which actions + * @return string[] + */ + protected function tooHighEditCountActionAllowlist(): array { + return []; + } + + /** + * Override to restrict a tool's access to only the specified projects, instead of any valid project. + * @return string[] Domain or DB names. + */ + protected function supportedProjects(): array { + return []; + } + + /** + * Override this to set which API actions for the controller require the + * target user to opt in to the restricted statistics. + * @see https://www.mediawiki.org/wiki/XTools/Edit_Counter#restricted_stats + * @return array + */ + protected function restrictedApiActions(): array { + return []; + } + + /** + * Override to set the maximum number of days allowed for the given date range. + * This will be used as the default date span unless $this->defaultDays() is overridden. + * @see XtoolsController::getUnixFromDateParams() + * @return int|null + */ + public function maxDays(): ?int { + return null; + } + + /** + * Override to set default days from current day, to use as the start date if none was provided. + * If this is null and $this->maxDays() is non-null, the latter will be used as the default. + * @return int|null + */ + protected function defaultDays(): ?int { + return null; + } + + /** + * Override to set the maximum number of results to show per page, default 5000. + * @return int + */ + protected function maxLimit(): int { + return 5000; + } + + /** + * XtoolsController constructor. + * @param ContainerInterface $container + * @param RequestStack $requestStack + * @param ManagerRegistry $managerRegistry + * @param CacheItemPoolInterface $cache + * @param Client $guzzle + * @param I18nHelper $i18n + * @param ProjectRepository $projectRepo + * @param UserRepository $userRepo + * @param PageRepository $pageRepo + * @param Environment $twig + * @param bool $isWMF + * @param string $defaultProject + */ + public function __construct( + ContainerInterface $container, + RequestStack $requestStack, + protected ManagerRegistry $managerRegistry, + protected CacheItemPoolInterface $cache, + protected Client $guzzle, + protected I18nHelper $i18n, + protected ProjectRepository $projectRepo, + protected UserRepository $userRepo, + protected PageRepository $pageRepo, + protected Environment $twig, + /** @var bool Whether this is a WMF installation. */ + protected bool $isWMF, + /** @var string The configured default project. */ + protected string $defaultProject, + ) { + $this->container = $container; + $this->request = $requestStack->getCurrentRequest(); + $this->params = $this->parseQueryParams(); + + // Parse out the name of the controller and action. + $pattern = "#::([a-zA-Z]*)Action#"; + $matches = []; + // The blank string here only happens in the unit tests, where the request may not be made to an action. + preg_match( $pattern, $this->request->get( '_controller' ) ?? '', $matches ); + $this->controllerAction = $matches[1] ?? ''; + + // Whether the action is an API action. + $this->isApi = str_ends_with( $this->controllerAction, 'Api' ) || $this->controllerAction === 'recordUsage'; + + // Whether we're making a subrequest (the view makes a request to another action). + $this->isSubRequest = $this->request->get( 'htmlonly' ) + || $requestStack->getParentRequest() !== null; + + // Disallow AJAX (unless it's an API or subrequest). + $this->checkIfAjax(); + + // Load user options from cookies. + $this->loadCookies(); + + // Set the class-level properties based on params. + if ( str_contains( strtolower( $this->controllerAction ), 'index' ) ) { + // Index pages should only set the project, and no other class properties. + $this->setProject( $this->getProjectFromQuery() ); + + // ...except for transforming IP ranges. Because Symfony routes are separated by slashes, we need a way to + // indicate a CIDR range because otherwise i.e. the path /sc/enwiki/192.168.0.0/24 could be interpreted as + // the Simple Edit Counter for 192.168.0.0 in the namespace with ID 24. So we prefix ranges with 'ipr-'. + // Further IP range handling logic is in the User class, i.e. see User::__construct, User::isIpRange. + if ( isset( $this->params['username'] ) && IPUtils::isValidRange( $this->params['username'] ) ) { + $this->params['username'] = 'ipr-' . $this->params['username']; + } + } else { + // Includes the project. + $this->setProperties(); + } + + // Check if the request is to a restricted API endpoint, where the target user has to opt-in to statistics. + $this->checkRestrictedApiEndpoint(); + } + + /** + * Check if the request is AJAX, and disallow it unless they're using the API or if it's a subrequest. + */ + private function checkIfAjax(): void { + if ( $this->request->isXmlHttpRequest() && !$this->isApi && !$this->isSubRequest ) { + throw new HttpException( + Response::HTTP_FORBIDDEN, + $this->i18n->msg( 'error-automation', [ 'https://www.mediawiki.org/Special:MyLanguage/XTools/API' ] ) + ); + } + } + + /** + * Check if the request is to a restricted API endpoint, and throw an exception if the target user hasn't opted-in. + * @throws XtoolsHttpException + */ + private function checkRestrictedApiEndpoint(): void { + $restrictedAction = in_array( $this->controllerAction, $this->restrictedApiActions() ); + + if ( $this->isApi && $restrictedAction && !$this->project->userHasOptedIn( $this->user ) ) { + throw new XtoolsHttpException( + $this->i18n->msg( 'not-opted-in', [ + $this->getOptedInPage()->getTitle(), + $this->i18n->msg( 'not-opted-in-link' ) . + ' ', + $this->i18n->msg( 'not-opted-in-login' ), + ] ), + '', + $this->params, + true, + Response::HTTP_UNAUTHORIZED + ); + } + } + + /** + * Get the path to the opt-in page for restricted statistics. + * @return Page + */ + protected function getOptedInPage(): Page { + return new Page( $this->pageRepo, $this->project, $this->project->userOptInPage( $this->user ) ); + } + + /*********** + * COOKIES * + */ + + /** + * Load user preferences from the associated cookies. + */ + private function loadCookies(): void { + // Not done for subrequests. + if ( $this->isSubRequest ) { + return; + } + + foreach ( array_keys( $this->cookies ) as $name ) { + $this->cookies[$name] = $this->request->cookies->get( $name ); + } + } + + /** + * Set cookies on the given Response. + * @param Response $response + */ + private function setCookies( Response $response ): void { + // Not done for subrequests. + if ( $this->isSubRequest ) { + return; + } + + foreach ( $this->cookies as $name => $value ) { + $response->headers->setCookie( + Cookie::create( $name, $value ) + ); + } + } + + /** + * Sets the project, with the domain in $this->cookies['XtoolsProject'] that will + * later get set on the Response headers in self::getFormattedResponse(). + * @param Project $project + */ + private function setProject( Project $project ): void { + $this->project = $project; + $this->cookies['XtoolsProject'] = $project->getDomain(); + } + + /**************************** + * SETTING CLASS PROPERTIES * + */ + + /** + * Normalize all common parameters used by the controllers and set class properties. + */ + private function setProperties(): void { + $this->namespace = $this->params['namespace'] ?? null; + + // Offset is given as ISO timestamp and is stored as a UNIX timestamp (or false). + if ( isset( $this->params['offset'] ) ) { + $this->offset = strtotime( $this->params['offset'] ); + } + + // Limit needs to be an int. + if ( isset( $this->params['limit'] ) ) { + // Normalize. + $this->params['limit'] = min( max( 1, (int)$this->params['limit'] ), $this->maxLimit() ); + $this->limit = $this->params['limit']; + } + + if ( isset( $this->params['project'] ) ) { + $this->setProject( $this->validateProject( $this->params['project'] ) ); + } elseif ( $this->cookies['XtoolsProject'] !== null ) { + // Set from cookie. + $this->setProject( + $this->validateProject( $this->cookies['XtoolsProject'] ) + ); + } + + if ( isset( $this->params['username'] ) ) { + $this->user = $this->validateUser( $this->params['username'] ); + } + if ( isset( $this->params['page'] ) ) { + $this->page = $this->getPageFromNsAndTitle( $this->namespace, $this->params['page'] ); + } + + $this->setDates(); + } + + /** + * Set class properties for dates, if such params were passed in. + */ + private function setDates(): void { + $start = $this->params['start'] ?? false; + $end = $this->params['end'] ?? false; + if ( $start || $end || $this->maxDays() !== null ) { + [ $this->start, $this->end ] = $this->getUnixFromDateParams( $start, $end ); + + // Set $this->params accordingly too, so that for instance API responses will include it. + $this->params['start'] = is_int( $this->start ) ? date( 'Y-m-d', $this->start ) : false; + $this->params['end'] = is_int( $this->end ) ? date( 'Y-m-d', $this->end ) : false; + } + } + + /** + * Construct a fully qualified page title given the namespace and title. + * @param int|string $ns Namespace ID. + * @param string $title Page title. + * @param bool $rawTitle Return only the title (and not a Page). + * @return Page|string + */ + protected function getPageFromNsAndTitle( $ns, string $title, bool $rawTitle = false ) { + if ( (int)$ns === 0 ) { + return $rawTitle ? $title : $this->validatePage( $title ); + } + + // Prepend namespace and strip out duplicates. + $nsName = $this->project->getNamespaces()[$ns] ?? $this->i18n->msg( 'unknown' ); + $title = $nsName . ':' . preg_replace( '/^' . $nsName . ':/', '', $title ); + return $rawTitle ? $title : $this->validatePage( $title ); + } + + /** + * Get a Project instance from the project string, using defaults if the given project string is invalid. + * @return Project + */ + public function getProjectFromQuery(): Project { + // Set default project so we can populate the namespace selector on index pages. + // Defaults to project stored in cookie, otherwise project specified in parameters.yml. + if ( isset( $this->params['project'] ) ) { + $project = $this->params['project']; + } elseif ( $this->cookies['XtoolsProject'] !== null ) { + $project = $this->cookies['XtoolsProject']; + } else { + $project = $this->defaultProject; + } + + $projectData = $this->projectRepo->getProject( $project ); + + // Revert back to defaults if we've established the given project was invalid. + if ( !$projectData->exists() ) { + $projectData = $this->projectRepo->getProject( $this->defaultProject ); + } + + return $projectData; + } + + /************************* + * GETTERS / VALIDATIONS * + */ + + /** + * Validate the given project, returning a Project if it is valid or false otherwise. + * @param string $projectQuery Project domain or database name. + * @return Project + * @throws XtoolsHttpException + */ + public function validateProject( string $projectQuery ): Project { + $project = $this->projectRepo->getProject( $projectQuery ); + + // Check if it is an explicitly allowed project for the current tool. + if ( $this->supportedProjects() && !in_array( $project->getDomain(), $this->supportedProjects() ) ) { + $this->throwXtoolsException( + $this->getIndexRoute(), + 'error-authorship-unsupported-project', + [ $this->params['project'] ], + 'project' + ); + } + + if ( !$project->exists() ) { + $this->throwXtoolsException( + $this->getIndexRoute(), + 'invalid-project', + [ $this->params['project'] ], + 'project' + ); + } + + return $project; + } + + /** + * Validate the given user, returning a User or Redirect if they don't exist. + * @param string $username + * @return User + * @throws XtoolsHttpException + */ + public function validateUser( string $username ): User { + $user = new User( $this->userRepo, $username ); + + // Allow querying for any IP, currently with no edit count limitation... + // Once T188677 is resolved IPs will be affected by the EXPLAIN results. + if ( $user->isIP() ) { + // Validate CIDR limits. + if ( !$user->isQueryableRange() ) { + $limit = $user->isIPv6() ? User::MAX_IPV6_CIDR : User::MAX_IPV4_CIDR; + $this->throwXtoolsException( $this->getIndexRoute(), 'ip-range-too-wide', [ $limit ], 'username' ); + } + return $user; + } + + // Check against centralauth for global tools. + $isGlobalTool = str_contains( $this->request->get( '_controller', '' ), 'Global' ); + if ( $isGlobalTool && !$user->existsGlobally() ) { + $this->throwXtoolsException( $this->getIndexRoute(), 'user-not-found', [], 'username' ); + } elseif ( !$isGlobalTool && isset( $this->project ) && !$user->existsOnProject( $this->project ) ) { + // Don't continue if the user doesn't exist. + $this->throwXtoolsException( $this->getIndexRoute(), 'user-not-found', [], 'username' ); + } + + if ( isset( $this->project ) && $user->hasManyEdits( $this->project ) ) { + $this->handleHasManyEdits( $user ); + } + + return $user; + } + + private function handleHasManyEdits( User $user ): void { + $originalParams = $this->params; + $actionAllowlisted = in_array( $this->controllerAction, $this->tooHighEditCountActionAllowlist() ); + + // Reject users with a crazy high edit count. + if ( $this->tooHighEditCountRoute() && + !$actionAllowlisted && + $user->hasTooManyEdits( $this->project ) + ) { + /** TODO: Somehow get this to use self::throwXtoolsException */ + + // If redirecting to a different controller, show an informative message accordingly. + if ( $this->tooHighEditCountRoute() !== $this->getIndexRoute() ) { + // FIXME: This is currently only done for Edit Counter, redirecting to Simple Edit Counter, + // so this bit is hardcoded. We need to instead give the i18n key of the route. + $redirMsg = $this->i18n->msg( 'too-many-edits-redir', [ + $this->i18n->msg( 'tool-simpleeditcounter' ), + ] ); + $msg = $this->i18n->msg( 'too-many-edits', [ + $this->i18n->numberFormat( $user->maxEdits() ), + ] ) . '. ' . $redirMsg; + $this->addFlashMessage( 'danger', $msg ); + } else { + $this->addFlashMessage( 'danger', 'too-many-edits', [ + $this->i18n->numberFormat( $user->maxEdits() ), + ] ); + + // Redirecting back to index, so remove username (otherwise we'd get a redirect loop). + unset( $this->params['username'] ); + } + + // Clear flash bag for API responses, since they get intercepted in ExceptionListener + // and would otherwise be shown in subsequent requests. + if ( $this->isApi ) { + $this->getFlashBag()?->clear(); + } + + throw new XtoolsHttpException( + $this->i18n->msg( 'too-many-edits', [ $user->maxEdits() ] ), + $this->generateUrl( $this->tooHighEditCountRoute(), $this->params ), + $originalParams, + $this->isApi, + Response::HTTP_NOT_IMPLEMENTED + ); + } + + // Require login for users with a semi-crazy high edit count. + // For now, this only effects HTML requests and not the API. + if ( !$this->isApi && !$actionAllowlisted && !$this->request->getSession()->get( 'logged_in_user' ) ) { + throw new AccessDeniedHttpException( 'error-login-required' ); + } + } + + /** + * Get a Page instance from the given page title, and validate that it exists. + * @param string $pageTitle + * @return Page + * @throws XtoolsHttpException + */ + public function validatePage( string $pageTitle ): Page { + $page = new Page( $this->pageRepo, $this->project, $pageTitle ); + + if ( !$page->exists() ) { + $this->throwXtoolsException( + $this->getIndexRoute(), + 'no-result', + [ $this->params['page'] ?? null ], + 'page' + ); + } + + return $page; + } + + /** + * Throw an XtoolsHttpException, which the given error message and redirects to specified action. + * @param string $redirectAction Name of action to redirect to. + * @param string $message i18n key of error message. Shown in API responses. + * If no message with this key exists, $message is shown as-is. + * @param array $messageParams + * @param string|null $invalidParam This will be removed from $this->params. Omit if you don't want this to happen. + * @throws XtoolsHttpException + */ + public function throwXtoolsException( + string $redirectAction, + string $message, + array $messageParams = [], + ?string $invalidParam = null + ): void { + $this->addFlashMessage( 'danger', $message, $messageParams ); + $originalParams = $this->params; + + // Remove invalid parameter if it was given. + if ( is_string( $invalidParam ) ) { + unset( $this->params[$invalidParam] ); + } + + // We sometimes are redirecting to the index page, so also remove project (otherwise we'd get a redirect loop). + /** + * FIXME: Index pages should have a 'nosubmit' parameter to prevent submission. + * Then we don't even need to remove $invalidParam. + * Better, we should show the error on the results page, with no results. + */ + unset( $this->params['project'] ); + + // Throw exception which will redirect to $redirectAction. + throw new XtoolsHttpException( + $this->i18n->msgIfExists( $message, $messageParams ), + $this->generateUrl( $redirectAction, $this->params ), + $originalParams, + $this->isApi + ); + } + + /****************** + * PARSING PARAMS * + */ + + /** + * Get all standardized parameters from the Request, either via URL query string or routing. + * @return string[] + */ + public function getParams(): array { + $paramsToCheck = [ + 'project', + 'username', + 'namespace', + 'page', + 'categories', + 'group', + 'redirects', + 'deleted', + 'start', + 'end', + 'offset', + 'limit', + 'format', + 'tool', + 'tools', + 'q', + 'include_pattern', + 'exclude_pattern', + 'classonly', + + // Legacy parameters. + 'user', + 'name', + 'article', + 'wiki', + 'wikifam', + 'lang', + 'wikilang', + 'begin', + ]; + + /** @var string[] $params Each parameter that was detected along with its value. */ + $params = []; + + foreach ( $paramsToCheck as $param ) { + // Pull in either from URL query string or route. + $value = $this->request->query->get( $param ) ?: $this->request->get( $param ); + + // Only store if value is given ('namespace' or 'username' could be '0'). + if ( $value !== null && $value !== '' ) { + $params[$param] = rawurldecode( (string)$value ); + } + } + + return $params; + } + + /** + * Parse out common parameters from the request. These include the 'project', 'username', 'namespace' and 'page', + * along with their legacy counterparts (e.g. 'lang' and 'wiki'). + * @return string[] Normalized parameters (no legacy params). + */ + public function parseQueryParams(): array { + $params = $this->getParams(); + + // Covert any legacy parameters, if present. + $params = $this->convertLegacyParams( $params ); + + // Remove blank values. + return array_filter( $params, static function ( $param ) { + // 'namespace' or 'username' could be '0'. + return $param !== null && $param !== ''; + } ); + } + + /** + * Get Unix timestamps from given start and end string parameters. This also makes $start $maxDays() before + * $end if not present, and makes $end the current time if not present. + * The date range will not exceed $this->maxDays() days, if this public class property is set. + * @param int|string|false $start Unix timestamp or string accepted by strtotime. + * @param int|string|false $end Unix timestamp or string accepted by strtotime. + * @return int[] Start and end date as UTC timestamps. + */ + public function getUnixFromDateParams( $start, $end ): array { + $today = strtotime( 'today midnight' ); + + // start time should not be in the future. + $startTime = min( + is_int( $start ) ? $start : strtotime( (string)$start ), + $today + ); + + // end time defaults to now, and will not be in the future. + $endTime = min( + ( is_int( $end ) ? $end : strtotime( (string)$end ) ) ?: $today, + $today + ); + + // Default to $this->defaultDays() or $this->maxDays() before end time if start is not present. + $daysOffset = $this->defaultDays() ?? $this->maxDays(); + if ( $startTime === false && $daysOffset ) { + $startTime = strtotime( "-$daysOffset days", $endTime ); + } + + // Default to $this->defaultDays() or $this->maxDays() after start time if end is not present. + if ( $end === false && $daysOffset ) { + $endTime = min( + strtotime( "+$daysOffset days", $startTime ), + $today + ); + } + + // Reverse if start date is after end date. + if ( $startTime > $endTime && $startTime !== false && $end !== false ) { + $newEndTime = $startTime; + $startTime = $endTime; + $endTime = $newEndTime; + } + + // Finally, don't let the date range exceed $this->maxDays(). + $startObj = DateTime::createFromFormat( 'U', (string)$startTime ); + $endObj = DateTime::createFromFormat( 'U', (string)$endTime ); + if ( $this->maxDays() && $startObj->diff( $endObj )->days > $this->maxDays() ) { + // Show warnings that the date range was truncated. + $this->addFlashMessage( 'warning', 'date-range-too-wide', [ $this->maxDays() ] ); + + $startTime = strtotime( '-' . $this->maxDays() . ' days', $endTime ); + } + + return [ $startTime, $endTime ]; + } + + /** + * Given the params hash, normalize any legacy parameters to their modern equivalent. + * @param string[] $params + * @return string[] + */ + private function convertLegacyParams( array $params ): array { + $paramMap = [ + 'user' => 'username', + 'name' => 'username', + 'article' => 'page', + 'begin' => 'start', + + // Copy super legacy project params to legacy so we can concatenate below. + 'wikifam' => 'wiki', + 'wikilang' => 'lang', + ]; + + // Copy legacy parameters to modern equivalent. + foreach ( $paramMap as $legacy => $modern ) { + if ( isset( $params[$legacy] ) ) { + $params[$modern] = $params[$legacy]; + unset( $params[$legacy] ); + } + } + + // Separate parameters for language and wiki. + if ( isset( $params['wiki'] ) && isset( $params['lang'] ) ) { + // 'wikifam' may be like '.wikipedia.org', vs just 'wikipedia', + // so we must remove leading periods and trailing .org's. + $params['project'] = $params['lang'] . '.' . rtrim( ltrim( $params['wiki'], '.' ), '.org' ) . '.org'; + unset( $params['wiki'] ); + unset( $params['lang'] ); + } + + return $params; + } + + /************************ + * FORMATTING RESPONSES * + */ + + /** + * Get the rendered template for the requested format. This method also updates the cookies. + * @param string $templatePath Path to template without format, + * such as '/editCounter/latest_global'. + * @param array $ret Data that should be passed to the view. + * @return Response + * @codeCoverageIgnore + */ + public function getFormattedResponse( string $templatePath, array $ret ): Response { + $format = $this->request->query->get( 'format', 'html' ); + if ( $format == '' ) { + // The default above doesn't work when the 'format' parameter is blank. + $format = 'html'; + } + + // Merge in common default parameters, giving $ret (from the caller) the priority. + $ret = array_merge( [ + 'project' => $this->project, + 'user' => $this->user, + 'page' => $this->page ?? null, + 'namespace' => $this->namespace, + 'start' => $this->start, + 'end' => $this->end, + ], $ret ); + + $formatMap = [ + 'wikitext' => 'text/plain', + 'csv' => 'text/csv', + 'tsv' => 'text/tab-separated-values', + 'json' => 'application/json', + ]; + + $response = new Response(); + + // Set cookies. Note this must be done before rendering the view, as the view may invoke subrequests. + $this->setCookies( $response ); + + // If requested format does not exist, assume HTML. + if ( $this->twig->getLoader()->exists( "$templatePath.$format.twig" ) === false ) { + $format = 'html'; + } + + $response = $this->render( "$templatePath.$format.twig", $ret, $response ); + + $contentType = $formatMap[$format] ?? 'text/html'; + $response->headers->set( 'Content-Type', $contentType ); + + if ( in_array( $format, [ 'csv', 'tsv' ] ) ) { + $filename = $this->getFilenameForRequest(); + $response->headers->set( + 'Content-Disposition', + "attachment; filename=\"{$filename}.$format\"" + ); + } + + return $response; + } + + /** + * Returns given filename from the current Request, with problematic characters filtered out. + * @return string + */ + private function getFilenameForRequest(): string { + $filename = trim( $this->request->getPathInfo(), '/' ); + return trim( preg_replace( '/[-\/:;*?|<>%#"]+/', '-', $filename ) ); + } + + /** + * Return a JsonResponse object pre-supplied with the requested params. + * @param array $data + * @param int $responseCode + * @return JsonResponse + */ + public function getFormattedApiResponse( array $data, int $responseCode = Response::HTTP_OK ): JsonResponse { + $response = new JsonResponse(); + $response->setEncodingOptions( JSON_NUMERIC_CHECK ); + $response->setStatusCode( $responseCode ); + + // Normalize display of IP ranges (they are prefixed with 'ipr-' in the params). + if ( $this->user && $this->user->isIpRange() ) { + $this->params['username'] = $this->user->getUsername(); + } + + $ret = array_merge( $this->params, [ + // In some controllers, $this->params['project'] may be overridden with a Project object. + 'project' => $this->project->getDomain(), + ], $data ); + + // Merge in flash messages, putting them at the top. + $flashes = $this->getFlashBag()?->peekAll() ?? []; + $ret = array_merge( $flashes, $ret ); + + // Flashes now can be cleared after merging into the response. + $this->getFlashBag()?->clear(); + + // Normalize path param values. + $ret = self::normalizeApiProperties( $ret ); + + $response->setData( $ret ); + + return $response; + } + + /** + * Normalize the response data, adding in the elapsed_time. + * @param array $params + * @return array + */ + public static function normalizeApiProperties( array $params ): array { + foreach ( $params as $param => $value ) { + if ( $value === false ) { + // False values must be empty params. + unset( $params[$param] ); + } elseif ( is_string( $value ) && str_contains( $value, '|' ) ) { + // Any pipe-separated values should be returned as an array. + $params[$param] = explode( '|', $value ); + } elseif ( $value instanceof DateTime ) { + // Convert DateTime objects to ISO 8601 strings. + $params[$param] = $value->format( 'Y-m-d\TH:i:s\Z' ); + } + } + + $elapsedTime = round( + microtime( true ) - $_SERVER['REQUEST_TIME_FLOAT'], + 3 + ); + return array_merge( $params, [ 'elapsed_time' => $elapsedTime ] ); + } + + /** + * Parse a boolean value from the query string, treating 'false' and '0' as false. + * @param string $param + * @return bool + */ + public function getBoolVal( string $param ): bool { + return isset( $this->params[$param] ) && + !in_array( $this->params[$param], [ 'false', '0' ] ); + } + + /** + * Used to standardized the format of API responses that contain revisions. + * Adds a 'full_page_title' key and value to each entry in $data. + * If there are as many entries in $data as there are $this->limit, pagination is assumed + * and a 'continue' key is added to the end of the response body. + * @param string $key Key accessing the list of revisions in $data. + * @param array $out Whatever data needs to appear above the $data in the response body. + * @param array $data The data set itself. + * @return array + */ + public function addFullPageTitlesAndContinue( string $key, array $out, array $data ): array { + // Add full_page_title (in addition to the existing page_title and namespace keys). + $out[$key] = array_map( function ( $rev ) { + return array_merge( [ + 'full_page_title' => $this->getPageFromNsAndTitle( + (int)$rev['namespace'], + $rev['page_title'], + true + ), + ], $rev ); + }, $data ); + + // Check if pagination is needed. + if ( count( $out[$key] ) === $this->limit && count( $out[$key] ) > 0 ) { + // Use the timestamp of the last Edit as the value for the 'continue' return key, + // which can be used as a value for 'offset' in order to paginate results. + $timestamp = array_slice( $out[$key], -1, 1 )[0]['timestamp']; + $out['continue'] = ( new DateTime( $timestamp ) )->format( 'Y-m-d\TH:i:s\Z' ); + } + + return $out; + } + + /********* + * OTHER * + */ + + /** + * Record usage of an API endpoint. + * @param string $endpoint + * @codeCoverageIgnore + */ + public function recordApiUsage( string $endpoint ): void { + /** @var Connection $conn */ + $conn = $this->managerRegistry->getConnection( 'default' ); + $date = date( 'Y-m-d' ); + + // Increment count in timeline + try { + $sql = "INSERT INTO usage_api_timeline VALUES(NULL, :date, :endpoint, 1) ON DUPLICATE KEY UPDATE `count` = `count` + 1"; - $conn->executeStatement($sql, [ - 'date' => $date, - 'endpoint' => $endpoint, - ]); - } catch (Exception $e) { - // Do nothing. API response should still be returned rather than erroring out. - } - } - - /** - * Get the FlashBag instance from the current session, if available. - * @return ?FlashBagInterface - */ - public function getFlashBag(): ?FlashBagInterface - { - if ($this->request->getSession() instanceof FlashBagAwareSessionInterface) { - return $this->request->getSession()->getFlashBag(); - } - return null; - } - - /** - * Add a flash message. - * @param string $type - * @param string|Markup $key i18n key or raw message. - * @param array $vars - */ - public function addFlashMessage(string $type, $key, array $vars = []): void - { - if ($key instanceof Markup || !$this->i18n->msgExists($key, $vars)) { - $msg = $key; - } else { - $msg = $this->i18n->msg($key, $vars); - } - $this->addFlash($type, $msg); - } + $conn->executeStatement( $sql, [ + 'date' => $date, + 'endpoint' => $endpoint, + ] ); + } catch ( Exception $e ) { + // Do nothing. API response should still be returned rather than erroring out. + } + } + + /** + * Get the FlashBag instance from the current session, if available. + * @return ?FlashBagInterface + */ + public function getFlashBag(): ?FlashBagInterface { + if ( $this->request->getSession() instanceof FlashBagAwareSessionInterface ) { + return $this->request->getSession()->getFlashBag(); + } + return null; + } + + /** + * Add a flash message. + * @param string $type + * @param string|Markup $key i18n key or raw message. + * @param array $vars + */ + public function addFlashMessage( string $type, string|Markup $key, array $vars = [] ): void { + if ( $key instanceof Markup || !$this->i18n->msgExists( $key, $vars ) ) { + $msg = $key; + } else { + $msg = $this->i18n->msg( $key, $vars ); + } + $this->addFlash( $type, $msg ); + } } diff --git a/src/EventSubscriber/DisabledToolSubscriber.php b/src/EventSubscriber/DisabledToolSubscriber.php index f4deb37d5..34f8fd27e 100644 --- a/src/EventSubscriber/DisabledToolSubscriber.php +++ b/src/EventSubscriber/DisabledToolSubscriber.php @@ -1,6 +1,6 @@ 'onKernelController', - ]; - } + /** + * Register our interest in the kernel.controller event. + * @return string[] + */ + public static function getSubscribedEvents(): array { + return [ + KernelEvents::CONTROLLER => 'onKernelController', + ]; + } - /** - * Check to see if the current tool is enabled. - * @param ControllerEvent $event The event. - * @throws NotFoundHttpException If the tool is not enabled. - */ - public function onKernelController(ControllerEvent $event): void - { - $controller = $event->getController(); + /** + * Check to see if the current tool is enabled. + * @param ControllerEvent $event The event. + * @throws NotFoundHttpException If the tool is not enabled. + */ + public function onKernelController( ControllerEvent $event ): void { + $controller = $event->getController(); - if ($controller instanceof XtoolsController && method_exists($controller, 'getIndexRoute')) { - $tool = $controller[0]->getIndexRoute(); - if (!in_array($tool, ['homepage', 'meta', 'Quote']) && !$this->parameterBag->get("enable.$tool")) { - throw new NotFoundHttpException('This tool is disabled'); - } - } - } + if ( $controller instanceof XtoolsController && method_exists( $controller, 'getIndexRoute' ) ) { + $tool = $controller[0]->getIndexRoute(); + if ( !in_array( $tool, [ 'homepage', 'meta', 'Quote' ] ) && !$this->parameterBag->get( "enable.$tool" ) ) { + throw new NotFoundHttpException( 'This tool is disabled' ); + } + } + } } diff --git a/src/EventSubscriber/ExceptionListener.php b/src/EventSubscriber/ExceptionListener.php index 409029c0e..217a9f662 100644 --- a/src/EventSubscriber/ExceptionListener.php +++ b/src/EventSubscriber/ExceptionListener.php @@ -1,6 +1,6 @@ flashBag = $requestStack->getSession()?->getFlashBag(); - $this->environment = $environment; - } - - /** - * Capture the exception, check if it's a Twig error and if so - * throw the previous exception, which should be more meaningful. - * @param ExceptionEvent $event - */ - public function onKernelException(ExceptionEvent $event): void - { - $exception = $event->getThrowable(); - - // We only care about the previous (original) exception, not the one Twig put on top of it. - $prevException = $exception->getPrevious(); - - $isApi = str_starts_with($event->getRequest()->getRequestUri(), '/api/'); - - if ($exception instanceof XtoolsHttpException && !$isApi) { - $response = $this->getXtoolsHttpResponse($exception); - } elseif ($exception instanceof RuntimeError && null !== $prevException) { - $response = $this->getTwigErrorResponse($prevException); - } elseif ($exception instanceof AccessDeniedHttpException) { - // FIXME: For some reason the automatic error page rendering doesn't work for 403 responses... - $response = new Response( - $this->templateEngine->render('bundles/TwigBundle/Exception/error.html.twig', [ - 'status_code' => $exception->getStatusCode(), - 'status_text' => 'Forbidden', - 'exception' => $exception, - ]) - ); - } elseif ($isApi && 'json' === $event->getRequest()->get('format', 'json')) { - $normalizer = new ProblemNormalizer('prod' !== $this->environment); - $params = array_merge( - $normalizer->normalize(FlattenException::createFromThrowable($exception)), - $event->getRequest()->attributes->get('_route_params') ?? [], - ); - $params['title'] = $params['detail']; - $params['detail'] = $this->i18n->msgIfExists($exception->getMessage(), [$exception->getCode()]); - $response = new JsonResponse( - XtoolsController::normalizeApiProperties($params) - ); - } else { - return; - } - - // sends the modified response object to the event - $event->setResponse($response); - } - - /** - * Handle an XtoolsHttpException, either redirecting back to the configured URL, - * or in the case of API requests, return the error in a JsonResponse. - * @param XtoolsHttpException $exception - * @return JsonResponse|RedirectResponse - */ - private function getXtoolsHttpResponse(XtoolsHttpException $exception) - { - if ($exception->isApi()) { - $this->flashBag?->add('error', $exception->getMessage()); - $flashes = $this->flashBag?->peekAll() ?? []; - $this->flashBag?->clear(); - return new JsonResponse(array_merge( - array_merge($flashes, FlattenException::createFromThrowable($exception)->toArray()), - $exception->getParams() - ), $exception->getStatusCode()); - } - - return new RedirectResponse($exception->getRedirectUrl()); - } - - /** - * Handle a Twig runtime exception. - * @param Throwable $exception - * @return Response - * @throws Throwable - */ - private function getTwigErrorResponse(Throwable $exception): Response - { - if ('prod' !== $this->environment) { - throw $exception; - } - - // Log the exception, since we're handling it and it won't automatically be logged. - $file = explode('/', $exception->getFile()); - $this->logger->error( - '>>> CRITICAL (\''.$exception->getMessage().'\' - '. - end($file).' - line '.$exception->getLine().')' - ); - - return new Response( - $this->templateEngine->render('bundles/TwigBundle/Exception/error.html.twig', [ - 'status_code' => $exception->getCode(), - 'status_text' => 'Internal Server Error', - 'exception' => $exception, - ]), - Response::HTTP_INTERNAL_SERVER_ERROR - ); - } +class ExceptionListener { + protected ?FlashBagInterface $flashBag; + + public function __construct( + protected Environment $templateEngine, + RequestStack $requestStack, + protected LoggerInterface $logger, + protected I18nHelper $i18n, + protected string $environment = 'prod' + ) { + $this->flashBag = $requestStack->getSession()?->getFlashBag(); + } + + /** + * Capture the exception, check if it's a Twig error and if so + * throw the previous exception, which should be more meaningful. + * @param ExceptionEvent $event + */ + public function onKernelException( ExceptionEvent $event ): void { + $exception = $event->getThrowable(); + + // We only care about the previous (original) exception, not the one Twig put on top of it. + $prevException = $exception->getPrevious(); + + $isApi = str_starts_with( $event->getRequest()->getRequestUri(), '/api/' ); + + if ( $exception instanceof XtoolsHttpException && !$isApi ) { + $response = $this->getXtoolsHttpResponse( $exception ); + } elseif ( $exception instanceof RuntimeError && $prevException !== null ) { + $response = $this->getTwigErrorResponse( $prevException ); + } elseif ( $exception instanceof AccessDeniedHttpException ) { + // FIXME: For some reason the automatic error page rendering doesn't work for 403 responses... + $response = new Response( + $this->templateEngine->render( 'bundles/TwigBundle/Exception/error.html.twig', [ + 'status_code' => $exception->getStatusCode(), + 'status_text' => 'Forbidden', + 'exception' => $exception, + ] ) + ); + } elseif ( $isApi && $event->getRequest()->get( 'format', 'json' ) === 'json' ) { + $normalizer = new ProblemNormalizer( $this->environment !== 'prod' ); + $params = array_merge( + $normalizer->normalize( FlattenException::createFromThrowable( $exception ) ), + $event->getRequest()->attributes->get( '_route_params' ) ?? [], + ); + $params['title'] = $params['detail']; + $params['detail'] = $this->i18n->msgIfExists( $exception->getMessage(), [ $exception->getCode() ] ); + $response = new JsonResponse( + XtoolsController::normalizeApiProperties( $params ) + ); + } else { + return; + } + + // sends the modified response object to the event + $event->setResponse( $response ); + } + + /** + * Handle an XtoolsHttpException, either redirecting back to the configured URL, + * or in the case of API requests, return the error in a JsonResponse. + * @param XtoolsHttpException $exception + * @return JsonResponse|RedirectResponse + */ + private function getXtoolsHttpResponse( XtoolsHttpException $exception ) { + if ( $exception->isApi() ) { + $this->flashBag?->add( 'error', $exception->getMessage() ); + $flashes = $this->flashBag?->peekAll() ?? []; + $this->flashBag?->clear(); + return new JsonResponse( array_merge( + array_merge( $flashes, FlattenException::createFromThrowable( $exception )->toArray() ), + $exception->getParams() + ), $exception->getStatusCode() ); + } + + return new RedirectResponse( $exception->getRedirectUrl() ); + } + + /** + * Handle a Twig runtime exception. + * @param Throwable $exception + * @return Response + * @throws Throwable + */ + private function getTwigErrorResponse( Throwable $exception ): Response { + if ( $this->environment !== 'prod' ) { + throw $exception; + } + + // Log the exception, since we're handling it and it won't automatically be logged. + $file = explode( '/', $exception->getFile() ); + $this->logger->error( + '>>> CRITICAL (\'' . $exception->getMessage() . '\' - ' . + end( $file ) . ' - line ' . $exception->getLine() . ')' + ); + + return new Response( + $this->templateEngine->render( 'bundles/TwigBundle/Exception/error.html.twig', [ + 'status_code' => $exception->getCode(), + 'status_text' => 'Internal Server Error', + 'exception' => $exception, + ] ), + Response::HTTP_INTERNAL_SERVER_ERROR + ); + } } diff --git a/src/EventSubscriber/RateLimitSubscriber.php b/src/EventSubscriber/RateLimitSubscriber.php index f31e28a8f..64b6bd289 100644 --- a/src/EventSubscriber/RateLimitSubscriber.php +++ b/src/EventSubscriber/RateLimitSubscriber.php @@ -1,6 +1,6 @@ 'onKernelController', - ]; - } - - /** - * Check if the current user has exceeded the configured usage limitations. - * @param ControllerEvent $event The event. - */ - public function onKernelController(ControllerEvent $event): void - { - $controller = $event->getController(); - $action = null; - - // when a controller class defines multiple action methods, the controller - // is returned as [$controllerInstance, 'methodName'] - if (is_array($controller)) { - [$controller, $action] = $controller; - } - - if (!$controller instanceof XtoolsController) { - return; - } - - $this->request = $event->getRequest(); - $this->userAgent = (string)$this->request->headers->get('User-Agent'); - $this->referer = (string)$this->request->headers->get('referer'); - $this->uri = $this->request->getRequestUri(); - - $this->checkDenylist(); - - // Zero values indicate the rate limiting feature should be disabled. - if (0 === $this->rateLimit || 0 === $this->rateDuration) { - return; - } - - $loggedIn = (bool)$this->request->getSession()->get('logged_in_user'); - $isApi = 'ApiAction' === substr($action, -9); - - // No rate limits on lightweight pages, logged in users, subrequests or API requests. - if (in_array($action, self::ACTION_ALLOWLIST) || $loggedIn || false === $event->isMainRequest() || $isApi) { - return; - } - - $this->logCrawlers(); - $this->xffRateLimit(); - } - - /** - * Don't let individual users hog up all the resources. - */ - private function xffRateLimit(): void - { - $xff = $this->request->headers->get('x-forwarded-for', ''); - - if ('' === $xff) { - // Happens in local environments, or outside of Cloud Services. - return; - } - - $cacheKey = "ratelimit.session.".sha1($xff); - $cacheItem = $this->cache->getItem($cacheKey); - - // If increment value already in cache, or start with 1. - $count = $cacheItem->isHit() ? (int) $cacheItem->get() + 1 : 1; - - // Check if limit has been exceeded, and if so, throw an error. - if ($count > $this->rateLimit) { - $this->denyAccess('Exceeded rate limitation'); - } - - // Reset the clock on every request. - $cacheItem->set($count) - ->expiresAfter(new DateInterval('PT'.$this->rateDuration.'M')); - $this->cache->save($cacheItem); - } - - /** - * Detect possible web crawlers and log the requests, and log them to /var/logs/crawlers.log. - * Crawlers typically click on every visible link on the page, so we check for rapid requests to the same URI - * but with a different interface language, as happens when it is crawling the language dropdown in the UI. - */ - private function logCrawlers(): void - { - $useLangMatches = []; - $hasMatch = preg_match('/\?uselang=(.*)/', $this->uri, $useLangMatches); - - if (1 !== $hasMatch) { - return; - } - - $useLang = $useLangMatches[1]; - - // If requesting the same language as the target project, ignore. - // FIXME: This has side-effects (T384711#10759078) - if (1 === preg_match("/[=\/]$useLang.?wik/", $this->uri)) { - return; - } - - // Require login. - throw new AccessDeniedHttpException('error-login-required'); - } - - /** - * Check the request against denylisted URIs and user agents - */ - private function checkDenylist(): void - { - // First check user agent and URI denylists. - if (!$this->parameterBag->has('request_denylist')) { - return; - } - - $denylist = (array)$this->parameterBag->get('request_denylist'); - - foreach ($denylist as $name => $item) { - $matches = []; - - if (isset($item['user_agent'])) { - $matches[] = $item['user_agent'] === $this->userAgent; - } - if (isset($item['user_agent_pattern'])) { - $matches[] = 1 === preg_match('/'.$item['user_agent_pattern'].'/', $this->userAgent); - } - if (isset($item['referer'])) { - $matches[] = $item['referer'] === $this->referer; - } - if (isset($item['referer_pattern'])) { - $matches[] = 1 === preg_match('/'.$item['referer_pattern'].'/', $this->referer); - } - if (isset($item['uri'])) { - $matches[] = $item['uri'] === $this->uri; - } - if (isset($item['uri_pattern'])) { - $matches[] = 1 === preg_match('/'.$item['uri_pattern'].'/', $this->uri); - } - - if (count($matches) > 0 && count($matches) === count(array_filter($matches))) { - $this->denyAccess("Matched denylist entry `$name`", true); - } - } - } - - /** - * Throw exception for denied access due to spider crawl or hitting usage limits. - * @param string $logComment Comment to include with the log entry. - * @param bool $denylist Changes the messaging to say access was denied due to abuse, rather than rate limiting. - * @throws TooManyRequestsHttpException - * @throws AccessDeniedHttpException - */ - private function denyAccess(string $logComment, bool $denylist = false): void - { - // Log the denied request - $logger = $denylist ? $this->denylistLogger : $this->rateLimitLogger; - $logger->info($logComment); - - if ($denylist) { - $message = $this->i18n->msg('error-denied', ['tools.xtools@toolforge.org']); - throw new AccessDeniedHttpException($message, null, 999); - } - - $message = $this->i18n->msg('error-rate-limit', [ - $this->rateDuration, - "".$this->i18n->msg('error-rate-limit-login')."", - "" . - $this->i18n->msg('api') . - "", - ]); - - /** - * TODO: Find a better way to do this. - * 999 is a random, complete hack to tell error.html.twig file to treat these exceptions as having - * fully safe messages that can be display with |raw. (In this case we authored the message). - */ - throw new TooManyRequestsHttpException(600, $message, null, 999); - } +class RateLimitSubscriber implements EventSubscriberInterface { + /** + * Rate limiting will not apply to these actions. + */ + public const ACTION_ALLOWLIST = [ + 'aboutAction', + 'indexAction', + 'loginAction', + 'oauthCallbackAction', + 'recordUsageAction', + 'showAction', + ]; + + protected Request $request; + + /** @var string User agent string. */ + protected string $userAgent; + + /** @var string The referer string. */ + protected string $referer; + + /** @var string The URI. */ + protected string $uri; + + /** + * @param I18nHelper $i18n + * @param CacheItemPoolInterface $cache + * @param ParameterBagInterface $parameterBag + * @param RequestStack $requestStack + * @param LoggerInterface $crawlerLogger + * @param LoggerInterface $denylistLogger + * @param LoggerInterface $rateLimitLogger + * @param int $rateLimit + * @param int $rateDuration + */ + public function __construct( + protected I18nHelper $i18n, + protected CacheItemPoolInterface $cache, + protected ParameterBagInterface $parameterBag, + protected RequestStack $requestStack, + protected LoggerInterface $crawlerLogger, + protected LoggerInterface $denylistLogger, + protected LoggerInterface $rateLimitLogger, + /** @var int Number of requests allowed in time period */ + protected int $rateLimit, + /** @var int Number of minutes during which $rateLimit requests are permitted. */ + protected int $rateDuration + ) { + } + + /** + * Register our interest in the kernel.controller event. + * @return string[] + */ + public static function getSubscribedEvents(): array { + return [ + KernelEvents::CONTROLLER => 'onKernelController', + ]; + } + + /** + * Check if the current user has exceeded the configured usage limitations. + * @param ControllerEvent $event The event. + */ + public function onKernelController( ControllerEvent $event ): void { + $controller = $event->getController(); + $action = null; + + // when a controller class defines multiple action methods, the controller + // is returned as [$controllerInstance, 'methodName'] + if ( is_array( $controller ) ) { + [ $controller, $action ] = $controller; + } + + if ( !$controller instanceof XtoolsController ) { + return; + } + + $this->request = $event->getRequest(); + $this->userAgent = (string)$this->request->headers->get( 'User-Agent' ); + $this->referer = (string)$this->request->headers->get( 'referer' ); + $this->uri = $this->request->getRequestUri(); + + $this->checkDenylist(); + + // Zero values indicate the rate limiting feature should be disabled. + if ( $this->rateLimit === 0 || $this->rateDuration === 0 ) { + return; + } + + $loggedIn = (bool)$this->request->getSession()->get( 'logged_in_user' ); + $isApi = str_ends_with( $action, 'ApiAction' ); + + // No rate limits on lightweight pages, logged in users, subrequests or API requests. + if ( in_array( $action, self::ACTION_ALLOWLIST ) || + $loggedIn || + !$event->isMainRequest() || + $isApi + ) { + return; + } + + $this->logCrawlers(); + $this->xffRateLimit(); + } + + /** + * Don't let individual users hog up all the resources. + */ + private function xffRateLimit(): void { + $xff = $this->request->headers->get( 'x-forwarded-for', '' ); + + if ( $xff === '' ) { + // Happens in local environments, or outside of Cloud Services. + return; + } + + $cacheKey = "ratelimit.session." . sha1( $xff ); + $cacheItem = $this->cache->getItem( $cacheKey ); + + // If increment value already in cache, or start with 1. + $count = $cacheItem->isHit() ? (int)$cacheItem->get() + 1 : 1; + + // Check if limit has been exceeded, and if so, throw an error. + if ( $count > $this->rateLimit ) { + $this->denyAccess( 'Exceeded rate limitation' ); + } + + // Reset the clock on every request. + $cacheItem->set( $count ) + ->expiresAfter( new DateInterval( 'PT' . $this->rateDuration . 'M' ) ); + $this->cache->save( $cacheItem ); + } + + /** + * Detect possible web crawlers and log the requests, and log them to /var/logs/crawlers.log. + * Crawlers typically click on every visible link on the page, so we check for rapid requests to the same URI + * but with a different interface language, as happens when it is crawling the language dropdown in the UI. + */ + private function logCrawlers(): void { + $useLangMatches = []; + $hasMatch = preg_match( '/\?uselang=(.*)/', $this->uri, $useLangMatches ); + + if ( $hasMatch !== 1 ) { + return; + } + + $useLang = $useLangMatches[1]; + + // If requesting the same language as the target project, ignore. + // FIXME: This has side-effects (T384711#10759078) + if ( preg_match( "/[=\/]$useLang.?wik/", $this->uri ) === 1 ) { + return; + } + + // Require login. + throw new AccessDeniedHttpException( 'error-login-required' ); + } + + /** + * Check the request against denylisted URIs and user agents + */ + private function checkDenylist(): void { + // First check user agent and URI denylists. + if ( !$this->parameterBag->has( 'request_denylist' ) ) { + return; + } + + $denylist = (array)$this->parameterBag->get( 'request_denylist' ); + + foreach ( $denylist as $name => $item ) { + $matches = []; + + if ( isset( $item['user_agent'] ) ) { + $matches[] = $item['user_agent'] === $this->userAgent; + } + if ( isset( $item['user_agent_pattern'] ) ) { + $matches[] = preg_match( '/' . $item['user_agent_pattern'] . '/', $this->userAgent ) === 1; + } + if ( isset( $item['referer'] ) ) { + $matches[] = $item['referer'] === $this->referer; + } + if ( isset( $item['referer_pattern'] ) ) { + $matches[] = preg_match( '/' . $item['referer_pattern'] . '/', $this->referer ) === 1; + } + if ( isset( $item['uri'] ) ) { + $matches[] = $item['uri'] === $this->uri; + } + if ( isset( $item['uri_pattern'] ) ) { + $matches[] = preg_match( '/' . $item['uri_pattern'] . '/', $this->uri ) === 1; + } + + if ( count( $matches ) > 0 && count( $matches ) === count( array_filter( $matches ) ) ) { + $this->denyAccess( "Matched denylist entry `$name`", true ); + } + } + } + + /** + * Throw exception for denied access due to spider crawl or hitting usage limits. + * @param string $logComment Comment to include with the log entry. + * @param bool $denylist Changes the messaging to say access was denied due to abuse, rather than rate limiting. + * @throws TooManyRequestsHttpException + * @throws AccessDeniedHttpException + */ + private function denyAccess( string $logComment, bool $denylist = false ): void { + // Log the denied request + $logger = $denylist ? $this->denylistLogger : $this->rateLimitLogger; + $logger->info( $logComment ); + + if ( $denylist ) { + $message = $this->i18n->msg( 'error-denied', [ 'tools.xtools@toolforge.org' ] ); + throw new AccessDeniedHttpException( $message, null, 999 ); + } + + $message = $this->i18n->msg( 'error-rate-limit', [ + $this->rateDuration, + "" . $this->i18n->msg( 'error-rate-limit-login' ) . "", + "" . + $this->i18n->msg( 'api' ) . + "", + ] ); + + /** + * TODO: Find a better way to do this. + * 999 is a random, complete hack to tell error.html.twig file to treat these exceptions as having + * fully safe messages that can be display with |raw. (In this case we authored the message). + */ + throw new TooManyRequestsHttpException( 600, $message, null, 999 ); + } } diff --git a/src/Exception/BadGatewayException.php b/src/Exception/BadGatewayException.php index fa9431be6..bc6e1165e 100644 --- a/src/Exception/BadGatewayException.php +++ b/src/Exception/BadGatewayException.php @@ -1,6 +1,6 @@ msgParams; - } + /** + * @return array + */ + public function getMsgParams(): array { + return $this->msgParams; + } } diff --git a/src/Exception/XtoolsHttpException.php b/src/Exception/XtoolsHttpException.php index 0ef9a555c..299fae5b6 100644 --- a/src/Exception/XtoolsHttpException.php +++ b/src/Exception/XtoolsHttpException.php @@ -1,6 +1,6 @@ redirectUrl; - } + /** + * The URL that should be redirected to. + * @return string + */ + public function getRedirectUrl(): string { + return $this->redirectUrl; + } - /** - * Get the configured parameters, which should be the same parameters parsed from the Request, - * and passed to the $redirectUrl when handled in the ExceptionListener. - * @return array - */ - public function getParams(): array - { - return $this->params; - } + /** + * Get the configured parameters, which should be the same parameters parsed from the Request, + * and passed to the $redirectUrl when handled in the ExceptionListener. + * @return array + */ + public function getParams(): array { + return $this->params; + } - /** - * Whether this exception was thrown as part of a request to the API. - * @return bool - */ - public function isApi(): bool - { - return $this->api; - } + /** + * Whether this exception was thrown as part of a request to the API. + * @return bool + */ + public function isApi(): bool { + return $this->api; + } } diff --git a/src/Helper/AutomatedEditsHelper.php b/src/Helper/AutomatedEditsHelper.php index fd54d3093..25e3a749c 100644 --- a/src/Helper/AutomatedEditsHelper.php +++ b/src/Helper/AutomatedEditsHelper.php @@ -1,6 +1,6 @@ getTools($project) as $tool => $values) { - if ((isset($values['regex']) && preg_match('/'.$values['regex'].'/', $summary)) || - (isset($values['tags']) && count(array_intersect($values['tags'], $tags)) > 0) - ) { - return array_merge([ - 'name' => $tool, - ], $values); - } - } - - return null; - } - - /** - * Was the edit (semi-)automated, based on the edit summary? - * @param string $summary Edit summary - * @param Project $project - * @return bool - */ - public function isAutomated(string $summary, Project $project): bool - { - return (bool)$this->getTool($summary, $project); - } - - /** - * Fetch the config from https://meta.wikimedia.org/wiki/MediaWiki:XTools-AutoEdits.json - * @param bool $useSandbox Use the sandbox version of the config, located at MediaWiki:XTools-AutoEdits.json/sandbox - * @return array - */ - public function getConfig(bool $useSandbox = false): array - { - $cacheKey = 'autoedits_config'; - if (!$useSandbox && $this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $uri = 'https://meta.wikimedia.org/w/api.php?' . http_build_query([ - 'action' => 'query', - 'prop' => 'revisions', - 'rvprop' => 'content', - 'rvslots' => 'main', - 'format' => 'json', - 'formatversion' => 2, - 'titles' => 'MediaWiki:XTools-AutoEdits.json' . ($useSandbox ? '/sandbox' : ''), - ]); - - if ($useSandbox && $this->requestStack->getSession()->get('logged_in_user')) { - // Request via OAuth to get around server-side caching. - /** @var Client $client */ - $client = $this->requestStack->getSession()->get('oauth_client'); - $resp = json_decode($client->makeOAuthCall( - $this->requestStack->getSession()->get('oauth_access_token'), - $uri - )); - } else { - $resp = json_decode($this->guzzle->get($uri)->getBody()->getContents()); - } - - $ret = json_decode($resp->query->pages[0]->revisions[0]->slots->main->content, true); - - if (!$useSandbox) { - $cacheItem = $this->cache - ->getItem($cacheKey) - ->set($ret) - ->expiresAfter(new DateInterval('PT20M')); - $this->cache->save($cacheItem); - } - - return $ret; - } - - /** - * Get list of automated tools and their associated info for the given project. - * This defaults to the DEFAULT_PROJECT if entries for the given project are not found. - * @param Project $project - * @param bool $useSandbox Whether to use the /sandbox version for testing (also bypasses caching). - * @return array Each tool with the tool name as the key and 'link', 'regex' and/or 'tag' as the subarray keys. - */ - public function getTools(Project $project, bool $useSandbox = false): array - { - $projectDomain = $project->getDomain(); - - if (isset($this->tools[$projectDomain])) { - return $this->tools[$projectDomain]; - } - - // Load the semi-automated edit types. - $tools = $this->getConfig($useSandbox); - - if (isset($tools[$projectDomain])) { - $localRules = $tools[$projectDomain]; - } else { - $localRules = []; - } - - $langRules = $tools[$project->getLang()] ?? []; - - // Per-wiki rules have priority, followed by language-specific and global. - $globalWithLangRules = $this->mergeRules($tools['global'], $langRules); - - $this->tools[$projectDomain] = $this->mergeRules( - $globalWithLangRules, - $localRules - ); - - // Once last walk through for some tidying up and validation. - $invalid = []; - array_walk($this->tools[$projectDomain], function (&$data, $tool) use (&$invalid): void { - // Populate the 'label' with the tool name, if a label doesn't already exist. - $data['label'] = $data['label'] ?? $tool; - - // 'namespaces' should be an array of ints. - $data['namespaces'] = $data['namespaces'] ?? []; - if (isset($data['namespace'])) { - $data['namespaces'][] = $data['namespace']; - unset($data['namespace']); - } - - // 'tags' should be an array of strings. - $data['tags'] = $data['tags'] ?? []; - if (isset($data['tag'])) { - $data['tags'][] = $data['tag']; - unset($data['tag']); - } - - // If neither a tag or regex is given, it's invalid. - if (empty($data['tags']) && empty($data['regex'])) { - $invalid[] = $tool; - } - }); - - uksort($this->tools[$projectDomain], 'strcasecmp'); - - if ($invalid) { - $this->tools[$projectDomain]['invalid'] = $invalid; - } - - return $this->tools[$projectDomain]; - } - - /** - * Get all the tags associated to automated edits on a given project. - * @param bool $useSandbox Whether to use the /sandbox version for testing (also bypasses caching). - * @return array Array with numeric keys and values being tag names (as in change_tag_def). - */ - public function getTags(Project $project, bool $useSandbox = false): array - { - $tools = $this->getTools($project, $useSandbox); - $tags = array_merge(... array_map(fn($o) => $o["tags"], array_values($tools))); - return $tags; - } - - /** - * Merges the given rule sets, giving priority to the local set. Regex is concatenated, not overridden. - * @param string[] $globalRules The global rule set. - * @param string[] $localRules The rule set for the local wiki. - * @return string[] Merged rules. - */ - private function mergeRules(array $globalRules, array $localRules): array - { - // Initial set, including just the global rules. - $tools = $globalRules; - - // Loop through local rules and override/merge as necessary. - foreach ($localRules as $tool => $rules) { - $newRules = $rules; - - if (isset($globalRules[$tool])) { - // Order within array_merge is important, so that local rules get priority. - $newRules = array_merge($globalRules[$tool], $rules); - } - - // Regex should be merged, not overridden. - if (isset($rules['regex']) && isset($globalRules[$tool]['regex'])) { - $newRules['regex'] = implode('|', [ - $rules['regex'], - $globalRules[$tool]['regex'], - ]); - } - - $tools[$tool] = $newRules; - } - - return $tools; - } - - /** - * Get only tools that are used to revert edits. - * Revert detection happens only by testing against a regular expression, and not by checking tags. - * @param Project $project - * @return string[][] Each tool with the tool name as the key, - * and 'link' and 'regex' as the subarray keys. - */ - public function getRevertTools(Project $project): array - { - $projectDomain = $project->getDomain(); - - if (isset($this->revertTools[$projectDomain])) { - return $this->revertTools[$projectDomain]; - } - - $revertEntries = array_filter( - $this->getTools($project), - function ($tool) { - return isset($tool['revert']) && isset($tool['regex']); - } - ); - - // If 'revert' is set to `true`, then use 'regex' as the regular expression, - // otherwise 'revert' is assumed to be the regex string. - $this->revertTools[$projectDomain] = array_map(function ($revertTool) { - return [ - 'link' => $revertTool['link'], - 'regex' => true === $revertTool['revert'] ? $revertTool['regex'] : $revertTool['revert'], - ]; - }, $revertEntries); - - return $this->revertTools[$projectDomain]; - } - - /** - * Was the edit a revert, based on the edit summary? - * This only works for tools defined with regular expressions, not tags. - * @param string|null $summary Edit summary. Can be null for instance for suppressed edits. - * @param Project $project - * @return bool - */ - public function isRevert(?string $summary, Project $project): bool - { - foreach (array_values($this->getRevertTools($project)) as $values) { - if (preg_match('/'.$values['regex'].'/', (string)$summary)) { - return true; - } - } - - return false; - } +class AutomatedEditsHelper { + /** @var array The list of tools that are considered reverting. */ + protected array $revertTools = []; + + /** @var array The list of tool names and their regexes/tags. */ + protected array $tools = []; + + /** + * AutomatedEditsHelper constructor. + * @param RequestStack $requestStack + * @param CacheItemPoolInterface $cache + * @param \GuzzleHttp\Client $guzzle + */ + public function __construct( + protected RequestStack $requestStack, + protected CacheItemPoolInterface $cache, + protected \GuzzleHttp\Client $guzzle + ) { + } + + /** + * Get the first tool that matched the given edit summary and tags. + * @param string $summary Edit summary + * @param Project $project + * @param string[] $tags + * @return string[]|null Tool entry including key for 'name', or false if nothing was found + */ + public function getTool( string $summary, Project $project, array $tags = [] ): ?array { + foreach ( $this->getTools( $project ) as $tool => $values ) { + if ( ( isset( $values['regex'] ) && preg_match( '/' . $values['regex'] . '/', $summary ) ) || + ( isset( $values['tags'] ) && count( array_intersect( $values['tags'], $tags ) ) > 0 ) + ) { + return array_merge( [ + 'name' => $tool, + ], $values ); + } + } + + return null; + } + + /** + * Was the edit (semi-)automated, based on the edit summary? + * @param string $summary Edit summary + * @param Project $project + * @return bool + */ + public function isAutomated( string $summary, Project $project ): bool { + return (bool)$this->getTool( $summary, $project ); + } + + /** + * Fetch the config from https://meta.wikimedia.org/wiki/MediaWiki:XTools-AutoEdits.json + * @param bool $useSandbox Use the sandbox version of the config, located at MediaWiki:XTools-AutoEdits.json/sandbox + * @return array + */ + public function getConfig( bool $useSandbox = false ): array { + $cacheKey = 'autoedits_config'; + if ( !$useSandbox && $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $uri = 'https://meta.wikimedia.org/w/api.php?' . http_build_query( [ + 'action' => 'query', + 'prop' => 'revisions', + 'rvprop' => 'content', + 'rvslots' => 'main', + 'format' => 'json', + 'formatversion' => 2, + 'titles' => 'MediaWiki:XTools-AutoEdits.json' . ( $useSandbox ? '/sandbox' : '' ), + ] ); + + if ( $useSandbox && $this->requestStack->getSession()->get( 'logged_in_user' ) ) { + // Request via OAuth to get around server-side caching. + /** @var Client $client */ + $client = $this->requestStack->getSession()->get( 'oauth_client' ); + $resp = json_decode( $client->makeOAuthCall( + $this->requestStack->getSession()->get( 'oauth_access_token' ), + $uri + ) ); + } else { + $resp = json_decode( $this->guzzle->get( $uri )->getBody()->getContents() ); + } + + $ret = json_decode( $resp->query->pages[0]->revisions[0]->slots->main->content, true ); + + if ( !$useSandbox ) { + $cacheItem = $this->cache + ->getItem( $cacheKey ) + ->set( $ret ) + ->expiresAfter( new DateInterval( 'PT20M' ) ); + $this->cache->save( $cacheItem ); + } + + return $ret; + } + + /** + * Get list of automated tools and their associated info for the given project. + * This defaults to the DEFAULT_PROJECT if entries for the given project are not found. + * @param Project $project + * @param bool $useSandbox Whether to use the /sandbox version for testing (also bypasses caching). + * @return array Each tool with the tool name as the key and 'link', 'regex' and/or 'tag' as the subarray keys. + */ + public function getTools( Project $project, bool $useSandbox = false ): array { + $projectDomain = $project->getDomain(); + + if ( isset( $this->tools[$projectDomain] ) ) { + return $this->tools[$projectDomain]; + } + + // Load the semi-automated edit types. + $tools = $this->getConfig( $useSandbox ); + + if ( isset( $tools[$projectDomain] ) ) { + $localRules = $tools[$projectDomain]; + } else { + $localRules = []; + } + + $langRules = $tools[$project->getLang()] ?? []; + + // Per-wiki rules have priority, followed by language-specific and global. + $globalWithLangRules = $this->mergeRules( $tools['global'], $langRules ); + + $this->tools[$projectDomain] = $this->mergeRules( + $globalWithLangRules, + $localRules + ); + + // Once last walk through for some tidying up and validation. + $invalid = []; + array_walk( $this->tools[$projectDomain], static function ( &$data, $tool ) use ( &$invalid ): void { + // Populate the 'label' with the tool name, if a label doesn't already exist. + $data['label'] = $data['label'] ?? $tool; + + // 'namespaces' should be an array of ints. + $data['namespaces'] = $data['namespaces'] ?? []; + if ( isset( $data['namespace'] ) ) { + $data['namespaces'][] = $data['namespace']; + unset( $data['namespace'] ); + } + + // 'tags' should be an array of strings. + $data['tags'] = $data['tags'] ?? []; + if ( isset( $data['tag'] ) ) { + $data['tags'][] = $data['tag']; + unset( $data['tag'] ); + } + + // If neither a tag or regex is given, it's invalid. + if ( empty( $data['tags'] ) && empty( $data['regex'] ) ) { + $invalid[] = $tool; + } + } ); + + uksort( $this->tools[$projectDomain], 'strcasecmp' ); + + if ( $invalid ) { + $this->tools[$projectDomain]['invalid'] = $invalid; + } + + return $this->tools[$projectDomain]; + } + + /** + * Get all the tags associated to automated edits on a given project. + * @param Project $project + * @param bool $useSandbox Whether to use the /sandbox version for testing (also bypasses caching). + * @return array Array with numeric keys and values being tag names (as in change_tag_def). + */ + public function getTags( Project $project, bool $useSandbox = false ): array { + $tools = $this->getTools( $project, $useSandbox ); + $tags = array_merge( ...array_map( static fn ( $o ) => $o["tags"], array_values( $tools ) ) ); + return $tags; + } + + /** + * Merges the given rule sets, giving priority to the local set. Regex is concatenated, not overridden. + * @param string[] $globalRules The global rule set. + * @param string[] $localRules The rule set for the local wiki. + * @return string[] Merged rules. + */ + private function mergeRules( array $globalRules, array $localRules ): array { + // Initial set, including just the global rules. + $tools = $globalRules; + + // Loop through local rules and override/merge as necessary. + foreach ( $localRules as $tool => $rules ) { + $newRules = $rules; + + if ( isset( $globalRules[$tool] ) ) { + // Order within array_merge is important, so that local rules get priority. + $newRules = array_merge( $globalRules[$tool], $rules ); + } + + // Regex should be merged, not overridden. + if ( isset( $rules['regex'] ) && isset( $globalRules[$tool]['regex'] ) ) { + $newRules['regex'] = implode( '|', [ + $rules['regex'], + $globalRules[$tool]['regex'], + ] ); + } + + $tools[$tool] = $newRules; + } + + return $tools; + } + + /** + * Get only tools that are used to revert edits. + * Revert detection happens only by testing against a regular expression, and not by checking tags. + * @param Project $project + * @return string[][] Each tool with the tool name as the key, + * and 'link' and 'regex' as the subarray keys. + */ + public function getRevertTools( Project $project ): array { + $projectDomain = $project->getDomain(); + + if ( isset( $this->revertTools[$projectDomain] ) ) { + return $this->revertTools[$projectDomain]; + } + + $revertEntries = array_filter( + $this->getTools( $project ), + static function ( $tool ) { + return isset( $tool['revert'] ) && isset( $tool['regex'] ); + } + ); + + // If 'revert' is set to `true`, then use 'regex' as the regular expression, + // otherwise 'revert' is assumed to be the regex string. + $this->revertTools[$projectDomain] = array_map( static function ( $revertTool ) { + return [ + 'link' => $revertTool['link'], + 'regex' => $revertTool['revert'] === true ? $revertTool['regex'] : $revertTool['revert'], + ]; + }, $revertEntries ); + + return $this->revertTools[$projectDomain]; + } + + /** + * Was the edit a revert, based on the edit summary? + * This only works for tools defined with regular expressions, not tags. + * @param string|null $summary Edit summary. Can be null for instance for suppressed edits. + * @param Project $project + * @return bool + */ + public function isRevert( ?string $summary, Project $project ): bool { + foreach ( array_values( $this->getRevertTools( $project ) ) as $values ) { + if ( preg_match( '/' . $values['regex'] . '/', (string)$summary ) ) { + return true; + } + } + + return false; + } } diff --git a/src/Helper/I18nHelper.php b/src/Helper/I18nHelper.php index 66110fd3f..6c05677d5 100644 --- a/src/Helper/I18nHelper.php +++ b/src/Helper/I18nHelper.php @@ -1,6 +1,6 @@ intuition)) { - return $this->intuition; - } - - // Find the path, and complain if English doesn't exist. - $path = $this->projectDir . '/i18n'; - if (!file_exists("$path/en.json")) { - throw new Exception("Language directory doesn't exist: $path"); - } - - $this->intuition = new Intuition('xtools'); - $this->intuition->registerDomain('xtools', $path); - - $useLang = $this->getIntuitionLang(); - // Validate the language. - if (!$this->intuition->getLangName($useLang)) { - $useLang = 'en'; - } - - // Save the language to the session. - $session = $this->requestStack->getSession(); - if ($session->get('lang') !== $useLang) { - $session->set('lang', $useLang); - } - - $this->intuition->setLang(strtolower($useLang)); - - return $this->intuition; - } - - /** - * Get the current language code. - * @return string - */ - public function getLang(): string - { - return $this->getIntuition()->getLang(); - } - - /** - * Get the current language name (defaults to 'English'). - * @return string - */ - public function getLangName(): string - { - return in_array(ucfirst($this->getIntuition()->getLangName()), $this->getAllLangs()) - ? $this->getIntuition()->getLangName() - : 'English'; - } - - /** - * Get all available languages in the i18n directory - * @return string[] Associative array of langKey => langName - */ - public function getAllLangs(): array - { - $messageFiles = glob($this->projectDir.'/i18n/*.json'); - - $languages = array_values(array_unique(array_map( - function ($filename) { - return basename($filename, '.json'); - }, - $messageFiles - ))); - - $availableLanguages = []; - - foreach ($languages as $lang) { - $availableLanguages[$lang] = ucfirst($this->getIntuition()->getLangName($lang)); - } - asort($availableLanguages); - - return $availableLanguages; - } - - /** - * Whether the current language is right-to-left. - * @param string|null $lang Optionally provide a specific language code. - * @return bool - */ - public function isRTL(?string $lang = null): bool - { - return $this->getIntuition()->isRTL( - $lang ?? $this->getLang() - ); - } - - /** - * Get the fallback languages for the current or given language, so we know what to - * load with jQuery.i18n. Languages for which no file exists are not returned. - * @param string|null $useLang - * @return string[] - */ - public function getFallbacks(?string $useLang = null): array - { - $i18nPath = $this->projectDir.'/i18n/'; - $useLang = $useLang ?? $this->getLang(); - - $fallbacks = array_merge( - [$useLang], - $this->getIntuition()->getLangFallbacks($useLang) - ); - - return array_filter($fallbacks, function ($lang) use ($i18nPath) { - return is_file($i18nPath.$lang.'.json'); - }); - } - - /******************** MESSAGE HELPERS ********************/ - - /** - * Get an i18n message. - * @param string|null $message - * @param string[] $vars - * @return string|null - */ - public function msg(?string $message, array $vars = []): ?string - { - return $this->getIntuition()->msg($message, ['domain' => 'xtools', 'variables' => $vars]); - } - - /** - * See if a given i18n message exists. - * @param string|null $message The message. - * @param string[] $vars - * @return bool - */ - public function msgExists(?string $message, array $vars = []): bool - { - return $message && $this->getIntuition()->msgExists($message, array_merge( - ['domain' => 'xtools'], - ['variables' => $vars] - )); - } - - /** - * Get an i18n message if it exists, otherwise just get the message key. - * @param string|null $message - * @param string[] $vars - * @return string - */ - public function msgIfExists(?string $message, array $vars = []): string - { - if ($this->msgExists($message, $vars)) { - return $this->msg($message, $vars); - } else { - return $message ?? ''; - } - } - - /************************ NUMBERS ************************/ - - /** - * Format a number based on language settings. - * @param int|float $number - * @param int $decimals Number of decimals to format to. - * @return string - */ - public function numberFormat(int|float $number, int $decimals = 0): string - { - $lang = $this->getLangForTranslatingNumerals(); - if (!isset($this->numFormatter)) { - $this->numFormatter = new NumberFormatter($lang, NumberFormatter::DECIMAL); - } - - $this->numFormatter->setAttribute(NumberFormatter::MAX_FRACTION_DIGITS, $decimals); - - return $this->numFormatter->format((float)$number ?? 0); - } - - /** - * Format a given number or fraction as a percentage. - * @param int|float $numerator Numerator or single fraction if denominator is omitted. - * @param int|null $denominator Denominator. - * @param integer $precision Number of decimal places to show. - * @return string Formatted percentage. - */ - public function percentFormat(int|float $numerator, ?int $denominator = null, int $precision = 1): string - { - $lang = $this->getLangForTranslatingNumerals(); - if (!isset($this->percentFormatter)) { - $this->percentFormatter = new NumberFormatter($lang, NumberFormatter::PERCENT); - } - - $this->percentFormatter->setAttribute(NumberFormatter::MAX_FRACTION_DIGITS, $precision); - - if (null === $denominator) { - $quotient = $numerator / 100; - } elseif (0 === $denominator) { - $quotient = 0; - } else { - $quotient = $numerator / $denominator; - } - - return $this->percentFormatter->format($quotient); - } - - /************************ DATES ************************/ - - /** - * Localize the given date based on language settings. - * @param string|int|DateTime $datetime - * @param string $pattern Format according to this ICU date format. - * @see http://userguide.icu-project.org/formatparse/datetime - * @return string - */ - public function dateFormat(string|int|DateTime $datetime, string $pattern = 'yyyy-MM-dd HH:mm'): string - { - $lang = $this->getLangForTranslatingNumerals(); - if (!isset($this->dateFormatter)) { - $this->dateFormatter = new IntlDateFormatter( - $lang, - IntlDateFormatter::SHORT, - IntlDateFormatter::SHORT - ); - } - - if (is_string($datetime)) { - $datetime = new DateTime($datetime); - } elseif (is_int($datetime)) { - $datetime = DateTime::createFromFormat('U', (string)$datetime); - } elseif (!is_a($datetime, 'DateTime')) { - return ''; // Unknown format. - } - - $this->dateFormatter->setPattern($pattern); - - return $this->dateFormatter->format($datetime); - } - - /********************* PRIVATE METHODS *********************/ - - /** - * Return the language to be used when translating numberals. - * Currently this just disables numeral translation for Arabic. - * @see https://mediawiki.org/wiki/Topic:Y4ufad47v5o4ebpe - * @todo This should go by $wgTranslateNumerals. - * @return string - */ - private function getLangForTranslatingNumerals(): string - { - return 'ar' === $this->getIntuition()->getLang() ? 'en': $this->getIntuition()->getLang(); - } - - /** - * Determine the interface language, either from the current request or session. - * @return string - */ - private function getIntuitionLang(): string - { - $queryLang = $this->getRequest()->query->get('uselang'); - $sessionLang = $this->requestStack->getSession()->get('lang'); - return $queryLang ?? $sessionLang ?? 'en'; - } - - /** - * Shorthand to get the current request from the request stack. - * @return Request|null Null in test suite. - * There is no request stack in the tests. - * @codeCoverageIgnore - */ - private function getRequest(): ?Request - { - return $this->requestStack->getCurrentRequest(); - } +class I18nHelper { + protected ContainerInterface $container; + protected Intuition $intuition; + protected IntlDateFormatter $dateFormatter; + protected NumberFormatter $numFormatter; + protected NumberFormatter $percentFormatter; + + /** + * Constructor for the I18nHelper. + * @param RequestStack $requestStack + * @param string $projectDir + */ + public function __construct( + protected RequestStack $requestStack, + private readonly string $projectDir + ) { + } + + /** + * Get an Intuition object, set to the current language based on the query string or session + * of the current request. + * @return Intuition + * @throws Exception If the 'i18n/en.json' file doesn't exist (as it's the default). + */ + public function getIntuition(): Intuition { + // Don't recreate the object. + if ( isset( $this->intuition ) ) { + return $this->intuition; + } + + // Find the path, and complain if English doesn't exist. + $path = $this->projectDir . '/i18n'; + if ( !file_exists( "$path/en.json" ) ) { + throw new Exception( "Language directory doesn't exist: $path" ); + } + + $this->intuition = new Intuition( 'xtools' ); + $this->intuition->registerDomain( 'xtools', $path ); + + $useLang = $this->getIntuitionLang(); + // Validate the language. + if ( !$this->intuition->getLangName( $useLang ) ) { + $useLang = 'en'; + } + + // Save the language to the session. + $session = $this->requestStack->getSession(); + if ( $session->get( 'lang' ) !== $useLang ) { + $session->set( 'lang', $useLang ); + } + + $this->intuition->setLang( strtolower( $useLang ) ); + + return $this->intuition; + } + + /** + * Get the current language code. + * @return string + */ + public function getLang(): string { + return $this->getIntuition()->getLang(); + } + + /** + * Get the current language name (defaults to 'English'). + * @return string + */ + public function getLangName(): string { + return in_array( ucfirst( $this->getIntuition()->getLangName() ), $this->getAllLangs() ) + ? $this->getIntuition()->getLangName() + : 'English'; + } + + /** + * Get all available languages in the i18n directory + * @return string[] Associative array of langKey => langName + */ + public function getAllLangs(): array { + $messageFiles = glob( $this->projectDir . '/i18n/*.json' ); + + $languages = array_values( array_unique( array_map( + static function ( $filename ) { + return basename( $filename, '.json' ); + }, + $messageFiles + ) ) ); + + $availableLanguages = []; + + foreach ( $languages as $lang ) { + $availableLanguages[$lang] = ucfirst( $this->getIntuition()->getLangName( $lang ) ); + } + asort( $availableLanguages ); + + return $availableLanguages; + } + + /** + * Whether the current language is right-to-left. + * @param string|null $lang Optionally provide a specific language code. + * @return bool + */ + public function isRTL( ?string $lang = null ): bool { + return $this->getIntuition()->isRTL( + $lang ?? $this->getLang() + ); + } + + /** + * Get the fallback languages for the current or given language, so we know what to + * load with jQuery.i18n. Languages for which no file exists are not returned. + * @param string|null $useLang + * @return string[] + */ + public function getFallbacks( ?string $useLang = null ): array { + $i18nPath = $this->projectDir . '/i18n/'; + $useLang = $useLang ?? $this->getLang(); + + $fallbacks = array_merge( + [ $useLang ], + $this->getIntuition()->getLangFallbacks( $useLang ) + ); + + return array_filter( $fallbacks, static function ( $lang ) use ( $i18nPath ) { + return is_file( $i18nPath . $lang . '.json' ); + } ); + } + + /******************** MESSAGE HELPERS */ + + /** + * Get an i18n message. + * @param string|null $message + * @param string[] $vars + * @return string|null + */ + public function msg( ?string $message, array $vars = [] ): ?string { + return $this->getIntuition()->msg( $message, [ 'domain' => 'xtools', 'variables' => $vars ] ); + } + + /** + * See if a given i18n message exists. + * @param string|null $message The message. + * @param string[] $vars + * @return bool + */ + public function msgExists( ?string $message, array $vars = [] ): bool { + return $message && $this->getIntuition()->msgExists( $message, array_merge( + [ 'domain' => 'xtools' ], + [ 'variables' => $vars ] + ) ); + } + + /** + * Get an i18n message if it exists, otherwise just get the message key. + * @param string|null $message + * @param string[] $vars + * @return string + */ + public function msgIfExists( ?string $message, array $vars = [] ): string { + if ( $this->msgExists( $message, $vars ) ) { + return $this->msg( $message, $vars ); + } else { + return $message ?? ''; + } + } + + /************************ NUMBERS */ + + /** + * Format a number based on language settings. + * @param int|float $number + * @param int $decimals Number of decimals to format to. + * @return string + */ + public function numberFormat( int|float $number, int $decimals = 0 ): string { + $lang = $this->getLangForTranslatingNumerals(); + if ( !isset( $this->numFormatter ) ) { + $this->numFormatter = new NumberFormatter( $lang, NumberFormatter::DECIMAL ); + } + + $this->numFormatter->setAttribute( NumberFormatter::MAX_FRACTION_DIGITS, $decimals ); + + return $this->numFormatter->format( (float)$number ?? 0 ); + } + + /** + * Format a given number or fraction as a percentage. + * @param int|float $numerator Numerator or single fraction if denominator is omitted. + * @param int|null $denominator Denominator. + * @param int $precision Number of decimal places to show. + * @return string Formatted percentage. + */ + public function percentFormat( int|float $numerator, ?int $denominator = null, int $precision = 1 ): string { + $lang = $this->getLangForTranslatingNumerals(); + if ( !isset( $this->percentFormatter ) ) { + $this->percentFormatter = new NumberFormatter( $lang, NumberFormatter::PERCENT ); + } + + $this->percentFormatter->setAttribute( NumberFormatter::MAX_FRACTION_DIGITS, $precision ); + + if ( $denominator === null ) { + $quotient = $numerator / 100; + } elseif ( $denominator === 0 ) { + $quotient = 0; + } else { + $quotient = $numerator / $denominator; + } + + return $this->percentFormatter->format( $quotient ); + } + + /************************ DATES */ + + /** + * Localize the given date based on language settings. + * @param string|int|DateTime $datetime + * @param string $pattern Format according to this ICU date format. + * @see http://userguide.icu-project.org/formatparse/datetime + * @return string + */ + public function dateFormat( string|int|DateTime $datetime, string $pattern = 'yyyy-MM-dd HH:mm' ): string { + $lang = $this->getLangForTranslatingNumerals(); + if ( !isset( $this->dateFormatter ) ) { + $this->dateFormatter = new IntlDateFormatter( + $lang, + IntlDateFormatter::SHORT, + IntlDateFormatter::SHORT + ); + } + + if ( is_string( $datetime ) ) { + $datetime = new DateTime( $datetime ); + } elseif ( is_int( $datetime ) ) { + $datetime = DateTime::createFromFormat( 'U', (string)$datetime ); + } elseif ( !is_a( $datetime, 'DateTime' ) ) { + // Unknown format. + return ''; + } + + $this->dateFormatter->setPattern( $pattern ); + + return $this->dateFormatter->format( $datetime ); + } + + /********************* PRIVATE METHODS */ + + /** + * Return the language to be used when translating numberals. + * Currently this just disables numeral translation for Arabic. + * @see https://mediawiki.org/wiki/Topic:Y4ufad47v5o4ebpe + * @todo This should go by $wgTranslateNumerals. + * @return string + */ + private function getLangForTranslatingNumerals(): string { + return $this->getIntuition()->getLang() === 'ar' ? 'en' : $this->getIntuition()->getLang(); + } + + /** + * Determine the interface language, either from the current request or session. + * @return string + */ + private function getIntuitionLang(): string { + $queryLang = $this->getRequest()->query->get( 'uselang' ); + $sessionLang = $this->requestStack->getSession()->get( 'lang' ); + return $queryLang ?? $sessionLang ?? 'en'; + } + + /** + * Shorthand to get the current request from the request stack. + * @return Request|null Null in test suite. + * There is no request stack in the tests. + * @codeCoverageIgnore + */ + private function getRequest(): ?Request { + return $this->requestStack->getCurrentRequest(); + } } diff --git a/src/Kernel.php b/src/Kernel.php index 433f634f0..48a83d69a 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -1,13 +1,12 @@ 1.25, - 'edit-count-mult' => 1.25, - 'user-page-mult' => 0.1, - 'patrols-mult' => 1, - 'blocks-mult' => 1.4, - 'afd-mult' => 1.15, - 'recent-activity-mult' => 0.9, - 'aiv-mult' => 1.15, - 'edit-summaries-mult' => 0.8, - 'namespaces-mult' => 1.0, - 'pages-created-live-mult' => 1.4, - 'pages-created-deleted-mult' => 1.4, - 'rpp-mult' => 1.15, - 'user-rights-mult' => 0.75, - ]; +class AdminScore extends Model { + /** + * @var array Multipliers (may need review). This currently is dynamic, but should be a constant. + */ + private array $multipliers = [ + 'account-age-mult' => 1.25, + 'edit-count-mult' => 1.25, + 'user-page-mult' => 0.1, + 'patrols-mult' => 1, + 'blocks-mult' => 1.4, + 'afd-mult' => 1.15, + 'recent-activity-mult' => 0.9, + 'aiv-mult' => 1.15, + 'edit-summaries-mult' => 0.8, + 'namespaces-mult' => 1.0, + 'pages-created-live-mult' => 1.4, + 'pages-created-deleted-mult' => 1.4, + 'rpp-mult' => 1.15, + 'user-rights-mult' => 0.75, + ]; - /** @var array The scoring results. */ - protected array $scores; + /** @var array The scoring results. */ + protected array $scores; - /** @var int The total of all scores. */ - protected int $total; + /** @var int The total of all scores. */ + protected int $total; - /** - * AdminScore constructor. - * @param Repository|AdminScoreRepository $repository - * @param Project $project - * @param ?User $user - */ - public function __construct( - protected Repository|AdminScoreRepository $repository, - protected Project $project, - protected ?User $user - ) { - } + /** + * AdminScore constructor. + * @param Repository|AdminScoreRepository $repository + * @param Project $project + * @param ?User $user + */ + public function __construct( + protected Repository|AdminScoreRepository $repository, + protected Project $project, + protected ?User $user + ) { + } - /** - * Get the scoring results. - * @return array See AdminScoreRepository::getData() for the list of keys. - */ - public function getScores(): array - { - if (isset($this->scores)) { - return $this->scores; - } - $this->prepareData(); - return $this->scores; - } + /** + * Get the scoring results. + * @return array See AdminScoreRepository::getData() for the list of keys. + */ + public function getScores(): array { + if ( isset( $this->scores ) ) { + return $this->scores; + } + $this->prepareData(); + return $this->scores; + } - /** - * Get the total score. - * @return int - */ - public function getTotal(): int - { - if (isset($this->total)) { - return $this->total; - } - $this->prepareData(); - return $this->total; - } + /** + * Get the total score. + * @return int + */ + public function getTotal(): int { + if ( isset( $this->total ) ) { + return $this->total; + } + $this->prepareData(); + return $this->total; + } - /** - * Set the scoring results on class properties $scores and $total. - */ - public function prepareData(): void - { - $data = $this->repository->fetchData($this->project, $this->user); - $this->total = 0; - $this->scores = []; + /** + * Set the scoring results on class properties $scores and $total. + */ + public function prepareData(): void { + $data = $this->repository->fetchData( $this->project, $this->user ); + $this->total = 0; + $this->scores = []; - foreach ($data as $row) { - $key = $row['source']; - $value = $row['value']; + foreach ( $data as $row ) { + $key = $row['source']; + $value = $row['value']; - // WMF Replica databases are returning binary control characters - // This is specifically shown with WikiData. - // More details: T197165 - $isnull = (null == $value); - if (!$isnull) { - $value = str_replace("\x00", "", $value); - } + // WMF Replica databases are returning binary control characters + // This is specifically shown with WikiData. + // More details: T197165 + $isnull = ( $value == null ); + if ( !$isnull ) { + $value = str_replace( "\x00", "", $value ); + } - if ('account-age' === $key) { - if ($isnull) { - $value = 0; - } else { - $now = new DateTime(); - $date = new DateTime($value); - $diff = $date->diff($now); - $formula = 365 * (int)$diff->format('%y') + 30 * - (int)$diff->format('%m') + (int)$diff->format('%d'); - if ($formula < 365) { - $this->multipliers['account-age-mult'] = 0; - } - $value = $formula; - } - } + if ( $key === 'account-age' ) { + if ( $isnull ) { + $value = 0; + } else { + $now = new DateTime(); + $date = new DateTime( $value ); + $diff = $date->diff( $now ); + $formula = 365 * (int)$diff->format( '%y' ) + 30 * + (int)$diff->format( '%m' ) + (int)$diff->format( '%d' ); + if ( $formula < 365 ) { + $this->multipliers['account-age-mult'] = 0; + } + $value = $formula; + } + } - $multiplierKey = $row['source'] . '-mult'; - $multiplier = $this->multipliers[$multiplierKey] ?? 1; - $score = max(min($value * $multiplier, 100), -100); - $this->scores[$key]['mult'] = $multiplier; - $this->scores[$key]['value'] = $value; - $this->scores[$key]['score'] = $score; - $this->total += (int)$score; - } - } + $multiplierKey = $row['source'] . '-mult'; + $multiplier = $this->multipliers[$multiplierKey] ?? 1; + $score = max( min( $value * $multiplier, 100 ), -100 ); + $this->scores[$key]['mult'] = $multiplier; + $this->scores[$key]['value'] = $value; + $this->scores[$key]['score'] = $score; + $this->total += (int)$score; + } + } } diff --git a/src/Model/AdminStats.php b/src/Model/AdminStats.php index c5ec2dbd0..d3a5ff5be 100644 --- a/src/Model/AdminStats.php +++ b/src/Model/AdminStats.php @@ -1,6 +1,6 @@ type; - } - - /** - * Get the user_group from the config given the 'group'. - * @return string - */ - public function getRelevantUserGroup(): string - { - // Quick cache, valid only for the same request. - static $relevantUserGroup = ''; - if ('' !== $relevantUserGroup) { - return $relevantUserGroup; - } - - return $relevantUserGroup = $this->getRepository()->getRelevantUserGroup($this->type); - } - - /** - * Get the array of statistics for each qualifying user. This may be called ahead of self::getStats() so certain - * class-level properties will be supplied (such as self::numUsers(), which is called in the view before iterating - * over the master array of statistics). - * @return string[] - */ - public function prepareStats(): array - { - if (isset($this->adminStats)) { - return $this->adminStats; - } - - $stats = $this->getRepository() - ->getStats($this->project, $this->start, $this->end, $this->type, $this->actions); - - // Group by username. - $stats = $this->groupStatsByUsername($stats); - - // Resort, as for some reason the SQL doesn't do this properly. - uasort($stats, function ($a, $b) { - if ($a['total'] === $b['total']) { - return 0; - } - return $a['total'] < $b['total'] ? 1 : -1; - }); - - $this->adminStats = $stats; - return $this->adminStats; - } - - /** - * Get users of the project that are capable of making the relevant actions, - * keyed by user name, with the user groups as the values. - * @return string[][] - */ - public function getUsersAndGroups(): array - { - if (isset($this->usersAndGroups)) { - return $this->usersAndGroups; - } - - // All the user groups that are considered capable of making the relevant actions for $this->group. - $groupUserGroups = $this->getRepository()->getUserGroups($this->project, $this->type); - - $this->usersAndGroups = $this->project->getUsersInGroups($groupUserGroups['local'], $groupUserGroups['global']); - - // Populate $this->usersInGroup with users who are in the relevant user group for $this->group. - $this->usersInGroup = array_keys(array_filter($this->usersAndGroups, function ($groups) { - return in_array($this->getRelevantUserGroup(), $groups); - })); - - return $this->usersAndGroups; - } - - /** - * Get all user groups with permissions applicable to the $this->group. - * @param bool $wikiPath Whether to return the title for the on-wiki image, instead of full URL. - * @return array Each entry contains 'name' (user group) and 'rights' (the permissions). - */ - public function getUserGroupIcons(bool $wikiPath = false): array - { - // Quick cache, valid only for the same request. - static $userGroupIcons = null; - if (null !== $userGroupIcons) { - $out = $userGroupIcons; - } else { - $out = $userGroupIcons = $this->getRepository()->getUserGroupIcons(); - } - - if ($wikiPath) { - $out = array_map(function ($url) { - return str_replace('.svg.png', '.svg', preg_replace('/.*\/18px-/', '', $url)); - }, $out); - } - - return $out; - } - - /** - * The number of days we're spanning between the start and end date. - * @return int - */ - public function numDays(): int - { - return (int)(($this->end - $this->start) / 60 / 60 / 24) + 1; - } - - /** - * Get the master array of statistics for each qualifying user. - * @return string[] - */ - public function getStats(): array - { - if (isset($this->adminStats)) { - $this->adminStats = $this->prepareStats(); - } - return $this->adminStats; - } - - /** - * Get the actions that are shown as columns in the view. - * @return string[] Each the i18n key of the action. - */ - public function getActions(): array - { - return count($this->getStats()) > 0 - ? array_diff(array_keys(array_values($this->getStats())[0]), ['username', 'user-groups', 'total']) - : []; - } - - /** - * Given the data returned by AdminStatsRepository::getStats, return the stats keyed by user name, - * adding in a key/value for user groups. - * @param string[][] $data As retrieved by AdminStatsRepository::getStats - * @return string[] Stats keyed by user name. - * Functionality covered in test for self::getStats(). - * @codeCoverageIgnore - */ - private function groupStatsByUsername(array $data): array - { - $usersAndGroups = $this->getUsersAndGroups(); - $users = []; - - foreach ($data as $datum) { - $username = $datum['username']; - - // Push to array containing all users with admin actions. - // We also want numerical values to be integers. - $users[$username] = array_map('intval', $datum); - - // Push back username which was casted to an integer. - $users[$username]['username'] = $username; - - // Set the 'user-groups' property with the user groups they belong to (if any), - // going off of self::getUsersAndGroups(). - if (isset($usersAndGroups[$username])) { - $users[$username]['user-groups'] = $usersAndGroups[$username]; - } else { - $users[$username]['user-groups'] = []; - } - - // Keep track of users who are not in the relevant user group but made applicable actions. - if (in_array($username, $this->usersInGroup)) { - $this->numWithActions++; - } - } - - return $users; - } - - /** - * Get the "totals" row. - * @return array containing as keys the counts. - */ - public function getTotalsRow(): array - { - $totalsRow = []; - foreach ($this->adminStats as $data) { - foreach ($data as $action => $count) { - if ('username' === $action || 'user-groups' === $action) { - continue; - } - $totalsRow[$action] ??= 0; - $totalsRow[$action] += $count; - } - } - return $totalsRow; - } - - /** - * Get the total number of users in the relevant user group. - * @return int - */ - public function getNumInRelevantUserGroup(): int - { - return count($this->usersInGroup); - } - - /** - * Number of users who made any relevant actions within the time period. - * @return int - */ - public function getNumWithActions(): int - { - return $this->numWithActions; - } - - /** - * Number of currently users who made any actions within the time period who are not in the relevant user group. - * @return int - */ - public function getNumWithActionsNotInGroup(): int - { - return count($this->adminStats) - $this->numWithActions; - } +class AdminStats extends Model { + + /** @var string[][] Keyed by user name, values are arrays containing actions and counts. */ + protected array $adminStats; + + /** @var string[] Keys are user names, values are their user groups. */ + protected array $usersAndGroups; + + /** @var int Number of users in the relevant group who made any actions within the time period. */ + protected int $numWithActions = 0; + + /** @var string[] Usernames of users who are in the relevant user group (sysop for admins, etc.). */ + private array $usersInGroup = []; + + /** + * AdminStats constructor. + * @param Repository|AdminStatsRepository $repository + * @param Project $project + * @param false|int $start as UTC timestamp. + * @param false|int $end as UTC timestamp. + * @param string $type Which user group to get stats for. Refer to admin_stats.yaml for possible values. + * @param string[] $actions Which actions to query for ('block', 'protect', etc.). Null for all actions. + */ + public function __construct( + protected Repository|AdminStatsRepository $repository, + protected Project $project, + protected false|int $start, + protected false|int $end, + /** @var string Type that we're getting stats for (admin, patroller, steward, etc.). See admin_stats.yaml */ + private string $type, + /** @var string[] Which actions to show ('block', 'protect', etc.) */ + private array $actions + ) { + } + + /** + * Get the group for this AdminStats. + * @return string + */ + public function getType(): string { + return $this->type; + } + + /** + * Get the user_group from the config given the 'group'. + * @return string + */ + public function getRelevantUserGroup(): string { + // Quick cache, valid only for the same request. + static $relevantUserGroup = ''; + if ( $relevantUserGroup !== '' ) { + return $relevantUserGroup; + } + + $relevantUserGroup = $this->getRepository()->getRelevantUserGroup( $this->type ); + return $relevantUserGroup; + } + + /** + * Get the array of statistics for each qualifying user. This may be called ahead of self::getStats() so certain + * class-level properties will be supplied (such as self::numUsers(), which is called in the view before iterating + * over the master array of statistics). + * @return string[] + */ + public function prepareStats(): array { + if ( isset( $this->adminStats ) ) { + return $this->adminStats; + } + + $stats = $this->getRepository() + ->getStats( $this->project, $this->start, $this->end, $this->type, $this->actions ); + + // Group by username. + $stats = $this->groupStatsByUsername( $stats ); + + // Resort, as for some reason the SQL doesn't do this properly. + uasort( $stats, static function ( $a, $b ) { + if ( $a['total'] === $b['total'] ) { + return 0; + } + return $a['total'] < $b['total'] ? 1 : -1; + } ); + + $this->adminStats = $stats; + return $this->adminStats; + } + + /** + * Get users of the project that are capable of making the relevant actions, + * keyed by user name, with the user groups as the values. + * @return string[][] + */ + public function getUsersAndGroups(): array { + if ( isset( $this->usersAndGroups ) ) { + return $this->usersAndGroups; + } + + // All the user groups that are considered capable of making the relevant actions for $this->group. + $groupUserGroups = $this->getRepository()->getUserGroups( $this->project, $this->type ); + + $this->usersAndGroups = $this->project->getUsersInGroups( + $groupUserGroups['local'], + $groupUserGroups['global'] + ); + + // Populate $this->usersInGroup with users who are in the relevant user group for $this->group. + $this->usersInGroup = array_keys( array_filter( $this->usersAndGroups, function ( array $groups ) { + return in_array( $this->getRelevantUserGroup(), $groups ); + } ) ); + + return $this->usersAndGroups; + } + + /** + * Get all user groups with permissions applicable to the $this->group. + * @param bool $wikiPath Whether to return the title for the on-wiki image, instead of full URL. + * @return array Each entry contains 'name' (user group) and 'rights' (the permissions). + */ + public function getUserGroupIcons( bool $wikiPath = false ): array { + // Quick cache, valid only for the same request. + static $userGroupIcons = null; + if ( $userGroupIcons !== null ) { + $out = $userGroupIcons; + } else { + $out = $userGroupIcons = $this->getRepository()->getUserGroupIcons(); + } + + if ( $wikiPath ) { + $out = array_map( static function ( $url ) { + return str_replace( '.svg.png', '.svg', preg_replace( '/.*\/18px-/', '', $url ) ); + }, $out ); + } + + return $out; + } + + /** + * The number of days we're spanning between the start and end date. + * @return int + */ + public function numDays(): int { + return (int)( ( $this->end - $this->start ) / 60 / 60 / 24 ) + 1; + } + + /** + * Get the master array of statistics for each qualifying user. + * @return string[] + */ + public function getStats(): array { + if ( isset( $this->adminStats ) ) { + $this->adminStats = $this->prepareStats(); + } + return $this->adminStats; + } + + /** + * Get the actions that are shown as columns in the view. + * @return string[] Each the i18n key of the action. + */ + public function getActions(): array { + return count( $this->getStats() ) > 0 + ? array_diff( array_keys( array_values( $this->getStats() )[0] ), [ 'username', 'user-groups', 'total' ] ) + : []; + } + + /** + * Given the data returned by AdminStatsRepository::getStats, return the stats keyed by user name, + * adding in a key/value for user groups. + * @param string[][] $data As retrieved by AdminStatsRepository::getStats + * @return string[] Stats keyed by user name. + * Functionality covered in test for self::getStats(). + * @codeCoverageIgnore + */ + private function groupStatsByUsername( array $data ): array { + $usersAndGroups = $this->getUsersAndGroups(); + $users = []; + + foreach ( $data as $datum ) { + $username = $datum['username']; + + // Push to array containing all users with admin actions. + // We also want numerical values to be integers. + $users[$username] = array_map( 'intval', $datum ); + + // Push back username which was casted to an integer. + $users[$username]['username'] = $username; + + // Set the 'user-groups' property with the user groups they belong to (if any), + // going off of self::getUsersAndGroups(). + if ( isset( $usersAndGroups[$username] ) ) { + $users[$username]['user-groups'] = $usersAndGroups[$username]; + } else { + $users[$username]['user-groups'] = []; + } + + // Keep track of users who are not in the relevant user group but made applicable actions. + if ( in_array( $username, $this->usersInGroup ) ) { + $this->numWithActions++; + } + } + + return $users; + } + + /** + * Get the "totals" row. + * @return array containing as keys the counts. + */ + public function getTotalsRow(): array { + $totalsRow = []; + foreach ( $this->adminStats as $data ) { + foreach ( $data as $action => $count ) { + if ( $action === 'username' || $action === 'user-groups' ) { + continue; + } + $totalsRow[$action] ??= 0; + $totalsRow[$action] += $count; + } + } + return $totalsRow; + } + + /** + * Get the total number of users in the relevant user group. + * @return int + */ + public function getNumInRelevantUserGroup(): int { + return count( $this->usersInGroup ); + } + + /** + * Number of users who made any relevant actions within the time period. + * @return int + */ + public function getNumWithActions(): int { + return $this->numWithActions; + } + + /** + * Number of currently users who made any actions within the time period who are not in the relevant user group. + * @return int + */ + public function getNumWithActionsNotInGroup(): int { + return count( $this->adminStats ) - $this->numWithActions; + } } diff --git a/src/Model/Authorship.php b/src/Model/Authorship.php index d67d7fabc..155b40de4 100644 --- a/src/Model/Authorship.php +++ b/src/Model/Authorship.php @@ -1,6 +1,6 @@ target = $this->getTargetRevId($target); - } - - private function getTargetRevId(?string $target): ?int - { - if (null === $target) { - return null; - } - - if (preg_match('/\d{4}-\d{2}-\d{2}/', $target)) { - $date = DateTime::createFromFormat('Y-m-d', $target); - return $this->page->getRevisionIdAtDate($date); - } - - return (int)$target; - } - - /** - * Domains of supported wikis. - * @return string[] - */ - public function getSupportedWikis(): array - { - return self::SUPPORTED_PROJECTS; - } - - /** - * Get the target revision ID. Null for latest revision. - * @return int|null - */ - public function getTarget(): ?int - { - return $this->target; - } - - /** - * Authorship information for the top $this->limit authors. - * @return array - */ - public function getList(): array - { - return $this->data['list'] ?? []; - } - - /** - * Get error thrown when preparing the data, or null if no error occurred. - * @return string|null - */ - public function getError(): ?string - { - return $this->data['error'] ?? null; - } - - /** - * Get the total number of authors. - * @return int - */ - public function getTotalAuthors(): int - { - return $this->data['totalAuthors']; - } - - /** - * Get the total number of characters added. - * @return int - */ - public function getTotalCount(): int - { - return $this->data['totalCount']; - } - - /** - * Get summary data on the 'other' authors who are not in the top $this->limit. - * @return array|null - */ - public function getOthers(): ?array - { - return $this->data['others'] ?? null; - } - - /** - * Get the revision the authorship data pertains to, with keys 'id' and 'timestamp'. - * @return array|null - */ - public function getRevision(): ?array - { - return $this->revision; - } - - /** - * Is the given page supported by the Authorship tool? - * @param Page $page - * @return bool - */ - public static function isSupportedPage(Page $page): bool - { - return in_array($page->getProject()->getDomain(), self::SUPPORTED_PROJECTS) && - 0 === $page->getNamespace(); - } - - /** - * Get the revision data from the WikiWho API and set $this->revision with basic info. - * If there are errors, they are placed in $this->data['error'] and null will be returned. - * @param bool $returnRevId Whether or not to include revision IDs in the response. - * @return array|null null if there were errors. - */ - protected function getRevisionData(bool $returnRevId = false): ?array - { - try { - $ret = $this->repository->getData($this->page, $this->target, $returnRevId); - } catch (RequestException) { - $this->data = [ - 'error' => 'unknown', - ]; - return null; - } - - // If revision can't be found, return error message. - if (!isset($ret['revisions'][0])) { - $this->data = [ - 'error' => $ret['Error'] ?? 'Unknown', - ]; - return null; - } - - $revId = array_keys($ret['revisions'][0])[0]; - $revisionData = $ret['revisions'][0][$revId]; - - $this->revision = [ - 'id' => $revId, - 'timestamp' => $revisionData['time'], - ]; - - return $revisionData; - } - - /** - * Get authorship attribution from the WikiWho API. - * @see https://www.mediawiki.org/wiki/WikiWho - */ - public function prepareData(): void - { - if (isset($this->data)) { - return; - } - - // Set revision data. self::setRevisionData() returns null if there are errors. - $revisionData = $this->getRevisionData(); - if (null === $revisionData) { - return; - } - - [$counts, $totalCount, $userIds] = $this->countTokens($revisionData['tokens']); - $usernameMap = $this->getUsernameMap($userIds); - - if (null !== $this->limit) { - $countsToProcess = array_slice($counts, 0, $this->limit, true); - } else { - $countsToProcess = $counts; - } - - $data = []; - - // Used to get the character count and percentage of the remaining N editors, after the top $this->limit. - $percentageSum = 0; - $countSum = 0; - $numEditors = 0; - - // Loop through once more, creating an array with the user names (or IP addresses) - // as the key, and the count and percentage as the value. - foreach ($countsToProcess as $editor => $count) { - $index = $usernameMap[$editor] ?? $editor; - - $percentage = round(100 * ($count / $totalCount), 1); - - // If we are showing > 10 editors in the table, we still only want the top 10 for the chart. - if ($numEditors < 10) { - $percentageSum += $percentage; - $countSum += $count; - $numEditors++; - } - - $data[$index] = [ - 'count' => $count, - 'percentage' => $percentage, - ]; - } - - $this->data = [ - 'list' => $data, - 'totalAuthors' => count($counts), - 'totalCount' => $totalCount, - ]; - - // Record character count and percentage for the remaining editors. - if ($percentageSum < 100) { - $this->data['others'] = [ - 'count' => $totalCount - $countSum, - 'percentage' => round(100 - $percentageSum, 1), - 'numEditors' => count($counts) - $numEditors, - ]; - } - } - - /** - * Get a map of user IDs to usernames, given the IDs. - * @param int[] $userIds - * @return array IDs as keys, usernames as values. - */ - private function getUsernameMap(array $userIds): array - { - if (empty($userIds)) { - return []; - } - - $userIdsNames = $this->repository->getUsernamesFromIds( - $this->page->getProject(), - $userIds - ); - - $usernameMap = []; - foreach ($userIdsNames as $userIdName) { - $usernameMap[$userIdName['user_id']] = $userIdName['user_name']; - } - - return $usernameMap; - } - - /** - * Get counts of token lengths for each author. Used in self::prepareData() - * @param array $tokens - * @return array [counts by user, total count, IDs of accounts] - */ - private function countTokens(array $tokens): array - { - $counts = []; - $userIds = []; - $totalCount = 0; - - // Loop through the tokens, keeping totals (token length) for each author. - foreach ($tokens as $token) { - $editor = $token['editor']; - - // IPs are prefixed with '0|', otherwise it's the user ID. - if (str_starts_with($editor, '0|')) { - $editor = substr($editor, 2); - } else { - $userIds[] = $editor; - } - - if (!isset($counts[$editor])) { - $counts[$editor] = 0; - } - - $counts[$editor] += strlen($token['str']); - $totalCount += strlen($token['str']); - } - - // Sort authors by count. - arsort($counts); - - return [$counts, $totalCount, $userIds]; - } +class Authorship extends Model { + /** @const string[] Domain names of wikis supported by WikiWho. */ + public const SUPPORTED_PROJECTS = [ + 'ar.wikipedia.org', + 'de.wikipedia.org', + 'en.wikipedia.org', + 'es.wikipedia.org', + 'eu.wikipedia.org', + 'fr.wikipedia.org', + 'hu.wikipedia.org', + 'id.wikipedia.org', + 'it.wikipedia.org', + 'ja.wikipedia.org', + 'nl.wikipedia.org', + 'pl.wikipedia.org', + 'pt.wikipedia.org', + 'tr.wikipedia.org', + ]; + + /** @var int|null Target revision ID. Null for latest revision. */ + protected ?int $target; + + /** @var array List of editors and the percentage of the current content that they authored. */ + protected array $data; + + /** @var array Revision that the data pertains to, with keys 'id' and 'timestamp'. */ + protected array $revision; + + /** + * Authorship constructor. + * @param Repository|AuthorshipRepository $repository + * @param ?Page $page The page to process. + * @param ?string $target Either a revision ID or date in YYYY-MM-DD format. Null to use latest revision. + * @param ?int $limit Max number of results. + */ + public function __construct( + protected Repository|AuthorshipRepository $repository, + protected ?Page $page, + ?string $target = null, + protected ?int $limit = null + ) { + $this->target = $this->getTargetRevId( $target ); + } + + private function getTargetRevId( ?string $target ): ?int { + if ( $target === null ) { + return null; + } + + if ( preg_match( '/\d{4}-\d{2}-\d{2}/', $target ) ) { + $date = DateTime::createFromFormat( 'Y-m-d', $target ); + return $this->page->getRevisionIdAtDate( $date ); + } + + return (int)$target; + } + + /** + * Domains of supported wikis. + * @return string[] + */ + public function getSupportedWikis(): array { + return self::SUPPORTED_PROJECTS; + } + + /** + * Get the target revision ID. Null for latest revision. + * @return int|null + */ + public function getTarget(): ?int { + return $this->target; + } + + /** + * Authorship information for the top $this->limit authors. + * @return array + */ + public function getList(): array { + return $this->data['list'] ?? []; + } + + /** + * Get error thrown when preparing the data, or null if no error occurred. + * @return string|null + */ + public function getError(): ?string { + return $this->data['error'] ?? null; + } + + /** + * Get the total number of authors. + * @return int + */ + public function getTotalAuthors(): int { + return $this->data['totalAuthors']; + } + + /** + * Get the total number of characters added. + * @return int + */ + public function getTotalCount(): int { + return $this->data['totalCount']; + } + + /** + * Get summary data on the 'other' authors who are not in the top $this->limit. + * @return array|null + */ + public function getOthers(): ?array { + return $this->data['others'] ?? null; + } + + /** + * Get the revision the authorship data pertains to, with keys 'id' and 'timestamp'. + * @return array|null + */ + public function getRevision(): ?array { + return $this->revision; + } + + /** + * Is the given page supported by the Authorship tool? + * @param Page $page + * @return bool + */ + public static function isSupportedPage( Page $page ): bool { + return in_array( $page->getProject()->getDomain(), self::SUPPORTED_PROJECTS ) && + $page->getNamespace() === 0; + } + + /** + * Get the revision data from the WikiWho API and set $this->revision with basic info. + * If there are errors, they are placed in $this->data['error'] and null will be returned. + * @param bool $returnRevId Whether or not to include revision IDs in the response. + * @return array|null null if there were errors. + */ + protected function getRevisionData( bool $returnRevId = false ): ?array { + try { + $ret = $this->repository->getData( $this->page, $this->target, $returnRevId ); + } catch ( RequestException ) { + $this->data = [ + 'error' => 'unknown', + ]; + return null; + } + + // If revision can't be found, return error message. + if ( !isset( $ret['revisions'][0] ) ) { + $this->data = [ + 'error' => $ret['Error'] ?? 'Unknown', + ]; + return null; + } + + $revId = array_keys( $ret['revisions'][0] )[0]; + $revisionData = $ret['revisions'][0][$revId]; + + $this->revision = [ + 'id' => $revId, + 'timestamp' => $revisionData['time'], + ]; + + return $revisionData; + } + + /** + * Get authorship attribution from the WikiWho API. + * @see https://www.mediawiki.org/wiki/WikiWho + */ + public function prepareData(): void { + if ( isset( $this->data ) ) { + return; + } + + // Set revision data. self::setRevisionData() returns null if there are errors. + $revisionData = $this->getRevisionData(); + if ( $revisionData === null ) { + return; + } + + [ $counts, $totalCount, $userIds ] = $this->countTokens( $revisionData['tokens'] ); + $usernameMap = $this->getUsernameMap( $userIds ); + + if ( $this->limit !== null ) { + $countsToProcess = array_slice( $counts, 0, $this->limit, true ); + } else { + $countsToProcess = $counts; + } + + $data = []; + + // Used to get the character count and percentage of the remaining N editors, after the top $this->limit. + $percentageSum = 0; + $countSum = 0; + $numEditors = 0; + + // Loop through once more, creating an array with the user names (or IP addresses) + // as the key, and the count and percentage as the value. + foreach ( $countsToProcess as $editor => $count ) { + $index = $usernameMap[$editor] ?? $editor; + + $percentage = round( 100 * ( $count / $totalCount ), 1 ); + + // If we are showing > 10 editors in the table, we still only want the top 10 for the chart. + if ( $numEditors < 10 ) { + $percentageSum += $percentage; + $countSum += $count; + $numEditors++; + } + + $data[$index] = [ + 'count' => $count, + 'percentage' => $percentage, + ]; + } + + $this->data = [ + 'list' => $data, + 'totalAuthors' => count( $counts ), + 'totalCount' => $totalCount, + ]; + + // Record character count and percentage for the remaining editors. + if ( $percentageSum < 100 ) { + $this->data['others'] = [ + 'count' => $totalCount - $countSum, + 'percentage' => round( 100 - $percentageSum, 1 ), + 'numEditors' => count( $counts ) - $numEditors, + ]; + } + } + + /** + * Get a map of user IDs to usernames, given the IDs. + * @param int[] $userIds + * @return array IDs as keys, usernames as values. + */ + private function getUsernameMap( array $userIds ): array { + if ( empty( $userIds ) ) { + return []; + } + + $userIdsNames = $this->repository->getUsernamesFromIds( + $this->page->getProject(), + $userIds + ); + + $usernameMap = []; + foreach ( $userIdsNames as $userIdName ) { + $usernameMap[$userIdName['user_id']] = $userIdName['user_name']; + } + + return $usernameMap; + } + + /** + * Get counts of token lengths for each author. Used in self::prepareData() + * @param array $tokens + * @return array [counts by user, total count, IDs of accounts] + */ + private function countTokens( array $tokens ): array { + $counts = []; + $userIds = []; + $totalCount = 0; + + // Loop through the tokens, keeping totals (token length) for each author. + foreach ( $tokens as $token ) { + $editor = $token['editor']; + + // IPs are prefixed with '0|', otherwise it's the user ID. + if ( str_starts_with( $editor, '0|' ) ) { + $editor = substr( $editor, 2 ); + } else { + $userIds[] = $editor; + } + + if ( !isset( $counts[$editor] ) ) { + $counts[$editor] = 0; + } + + $counts[$editor] += strlen( $token['str'] ); + $totalCount += strlen( $token['str'] ); + } + + // Sort authors by count. + arsort( $counts ); + + return [ $counts, $totalCount, $userIds ]; + } } diff --git a/src/Model/AutoEdits.php b/src/Model/AutoEdits.php index e516f7c1a..ee8b9b7ba 100644 --- a/src/Model/AutoEdits.php +++ b/src/Model/AutoEdits.php @@ -1,6 +1,6 @@ limit = $limit ?? self::RESULTS_PER_PAGE; - } - - /** - * The tool we're limiting the results to when fetching - * (semi-)automated contributions. - * @return null|string - */ - public function getTool(): ?string - { - return $this->tool; - } - - /** - * Get the raw edit count of the user. - * @return int - */ - public function getEditCount(): int - { - if (!isset($this->editCount)) { - $this->editCount = $this->user->countEdits( - $this->project, - $this->namespace, - $this->start, - $this->end - ); - } - - return $this->editCount; - } - - /** - * Get the number of edits this user made using semi-automated tools. - * This is not the same as self::getToolCounts because the regex can overlap. - * @return int Result of query, see below. - */ - public function getAutomatedCount(): int - { - if (isset($this->automatedCount)) { - return $this->automatedCount; - } - - $this->automatedCount = $this->repository->countAutomatedEdits( - $this->project, - $this->user, - $this->namespace, - $this->start, - $this->end - ); - - return $this->automatedCount; - } - - /** - * Get the percentage of all edits made using automated tools. - * @return float - */ - public function getAutomatedPercentage(): float - { - return $this->getEditCount() > 0 - ? ($this->getAutomatedCount() / $this->getEditCount()) * 100 - : 0; - } - - /** - * Get non-automated contributions for this user. - * @param bool $forJson - * @return string[]|Edit[] - */ - public function getNonAutomatedEdits(bool $forJson = false): array - { - if (isset($this->nonAutomatedEdits)) { - return $this->nonAutomatedEdits; - } - - $revs = $this->repository->getNonAutomatedEdits( - $this->project, - $this->user, - $this->namespace, - $this->start, - $this->end, - $this->offset, - $this->limit - ); - - $this->nonAutomatedEdits = Edit::getEditsFromRevs( - $this->pageRepo, - $this->editRepo, - $this->userRepo, - $this->project, - $this->user, - $revs - ); - - if ($forJson) { - return array_map(function (Edit $edit) { - return $edit->getForJson(); - }, $this->nonAutomatedEdits); - } - - return $this->nonAutomatedEdits; - } - - /** - * Get automated contributions for this user. - * @param bool $forJson - * @return Edit[] - */ - public function getAutomatedEdits(bool $forJson = false): array - { - if (isset($this->automatedEdits)) { - return $this->automatedEdits; - } - - $revs = $this->repository->getAutomatedEdits( - $this->project, - $this->user, - $this->namespace, - $this->start, - $this->end, - $this->tool, - $this->offset, - $this->limit - ); - - $this->automatedEdits = Edit::getEditsFromRevs( - $this->pageRepo, - $this->editRepo, - $this->userRepo, - $this->project, - $this->user, - $revs - ); - - if ($forJson) { - return array_map(function (Edit $edit) { - return $edit->getForJson(); - }, $this->automatedEdits); - } - - return $this->automatedEdits; - } - - /** - * Get counts of known automated tools used by the given user. - * @return array Each tool that they used along with the count and link: - * [ - * 'Twinkle' => [ - * 'count' => 50, - * 'link' => 'Wikipedia:Twinkle', - * ], - * ] - */ - public function getToolCounts(): array - { - if (isset($this->toolCounts)) { - return $this->toolCounts; - } - - $this->toolCounts = $this->repository->getToolCounts( - $this->project, - $this->user, - $this->namespace, - $this->start, - $this->end - ); - - return $this->toolCounts; - } - - /** - * Get a list of all available tools for the Project. - * @return array - */ - public function getAllTools(): array - { - return $this->repository->getTools($this->project); - } - - /** - * Get the combined number of edits made with each tool. This is calculated separately from - * self::getAutomatedCount() because the regex can sometimes overlap, and the counts are actually different. - * @return int - */ - public function getToolsTotal(): int - { - if (!isset($this->toolsTotal)) { - $this->toolsTotal = array_reduce($this->getToolCounts(), function ($a, $b) { - return $a + $b['count']; - }); - } - - return $this->toolsTotal; - } - - /** - * @return bool - */ - public function getUseSandbox(): bool - { - return $this->repository->getUseSandbox(); - } +class AutoEdits extends Model { + /** @var Edit[] The list of non-automated contributions. */ + protected array $nonAutomatedEdits; + + /** @var Edit[] The list of automated contributions. */ + protected array $automatedEdits; + + /** @var int Total number of edits. */ + protected int $editCount; + + /** @var int Total number of non-automated edits. */ + protected int $automatedCount; + + /** @var array Counts of known automated tools used by the given user. */ + protected array $toolCounts; + + /** @var int Total number of edits made with the tools. */ + protected int $toolsTotal; + + /** @var int Default number of results to show per page when fetching (non-)automated edits. */ + public const RESULTS_PER_PAGE = 50; + + /** + * Constructor for the AutoEdits class. + * @param Repository|AutoEditsRepository $repository + * @param EditRepository $editRepo + * @param PageRepository $pageRepo + * @param UserRepository $userRepo + * @param Project $project + * @param ?User $user + * @param int|string $namespace Namespace ID or 'all' + * @param false|int $start Start date as Unix timestamp. + * @param false|int $end End date as Unix timestamp. + * @param ?string $tool The tool we're searching for when fetching (semi-)automated edits. + * @param false|int $offset Unix timestamp. Used for pagination. + * @param int|null $limit Number of results to return. + */ + public function __construct( + protected Repository|AutoEditsRepository $repository, + protected EditRepository $editRepo, + protected PageRepository $pageRepo, + protected UserRepository $userRepo, + protected Project $project, + protected ?User $user, + protected int|string $namespace = 0, + protected false|int $start = false, + protected false|int $end = false, + /** @var ?string The tool we're searching for when fetching (semi-)automated edits. */ + protected ?string $tool = null, + protected false|int $offset = false, + ?int $limit = self::RESULTS_PER_PAGE + ) { + $this->limit = $limit ?? self::RESULTS_PER_PAGE; + } + + /** + * The tool we're limiting the results to when fetching + * (semi-)automated contributions. + * @return null|string + */ + public function getTool(): ?string { + return $this->tool; + } + + /** + * Get the raw edit count of the user. + * @return int + */ + public function getEditCount(): int { + if ( !isset( $this->editCount ) ) { + $this->editCount = $this->user->countEdits( + $this->project, + $this->namespace, + $this->start, + $this->end + ); + } + + return $this->editCount; + } + + /** + * Get the number of edits this user made using semi-automated tools. + * This is not the same as self::getToolCounts because the regex can overlap. + * @return int Result of query, see below. + */ + public function getAutomatedCount(): int { + if ( isset( $this->automatedCount ) ) { + return $this->automatedCount; + } + + $this->automatedCount = $this->repository->countAutomatedEdits( + $this->project, + $this->user, + $this->namespace, + $this->start, + $this->end + ); + + return $this->automatedCount; + } + + /** + * Get the percentage of all edits made using automated tools. + * @return float + */ + public function getAutomatedPercentage(): float { + return $this->getEditCount() > 0 + ? ( $this->getAutomatedCount() / $this->getEditCount() ) * 100 + : 0; + } + + /** + * Get non-automated contributions for this user. + * @param bool $forJson + * @return string[]|Edit[] + */ + public function getNonAutomatedEdits( bool $forJson = false ): array { + if ( isset( $this->nonAutomatedEdits ) ) { + return $this->nonAutomatedEdits; + } + + $revs = $this->repository->getNonAutomatedEdits( + $this->project, + $this->user, + $this->namespace, + $this->start, + $this->end, + $this->offset, + $this->limit + ); + + $this->nonAutomatedEdits = Edit::getEditsFromRevs( + $this->pageRepo, + $this->editRepo, + $this->userRepo, + $this->project, + $this->user, + $revs + ); + + if ( $forJson ) { + return array_map( static function ( Edit $edit ) { + return $edit->getForJson(); + }, $this->nonAutomatedEdits ); + } + + return $this->nonAutomatedEdits; + } + + /** + * Get automated contributions for this user. + * @param bool $forJson + * @return Edit[] + */ + public function getAutomatedEdits( bool $forJson = false ): array { + if ( isset( $this->automatedEdits ) ) { + return $this->automatedEdits; + } + + $revs = $this->repository->getAutomatedEdits( + $this->project, + $this->user, + $this->namespace, + $this->start, + $this->end, + $this->tool, + $this->offset, + $this->limit + ); + + $this->automatedEdits = Edit::getEditsFromRevs( + $this->pageRepo, + $this->editRepo, + $this->userRepo, + $this->project, + $this->user, + $revs + ); + + if ( $forJson ) { + return array_map( static function ( Edit $edit ) { + return $edit->getForJson(); + }, $this->automatedEdits ); + } + + return $this->automatedEdits; + } + + /** + * Get counts of known automated tools used by the given user. + * @return array Each tool that they used along with the count and link: + * [ + * 'Twinkle' => [ + * 'count' => 50, + * 'link' => 'Wikipedia:Twinkle', + * ], + * ] + */ + public function getToolCounts(): array { + if ( isset( $this->toolCounts ) ) { + return $this->toolCounts; + } + + $this->toolCounts = $this->repository->getToolCounts( + $this->project, + $this->user, + $this->namespace, + $this->start, + $this->end + ); + + return $this->toolCounts; + } + + /** + * Get a list of all available tools for the Project. + * @return array + */ + public function getAllTools(): array { + return $this->repository->getTools( $this->project ); + } + + /** + * Get the combined number of edits made with each tool. This is calculated separately from + * self::getAutomatedCount() because the regex can sometimes overlap, and the counts are actually different. + * @return int + */ + public function getToolsTotal(): int { + if ( !isset( $this->toolsTotal ) ) { + $this->toolsTotal = array_reduce( $this->getToolCounts(), static function ( $a, $b ) { + return $a + $b['count']; + } ); + } + + return $this->toolsTotal; + } + + /** + * @return bool + */ + public function getUseSandbox(): bool { + return $this->repository->getUseSandbox(); + } } diff --git a/src/Model/Blame.php b/src/Model/Blame.php index b184abbcf..b9ddd144c 100644 --- a/src/Model/Blame.php +++ b/src/Model/Blame.php @@ -1,6 +1,6 @@ and 'tokens' . */ - protected ?array $matches; - - /** @var Edit|null Target revision that is being blamed. */ - protected ?Edit $asOf; - - /** - * Blame constructor. - * @param Repository|BlameRepository $repository - * @param ?Page $page The page to process. - * @param string $query Text to search for. - * @param string|null $target Either a revision ID or date in YYYY-MM-DD format. Null to use latest revision. - */ - public function __construct( - protected Repository|BlameRepository $repository, - protected ?Page $page, - /** @var string Text to search for. */ - protected string $query, - ?string $target = null - ) { - parent::__construct($repository, $page, $target); - } - - /** - * Get the search query. - * @return string - */ - public function getQuery(): string - { - return $this->query; - } - - /** - * Matches, keyed by revision ID, each with keys 'edit' and 'tokens' . - * @return array|null - */ - public function getMatches(): ?array - { - return $this->matches; - } - - /** - * Get all the matches as Edits. - * @return Edit[]|null - */ - public function getEdits(): ?array - { - return array_column($this->matches, 'edit'); - } - - /** - * Strip out spaces, since they are not accounted for in the WikiWho API. - * @return string - */ - public function getTokenizedQuery(): string - { - return strtolower(preg_replace('/\s*/m', '', $this->query)); - } - - /** - * Get the first "token" of the search query. A "token" in this case is a word or group of syntax, - * roughly correlating to the token structure returned by the WikiWho API. - * @return string - */ - public function getFirstQueryToken(): string - { - return strtolower(preg_split('/[\n\s]/', $this->query)[0]); - } - - /** - * Get the target revision that is being blamed. - * @return Edit|null - */ - public function getAsOf(): ?Edit - { - if (isset($this->asOf)) { - return $this->asOf; - } - - $this->asOf = $this->target - ? $this->repository->getEditFromRevId($this->page, $this->target) - : null; - - return $this->asOf; - } - - /** - * Get authorship attribution from the WikiWho API. - * @see https://www.mediawiki.org/wiki/WikiWho - */ - public function prepareData(): void - { - if (isset($this->matches)) { - return; - } - - // Set revision data. self::setRevisionData() returns null if there are errors. - $revisionData = $this->getRevisionData(true); - if (null === $revisionData) { - return; - } - - $matches = $this->searchTokens($revisionData['tokens']); - - // We want the results grouped by editor and revision ID. - $this->matches = []; - foreach ($matches as $match) { - if (isset($this->matches[$match['id']])) { - $this->matches[$match['id']]['tokens'][] = $match['token']; - continue; - } - - $edit = $this->repository->getEditFromRevId($this->page, $match['id']); - if ($edit) { - $this->matches[$match['id']] = [ - 'edit' => $edit, - 'tokens' => [$match['token']], - ]; - } - } - } - - /** - * Find matches of search query in the given list of tokens. - * @param array $tokens - * @return array - */ - private function searchTokens(array $tokens): array - { - $matchData = []; - $matchDataSoFar = []; - $matchSoFar = ''; - $firstQueryToken = $this->getFirstQueryToken(); - $tokenizedQuery = $this->getTokenizedQuery(); - - foreach ($tokens as $token) { - // The previous matches plus the new token. This is basically a candidate for what may become $matchSoFar. - $newMatchSoFar = $matchSoFar.$token['str']; - - // We first check if the first token of the query matches, because we want to allow for partial matches - // (e.g. for query "barbaz", the tokens ["foobar","baz"] should match). - if (str_contains($newMatchSoFar, $firstQueryToken)) { - // If the full query is in the new match, use it, otherwise use just the first token. This is because - // the full match may exist across multiple tokens, but the first match is only a partial match. - $newMatchSoFar = str_contains($newMatchSoFar, $tokenizedQuery) - ? $newMatchSoFar - : $firstQueryToken; - } - - // Keep track of tokens that match. To allow partial matches, - // we check the query against $newMatchSoFar and vice versa. - if (str_contains($tokenizedQuery, $newMatchSoFar) || - str_contains($newMatchSoFar, $tokenizedQuery) - ) { - $matchSoFar = $newMatchSoFar; - $matchDataSoFar[] = [ - 'id' => $token['o_rev_id'], - 'editor' => $token['editor'], - 'token' => $token['str'], - ]; - } elseif (!empty($matchSoFar)) { - // We hit a token that isn't in the query string, so start over. - $matchDataSoFar = []; - $matchSoFar = ''; - } - - // A full match was found, so merge $matchDataSoFar into $matchData, - // and start over to see if there are more matches in the article. - if (str_contains($matchSoFar, $tokenizedQuery)) { - $matchData = array_merge($matchData, $matchDataSoFar); - $matchDataSoFar = []; - $matchSoFar = ''; - } - } - - // Full matches usually come last, but are the most relevant. - return array_reverse($matchData); - } +class Blame extends Authorship { + /** @var array|null Matches, keyed by revision ID, each with keys 'edit' and 'tokens' . */ + protected ?array $matches; + + /** @var Edit|null Target revision that is being blamed. */ + protected ?Edit $asOf; + + /** + * Blame constructor. + * @param Repository|BlameRepository $repository + * @param ?Page $page The page to process. + * @param string $query Text to search for. + * @param string|null $target Either a revision ID or date in YYYY-MM-DD format. Null to use latest revision. + */ + public function __construct( + protected Repository|BlameRepository $repository, + protected ?Page $page, + /** @var string Text to search for. */ + protected string $query, + ?string $target = null + ) { + parent::__construct( $repository, $page, $target ); + } + + /** + * Get the search query. + * @return string + */ + public function getQuery(): string { + return $this->query; + } + + /** + * Matches, keyed by revision ID, each with keys 'edit' and 'tokens' . + * @return array|null + */ + public function getMatches(): ?array { + return $this->matches; + } + + /** + * Get all the matches as Edits. + * @return Edit[]|null + */ + public function getEdits(): ?array { + return array_column( $this->matches, 'edit' ); + } + + /** + * Strip out spaces, since they are not accounted for in the WikiWho API. + * @return string + */ + public function getTokenizedQuery(): string { + return strtolower( preg_replace( '/\s*/m', '', $this->query ) ); + } + + /** + * Get the first "token" of the search query. A "token" in this case is a word or group of syntax, + * roughly correlating to the token structure returned by the WikiWho API. + * @return string + */ + public function getFirstQueryToken(): string { + return strtolower( preg_split( '/[\n\s]/', $this->query )[0] ); + } + + /** + * Get the target revision that is being blamed. + * @return Edit|null + */ + public function getAsOf(): ?Edit { + if ( isset( $this->asOf ) ) { + return $this->asOf; + } + + $this->asOf = $this->target + ? $this->repository->getEditFromRevId( $this->page, $this->target ) + : null; + + return $this->asOf; + } + + /** + * Get authorship attribution from the WikiWho API. + * @see https://www.mediawiki.org/wiki/WikiWho + */ + public function prepareData(): void { + if ( isset( $this->matches ) ) { + return; + } + + // Set revision data. self::setRevisionData() returns null if there are errors. + $revisionData = $this->getRevisionData( true ); + if ( $revisionData === null ) { + return; + } + + $matches = $this->searchTokens( $revisionData['tokens'] ); + + // We want the results grouped by editor and revision ID. + $this->matches = []; + foreach ( $matches as $match ) { + if ( isset( $this->matches[$match['id']] ) ) { + $this->matches[$match['id']]['tokens'][] = $match['token']; + continue; + } + + $edit = $this->repository->getEditFromRevId( $this->page, $match['id'] ); + if ( $edit ) { + $this->matches[$match['id']] = [ + 'edit' => $edit, + 'tokens' => [ $match['token'] ], + ]; + } + } + } + + /** + * Find matches of search query in the given list of tokens. + * @param array $tokens + * @return array + */ + private function searchTokens( array $tokens ): array { + $matchData = []; + $matchDataSoFar = []; + $matchSoFar = ''; + $firstQueryToken = $this->getFirstQueryToken(); + $tokenizedQuery = $this->getTokenizedQuery(); + + foreach ( $tokens as $token ) { + // The previous matches plus the new token. This is basically a candidate for what may become $matchSoFar. + $newMatchSoFar = $matchSoFar . $token['str']; + + // We first check if the first token of the query matches, because we want to allow for partial matches + // (e.g. for query "barbaz", the tokens ["foobar","baz"] should match). + if ( str_contains( $newMatchSoFar, $firstQueryToken ) ) { + // If the full query is in the new match, use it, otherwise use just the first token. This is because + // the full match may exist across multiple tokens, but the first match is only a partial match. + $newMatchSoFar = str_contains( $newMatchSoFar, $tokenizedQuery ) + ? $newMatchSoFar + : $firstQueryToken; + } + + // Keep track of tokens that match. To allow partial matches, + // we check the query against $newMatchSoFar and vice versa. + if ( str_contains( $tokenizedQuery, $newMatchSoFar ) || + str_contains( $newMatchSoFar, $tokenizedQuery ) + ) { + $matchSoFar = $newMatchSoFar; + $matchDataSoFar[] = [ + 'id' => $token['o_rev_id'], + 'editor' => $token['editor'], + 'token' => $token['str'], + ]; + } elseif ( !empty( $matchSoFar ) ) { + // We hit a token that isn't in the query string, so start over. + $matchDataSoFar = []; + $matchSoFar = ''; + } + + // A full match was found, so merge $matchDataSoFar into $matchData, + // and start over to see if there are more matches in the article. + if ( str_contains( $matchSoFar, $tokenizedQuery ) ) { + $matchData = array_merge( $matchData, $matchDataSoFar ); + $matchDataSoFar = []; + $matchSoFar = ''; + } + } + + // Full matches usually come last, but are the most relevant. + return array_reverse( $matchData ); + } } diff --git a/src/Model/CategoryEdits.php b/src/Model/CategoryEdits.php index 77ce3f5d3..bff82cd1e 100644 --- a/src/Model/CategoryEdits.php +++ b/src/Model/CategoryEdits.php @@ -1,6 +1,6 @@ categories = array_map(function ($category) { - return str_replace(' ', '_', $category); - }, $categories); - } - - /** - * Get the categories. - * @return string[] - */ - public function getCategories(): array - { - return $this->categories; - } - - /** - * Get the categories as a piped string. - * @return string - */ - public function getCategoriesPiped(): string - { - return implode('|', $this->categories); - } - - /** - * Get the categories as an array of normalized strings (without namespace). - * @return string[] - */ - public function getCategoriesNormalized(): array - { - return array_map(function ($category) { - return str_replace('_', ' ', $category); - }, $this->categories); - } - - /** - * Get the raw edit count of the user. - * @return int - */ - public function getEditCount(): int - { - if (!isset($this->editCount)) { - $this->editCount = $this->user->countEdits( - $this->project, - 'all', - $this->start, - $this->end - ); - } - - return $this->editCount; - } - - /** - * Get the number of edits this user made within the categories. - * @return int Result of query, see below. - */ - public function getCategoryEditCount(): int - { - if (isset($this->categoryEditCount)) { - return $this->categoryEditCount; - } - - $this->categoryEditCount = $this->repository->countCategoryEdits( - $this->project, - $this->user, - $this->categories, - $this->start, - $this->end - ); - - return $this->categoryEditCount; - } - - /** - * Get the percentage of all edits made to the categories. - * @return float - */ - public function getCategoryPercentage(): float - { - return $this->getEditCount() > 0 - ? ($this->getCategoryEditCount() / $this->getEditCount()) * 100 - : 0; - } - - /** - * Get the number of pages edited. - * @return int - */ - public function getCategoryPageCount(): int - { - $pageCount = 0; - foreach ($this->getCategoryCounts() as $categoryCount) { - $pageCount += $categoryCount['pageCount']; - } - - return $pageCount; - } - - /** - * Get contributions made to the categories. - * @param bool $raw Wether to return raw data from the database, or get Edit objects. - * @return string[]|Edit[] - */ - public function getCategoryEdits(bool $raw = false): array - { - if (isset($this->categoryEdits)) { - return $this->categoryEdits; - } - - $revs = $this->repository->getCategoryEdits( - $this->project, - $this->user, - $this->categories, - $this->start, - $this->end, - $this->offset - ); - - if ($raw) { - return $revs; - } - - $this->categoryEdits = $this->repository->getEditsFromRevs( - $this->project, - $this->user, - $revs - ); - - return $this->categoryEdits; - } - - /** - * Get counts of edits made to each individual category. - * @return array Counts, keyed by category name. - */ - public function getCategoryCounts(): array - { - if (isset($this->categoryCounts)) { - return $this->categoryCounts; - } - - $this->categoryCounts = $this->repository->getCategoryCounts( - $this->project, - $this->user, - $this->categories, - $this->start, - $this->end - ); - - return $this->categoryCounts; - } +class CategoryEdits extends Model { + /** @var string[] The categories. */ + protected array $categories; + + /** @var Edit[] The list of contributions. */ + protected array $categoryEdits; + + /** @var int Total number of edits. */ + protected int $editCount; + + /** @var int Total number of edits within the category. */ + protected int $categoryEditCount; + + /** @var array Counts of edits within each category, keyed by category name. */ + protected array $categoryCounts; + + /** + * Constructor for the CategoryEdits class. + * @param Repository|CategoryEditsRepository $repository + * @param Project $project + * @param ?User $user + * @param array $categories + * @param int|false $start As Unix timestamp. + * @param int|false $end As Unix timestamp. + * @param int|false $offset As Unix timestamp. Used for pagination. + */ + public function __construct( + protected Repository|CategoryEditsRepository $repository, + protected Project $project, + protected ?User $user, + array $categories, + protected int|false $start = false, + protected int|false $end = false, + protected int|false $offset = false + ) { + $this->categories = array_map( static function ( $category ) { + return str_replace( ' ', '_', $category ); + }, $categories ); + } + + /** + * Get the categories. + * @return string[] + */ + public function getCategories(): array { + return $this->categories; + } + + /** + * Get the categories as a piped string. + * @return string + */ + public function getCategoriesPiped(): string { + return implode( '|', $this->categories ); + } + + /** + * Get the categories as an array of normalized strings (without namespace). + * @return string[] + */ + public function getCategoriesNormalized(): array { + return array_map( static function ( $category ) { + return str_replace( '_', ' ', $category ); + }, $this->categories ); + } + + /** + * Get the raw edit count of the user. + * @return int + */ + public function getEditCount(): int { + if ( !isset( $this->editCount ) ) { + $this->editCount = $this->user->countEdits( + $this->project, + 'all', + $this->start, + $this->end + ); + } + + return $this->editCount; + } + + /** + * Get the number of edits this user made within the categories. + * @return int Result of query, see below. + */ + public function getCategoryEditCount(): int { + if ( isset( $this->categoryEditCount ) ) { + return $this->categoryEditCount; + } + + $this->categoryEditCount = $this->repository->countCategoryEdits( + $this->project, + $this->user, + $this->categories, + $this->start, + $this->end + ); + + return $this->categoryEditCount; + } + + /** + * Get the percentage of all edits made to the categories. + * @return float + */ + public function getCategoryPercentage(): float { + return $this->getEditCount() > 0 + ? ( $this->getCategoryEditCount() / $this->getEditCount() ) * 100 + : 0; + } + + /** + * Get the number of pages edited. + * @return int + */ + public function getCategoryPageCount(): int { + $pageCount = 0; + foreach ( $this->getCategoryCounts() as $categoryCount ) { + $pageCount += $categoryCount['pageCount']; + } + + return $pageCount; + } + + /** + * Get contributions made to the categories. + * @param bool $raw Wether to return raw data from the database, or get Edit objects. + * @return string[]|Edit[] + */ + public function getCategoryEdits( bool $raw = false ): array { + if ( isset( $this->categoryEdits ) ) { + return $this->categoryEdits; + } + + $revs = $this->repository->getCategoryEdits( + $this->project, + $this->user, + $this->categories, + $this->start, + $this->end, + $this->offset + ); + + if ( $raw ) { + return $revs; + } + + $this->categoryEdits = $this->repository->getEditsFromRevs( + $this->project, + $this->user, + $revs + ); + + return $this->categoryEdits; + } + + /** + * Get counts of edits made to each individual category. + * @return array Counts, keyed by category name. + */ + public function getCategoryCounts(): array { + if ( isset( $this->categoryCounts ) ) { + return $this->categoryCounts; + } + + $this->categoryCounts = $this->repository->getCategoryCounts( + $this->project, + $this->user, + $this->categories, + $this->start, + $this->end + ); + + return $this->categoryCounts; + } } diff --git a/src/Model/Edit.php b/src/Model/Edit.php index 7ffbe24a6..5a108f81f 100644 --- a/src/Model/Edit.php +++ b/src/Model/Edit.php @@ -1,6 +1,6 @@ id = isset($attrs['id']) ? (int)$attrs['id'] : (int)$attrs['rev_id']; - - // Allow DateTime or string (latter assumed to be of format YmdHis) - if ($attrs['timestamp'] instanceof DateTime) { - $this->timestamp = $attrs['timestamp']; - } else { - try { - $this->timestamp = DateTime::createFromFormat('YmdHis', $attrs['timestamp']); - } catch (TypeError $e) { - // Some very old revisions may be missing a timestamp. - $this->timestamp = new DateTime('1970-01-01T00:00:00Z'); - } - } - - $this->deleted = (int)($attrs['rev_deleted'] ?? 0); - - if (($this->deleted & self::DELETED_USER) || ($this->deleted & self::DELETED_RESTRICTED)) { - $this->user = null; - } else { - $this->user = $attrs['user'] ?? ($attrs['username'] ? new User($this->userRepo, $attrs['username']) : null); - } - - $this->minor = 1 === (int)$attrs['minor']; - $this->length = isset($attrs['length']) ? (int)$attrs['length'] : null; - $this->lengthChange = isset($attrs['length_change']) ? (int)$attrs['length_change'] : null; - $this->comment = $attrs['comment'] ?? ''; - - // Had to be JSON to put multiple values in 1 column. - $this->tags = json_decode($attrs['tags'] ?? '[]'); - - if (isset($attrs['rev_sha1']) || isset($attrs['sha'])) { - $this->sha = $attrs['rev_sha1'] ?? $attrs['sha']; - } - - // This can be passed in to save as a property on the Edit instance. - // Note that the Edit class knows nothing about it's value, and - // is not capable of detecting whether the given edit was actually reverted. - $this->reverted = isset($attrs['reverted']) ? (bool)$attrs['reverted'] : null; - } - - /** - * Get Edits given revision rows (JOINed on the page table). - * @param PageRepository $pageRepo - * @param EditRepository $editRepo - * @param UserRepository $userRepo - * @param Project $project - * @param User $user - * @param array $revs Each must contain 'page_title' and 'namespace'. - * @return Edit[] - */ - public static function getEditsFromRevs( - PageRepository $pageRepo, - EditRepository $editRepo, - UserRepository $userRepo, - Project $project, - User $user, - array $revs - ): array { - return array_map(function ($rev) use ($pageRepo, $editRepo, $userRepo, $project, $user) { - /** Page object to be passed to the Edit constructor. */ - $page = Page::newFromRow($pageRepo, $project, $rev); - $rev['user'] = $user; - - return new self($editRepo, $userRepo, $page, $rev); - }, $revs); - } - - /** - * Unique identifier for this Edit, to be used in cache keys. - * @see Repository::getCacheKey() - * @return string - */ - public function getCacheKey(): string - { - return (string)$this->id; - } - - /** - * ID of the edit. - * @return int - */ - public function getId(): int - { - return $this->id; - } - - /** - * Get the edit's timestamp. - * @return DateTime - */ - public function getTimestamp(): DateTime - { - return $this->timestamp; - } - - /** - * Get the edit's timestamp as a UTC string, as with YYYY-MM-DDTHH:MM:SSZ - * @return string - */ - public function getUTCTimestamp(): string - { - return $this->getTimestamp()->format('Y-m-d\TH:i:s\Z'); - } - - /** - * Year the revision was made. - * @return string - */ - public function getYear(): string - { - return $this->timestamp->format('Y'); - } - - /** - * Get the numeric representation of the month the revision was made, with leading zeros. - * @return string - */ - public function getMonth(): string - { - return $this->timestamp->format('m'); - } - - /** - * Whether or not this edit was a minor edit. - * @return bool - */ - public function getMinor(): bool - { - return $this->minor; - } - - /** - * Alias of getMinor() - * @return bool Whether or not this edit was a minor edit - */ - public function isMinor(): bool - { - return $this->getMinor(); - } - - /** - * Length of the page as of this edit, in bytes. - * @see Edit::getSize() Edit::getSize() for the size change. - * @return int|null - */ - public function getLength(): ?int - { - return $this->length; - } - - /** - * The diff size of this edit. - * @return int|null Signed length change in bytes. - */ - public function getSize(): ?int - { - return $this->lengthChange; - } - - /** - * Alias of getSize() - * @return int|null The diff size of this edit - */ - public function getLengthChange(): ?int - { - return $this->getSize(); - } - - /** - * Get the user who made the edit. - * @return User|null null can happen for instance if the username was suppressed. - */ - public function getUser(): ?User - { - return $this->user; - } - - /** - * Get the edit summary. - * @return string - */ - public function getComment(): string - { - return (string)$this->comment; - } - - /** - * Get the edit summary (alias of Edit::getComment()). - * @return string - */ - public function getSummary(): string - { - return $this->getComment(); - } - - /** - * Get the SHA-1 of the revision. - * @return string|null - */ - public function getSha(): ?string - { - return $this->sha; - } - - /** - * Was this edit reported as having been reverted? - * The value for this is merely passed in from precomputed data. - * @return bool|null - */ - public function isReverted(): ?bool - { - return $this->reverted; - } - - /** - * Set the reverted property. - * @param bool $reverted - */ - public function setReverted(bool $reverted): void - { - $this->reverted = $reverted; - } - - /** - * Get deletion status of the revision. - * @return int - */ - public function getDeleted(): int - { - return $this->deleted; - } - - /** - * Was the username deleted from public view? - * @return bool - */ - public function deletedUser(): bool - { - return ($this->deleted & self::DELETED_USER) > 0; - } - - /** - * Was the edit summary deleted from public view? - * @return bool - */ - public function deletedSummary(): bool - { - return ($this->deleted & self::DELETED_COMMENT) > 0; - } - - /** - * Get edit summary as 'wikified' HTML markup - * @param bool $useUnnormalizedPageTitle Use the unnormalized page title to avoid - * an API call. This should be used only if you fetched the page title via other - * means (SQL query), and is not from user input alone. - * @return string Safe HTML - */ - public function getWikifiedComment(bool $useUnnormalizedPageTitle = false): string - { - return self::wikifyString( - $this->getSummary(), - $this->getProject(), - $this->page, - $useUnnormalizedPageTitle - ); - } - - /** - * Public static method to wikify a summary, can be used on any arbitrary string. - * Does NOT support section links unless you specify a page. - * @param string $summary - * @param Project $project - * @param Page|null $page - * @param bool $useUnnormalizedPageTitle Use the unnormalized page title to avoid - * an API call. This should be used only if you fetched the page title via other - * means (SQL query), and is not from user input alone. - * @static - * @return string - */ - public static function wikifyString( - string $summary, - Project $project, - ?Page $page = null, - bool $useUnnormalizedPageTitle = false - ): string { - // The html_entity_decode makes & and & display the same - // But that is MW behaviour - $summary = htmlspecialchars(html_entity_decode($summary), ENT_NOQUOTES); - - // First link raw URLs. Courtesy of https://stackoverflow.com/a/11641499/604142 - $summary = preg_replace( - '%\b(([\w-]+://?|www[.])[^\s()<>]+(?:\([\w\d]+\)|([^[:punct:]\s]|/)))%s', - '$1', - $summary - ); - - $sectionMatch = null; - $isSection = preg_match_all("/^\/\* (.*?) \*\//", $summary, $sectionMatch); - - if ($isSection && isset($page)) { - $pageUrl = $project->getUrlForPage($page->getTitle($useUnnormalizedPageTitle)); - $sectionTitle = $sectionMatch[1][0]; - - // Must have underscores for the link to properly go to the section. - // Have to decode twice; once for the entities added with htmlspecialchars; - // And one for user entities (which are decoded in mw section ids). - $sectionTitleLink = html_entity_decode(html_entity_decode(str_replace(' ', '_', $sectionTitle))); - - $sectionWikitext = "" . - "" . $sectionTitle . ": "; - $summary = str_replace($sectionMatch[0][0], $sectionWikitext, $summary); - } - - $linkMatch = null; - - while (preg_match_all("/\[\[:?([^\[\]]*?)]]/", $summary, $linkMatch)) { - $wikiLinkParts = explode('|', $linkMatch[1][0]); - $wikiLinkPath = htmlspecialchars($wikiLinkParts[0]); - $wikiLinkText = htmlspecialchars( - $wikiLinkParts[1] ?? $wikiLinkPath - ); - - // Use normalized page title (underscored, capitalized). - $pageUrl = $project->getUrlForPage(ucfirst(str_replace(' ', '_', $wikiLinkPath))); - - $link = "$wikiLinkText"; - $summary = str_replace($linkMatch[0][0], $link, $summary); - } - - return $summary; - } - - /** - * Get edit summary as 'wikified' HTML markup (alias of Edit::getWikifiedComment()). - * @return string - */ - public function getWikifiedSummary(): string - { - return $this->getWikifiedComment(); - } - - /** - * Get the project this edit was made on - * @return Project - */ - public function getProject(): Project - { - return $this->getPage()->getProject(); - } - - /** - * Get the full URL to the diff of the edit - * @return string - */ - public function getDiffUrl(): string - { - return rtrim($this->getProject()->getUrlForPage('Special:Diff/' . $this->id), '/'); - } - - /** - * Get the full permanent URL to the page at the time of the edit - * @return string - */ - public function getPermaUrl(): string - { - return rtrim($this->getProject()->getUrlForPage('Special:PermaLink/' . $this->id), '/'); - } - - /** - * Was the edit a revert, based on the edit summary? - * @return bool - */ - public function isRevert(): bool - { - return $this->repository->getAutoEditsHelper()->isRevert($this->comment, $this->getProject()); - } - - /** - * Get the name of the tool that was used to make this edit. - * @return array|null The name of the tool(s) that was used to make the edit. - */ - public function getTool(): ?array - { - return $this->repository->getAutoEditsHelper()->getTool($this->comment, $this->getProject(), $this->tags); - } - - /** - * Was the edit (semi-)automated, based on the edit summary? - * @return bool - */ - public function isAutomated(): bool - { - return (bool)$this->getTool(); - } - - /** - * Was the edit made by a logged out user (IP or temporary account)? - * @param Project $project - * @return bool|null - */ - public function isAnon(Project $project): ?bool - { - return $this->getUser() ? $this->getUser()->isAnon($project) : null; - } - - /** - * List of tag names for the edit. - * Only filled in by PageInfo. - * @return string[] - */ - public function getTags(): array - { - return $this->tags; - } - - /** - * Get HTML for the diff of this Edit. - * @return string|null Raw HTML, must be wrapped in a tag. Null if no comparison could be made. - */ - public function getDiffHtml(): ?string - { - return $this->repository->getDiffHtml($this); - } - - /** - * Formats the data as an array for use in JSON APIs. - * @param bool $includeProject - * @return array - * @internal This method assumes the Edit was constructed with data already filled in from a database query. - */ - public function getForJson(bool $includeProject = false): array - { - $nsId = $this->getPage()->getNamespace(); - $pageTitle = $this->getPage()->getTitle(true); - - if ($nsId > 0) { - $nsName = $this->getProject()->getNamespaces()[$nsId]; - $pageTitle = preg_replace("/^$nsName:/", '', $pageTitle); - } - - $ret = [ - 'page_title' => str_replace('_', ' ', $pageTitle), - 'namespace' => $nsId, - ]; - if ($includeProject) { - $ret += ['project' => $this->getProject()->getDomain()]; - } - if ($this->getUser()) { - $ret += ['username' => $this->getUser()->getUsername()]; - } - $ret += [ - 'rev_id' => $this->id, - 'timestamp' => $this->getUTCTimestamp(), - 'minor' => $this->minor, - 'length' => $this->length, - 'length_change' => $this->lengthChange, - 'comment' => $this->comment, - ]; - if (null !== $this->reverted) { - $ret['reverted'] = $this->reverted; - } - - return $ret; - } +class Edit extends Model { + public const DELETED_TEXT = 1; + public const DELETED_COMMENT = 2; + public const DELETED_USER = 4; + public const DELETED_RESTRICTED = 8; + + /** @var int ID of the revision */ + protected int $id; + + /** @var DateTime Timestamp of the revision */ + protected DateTime $timestamp; + + /** @var bool Whether or not this edit was a minor edit */ + protected bool $minor; + + /** @var int|null Length of the page as of this edit, in bytes */ + protected ?int $length; + + /** @var int|null The diff size of this edit */ + protected ?int $lengthChange; + + /** @var string The edit summary */ + protected string $comment; + + /** @var string|null The SHA-1 of the wikitext as of the revision. */ + protected ?string $sha = null; + + /** @var bool|null Whether this edit was later reverted. */ + protected ?bool $reverted; + + /** @var int Deletion status of the revision. */ + protected int $deleted; + + /** @var string[] List of tags of the revision. */ + protected array $tags; + + /** + * Edit constructor. + * @param Repository|EditRepository $repository + * @param UserRepository $userRepo + * @param ?Page $page + * @param string[] $attrs Attributes, as retrieved by PageRepository::getRevisions() + */ + public function __construct( + protected Repository|EditRepository $repository, + protected UserRepository $userRepo, + protected ?Page $page, + array $attrs = [] + ) { + // Copy over supported attributes + $this->id = isset( $attrs['id'] ) ? (int)$attrs['id'] : (int)$attrs['rev_id']; + + // Allow DateTime or string (latter assumed to be of format YmdHis) + if ( $attrs['timestamp'] instanceof DateTime ) { + $this->timestamp = $attrs['timestamp']; + } else { + try { + $this->timestamp = DateTime::createFromFormat( 'YmdHis', $attrs['timestamp'] ); + } catch ( TypeError $e ) { + // Some very old revisions may be missing a timestamp. + $this->timestamp = new DateTime( '1970-01-01T00:00:00Z' ); + } + } + + $this->deleted = (int)( $attrs['rev_deleted'] ?? 0 ); + + if ( ( $this->deleted & self::DELETED_USER ) || ( $this->deleted & self::DELETED_RESTRICTED ) ) { + $this->user = null; + } else { + $this->user = $attrs['user'] ?? + ( $attrs['username'] ? new User( $this->userRepo, $attrs['username'] ) : null ); + } + + $this->minor = (int)$attrs['minor'] === 1; + $this->length = isset( $attrs['length'] ) ? (int)$attrs['length'] : null; + $this->lengthChange = isset( $attrs['length_change'] ) ? (int)$attrs['length_change'] : null; + $this->comment = $attrs['comment'] ?? ''; + + // Had to be JSON to put multiple values in 1 column. + $this->tags = json_decode( $attrs['tags'] ?? '[]' ); + + if ( isset( $attrs['rev_sha1'] ) || isset( $attrs['sha'] ) ) { + $this->sha = $attrs['rev_sha1'] ?? $attrs['sha']; + } + + // This can be passed in to save as a property on the Edit instance. + // Note that the Edit class knows nothing about it's value, and + // is not capable of detecting whether the given edit was actually reverted. + $this->reverted = isset( $attrs['reverted'] ) ? (bool)$attrs['reverted'] : null; + } + + /** + * Get Edits given revision rows (JOINed on the page table). + * @param PageRepository $pageRepo + * @param EditRepository $editRepo + * @param UserRepository $userRepo + * @param Project $project + * @param User $user + * @param array $revs Each must contain 'page_title' and 'namespace'. + * @return Edit[] + */ + public static function getEditsFromRevs( + PageRepository $pageRepo, + EditRepository $editRepo, + UserRepository $userRepo, + Project $project, + User $user, + array $revs + ): array { + return array_map( static function ( $rev ) use ( $pageRepo, $editRepo, $userRepo, $project, $user ) { + /** Page object to be passed to the Edit constructor. */ + $page = Page::newFromRow( $pageRepo, $project, $rev ); + $rev['user'] = $user; + + return new self( $editRepo, $userRepo, $page, $rev ); + }, $revs ); + } + + /** + * Unique identifier for this Edit, to be used in cache keys. + * @see Repository::getCacheKey() + * @return string + */ + public function getCacheKey(): string { + return (string)$this->id; + } + + /** + * ID of the edit. + * @return int + */ + public function getId(): int { + return $this->id; + } + + /** + * Get the edit's timestamp. + * @return DateTime + */ + public function getTimestamp(): DateTime { + return $this->timestamp; + } + + /** + * Get the edit's timestamp as a UTC string, as with YYYY-MM-DDTHH:MM:SSZ + * @return string + */ + public function getUTCTimestamp(): string { + return $this->getTimestamp()->format( 'Y-m-d\TH:i:s\Z' ); + } + + /** + * Year the revision was made. + * @return string + */ + public function getYear(): string { + return $this->timestamp->format( 'Y' ); + } + + /** + * Get the numeric representation of the month the revision was made, with leading zeros. + * @return string + */ + public function getMonth(): string { + return $this->timestamp->format( 'm' ); + } + + /** + * Whether or not this edit was a minor edit. + * @return bool + */ + public function getMinor(): bool { + return $this->minor; + } + + /** + * Alias of getMinor() + * @return bool Whether or not this edit was a minor edit + */ + public function isMinor(): bool { + return $this->getMinor(); + } + + /** + * Length of the page as of this edit, in bytes. + * @see Edit::getSize() Edit::getSize() for the size change. + * @return int|null + */ + public function getLength(): ?int { + return $this->length; + } + + /** + * The diff size of this edit. + * @return int|null Signed length change in bytes. + */ + public function getSize(): ?int { + return $this->lengthChange; + } + + /** + * Alias of getSize() + * @return int|null The diff size of this edit + */ + public function getLengthChange(): ?int { + return $this->getSize(); + } + + /** + * Get the user who made the edit. + * @return User|null null can happen for instance if the username was suppressed. + */ + public function getUser(): ?User { + return $this->user; + } + + /** + * Get the edit summary. + * @return string + */ + public function getComment(): string { + return (string)$this->comment; + } + + /** + * Get the edit summary (alias of Edit::getComment()). + * @return string + */ + public function getSummary(): string { + return $this->getComment(); + } + + /** + * Get the SHA-1 of the revision. + * @return string|null + */ + public function getSha(): ?string { + return $this->sha; + } + + /** + * Was this edit reported as having been reverted? + * The value for this is merely passed in from precomputed data. + * @return bool|null + */ + public function isReverted(): ?bool { + return $this->reverted; + } + + /** + * Set the reverted property. + * @param bool $reverted + */ + public function setReverted( bool $reverted ): void { + $this->reverted = $reverted; + } + + /** + * Get deletion status of the revision. + * @return int + */ + public function getDeleted(): int { + return $this->deleted; + } + + /** + * Was the username deleted from public view? + * @return bool + */ + public function deletedUser(): bool { + return ( $this->deleted & self::DELETED_USER ) > 0; + } + + /** + * Was the edit summary deleted from public view? + * @return bool + */ + public function deletedSummary(): bool { + return ( $this->deleted & self::DELETED_COMMENT ) > 0; + } + + /** + * Get edit summary as 'wikified' HTML markup + * @param bool $useUnnormalizedPageTitle Use the unnormalized page title to avoid + * an API call. This should be used only if you fetched the page title via other + * means (SQL query), and is not from user input alone. + * @return string Safe HTML + */ + public function getWikifiedComment( bool $useUnnormalizedPageTitle = false ): string { + return self::wikifyString( + $this->getSummary(), + $this->getProject(), + $this->page, + $useUnnormalizedPageTitle + ); + } + + /** + * Public static method to wikify a summary, can be used on any arbitrary string. + * Does NOT support section links unless you specify a page. + * @param string $summary + * @param Project $project + * @param Page|null $page + * @param bool $useUnnormalizedPageTitle Use the unnormalized page title to avoid + * an API call. This should be used only if you fetched the page title via other + * means (SQL query), and is not from user input alone. + * @return string + */ + public static function wikifyString( + string $summary, + Project $project, + ?Page $page = null, + bool $useUnnormalizedPageTitle = false + ): string { + // The html_entity_decode makes & and & display the same + // But that is MW behaviour + $summary = htmlspecialchars( html_entity_decode( $summary ), ENT_NOQUOTES ); + + // First link raw URLs. Courtesy of https://stackoverflow.com/a/11641499/604142 + $summary = preg_replace( + '%\b(([\w-]+://?|www[.])[^\s()<>]+(?:\([\w\d]+\)|([^[:punct:]\s]|/)))%s', + '$1', + $summary + ); + + $sectionMatch = null; + $isSection = preg_match_all( "/^\/\* (.*?) \*\//", $summary, $sectionMatch ); + + if ( $isSection && isset( $page ) ) { + $pageUrl = $project->getUrlForPage( $page->getTitle( $useUnnormalizedPageTitle ) ); + $sectionTitle = $sectionMatch[1][0]; + + // Must have underscores for the link to properly go to the section. + // Have to decode twice; once for the entities added with htmlspecialchars; + // And one for user entities (which are decoded in mw section ids). + $sectionTitleLink = html_entity_decode( html_entity_decode( str_replace( ' ', '_', $sectionTitle ) ) ); + + $sectionWikitext = "" . + "" . $sectionTitle . ": "; + $summary = str_replace( $sectionMatch[0][0], $sectionWikitext, $summary ); + } + + $linkMatch = null; + + while ( preg_match_all( "/\[\[:?([^\[\]]*?)]]/", $summary, $linkMatch ) ) { + $wikiLinkParts = explode( '|', $linkMatch[1][0] ); + $wikiLinkPath = htmlspecialchars( $wikiLinkParts[0] ); + $wikiLinkText = htmlspecialchars( + $wikiLinkParts[1] ?? $wikiLinkPath + ); + + // Use normalized page title (underscored, capitalized). + $pageUrl = $project->getUrlForPage( ucfirst( str_replace( ' ', '_', $wikiLinkPath ) ) ); + + $link = "$wikiLinkText"; + $summary = str_replace( $linkMatch[0][0], $link, $summary ); + } + + return $summary; + } + + /** + * Get edit summary as 'wikified' HTML markup (alias of Edit::getWikifiedComment()). + * @return string + */ + public function getWikifiedSummary(): string { + return $this->getWikifiedComment(); + } + + /** + * Get the project this edit was made on + * @return Project + */ + public function getProject(): Project { + return $this->getPage()->getProject(); + } + + /** + * Get the full URL to the diff of the edit + * @return string + */ + public function getDiffUrl(): string { + return rtrim( $this->getProject()->getUrlForPage( 'Special:Diff/' . $this->id ), '/' ); + } + + /** + * Get the full permanent URL to the page at the time of the edit + * @return string + */ + public function getPermaUrl(): string { + return rtrim( $this->getProject()->getUrlForPage( 'Special:PermaLink/' . $this->id ), '/' ); + } + + /** + * Was the edit a revert, based on the edit summary? + * @return bool + */ + public function isRevert(): bool { + return $this->repository->getAutoEditsHelper()->isRevert( $this->comment, $this->getProject() ); + } + + /** + * Get the name of the tool that was used to make this edit. + * @return array|null The name of the tool(s) that was used to make the edit. + */ + public function getTool(): ?array { + return $this->repository->getAutoEditsHelper()->getTool( $this->comment, $this->getProject(), $this->tags ); + } + + /** + * Was the edit (semi-)automated, based on the edit summary? + * @return bool + */ + public function isAutomated(): bool { + return (bool)$this->getTool(); + } + + /** + * Was the edit made by a logged out user (IP or temporary account)? + * @param Project $project + * @return bool|null + */ + public function isAnon( Project $project ): ?bool { + return $this->getUser() ? $this->getUser()->isAnon( $project ) : null; + } + + /** + * List of tag names for the edit. + * Only filled in by PageInfo. + * @return string[] + */ + public function getTags(): array { + return $this->tags; + } + + /** + * Get HTML for the diff of this Edit. + * @return string|null Raw HTML, must be wrapped in a
    tag. Null if no comparison could be made. + */ + public function getDiffHtml(): ?string { + return $this->repository->getDiffHtml( $this ); + } + + /** + * Formats the data as an array for use in JSON APIs. + * @param bool $includeProject + * @return array + * @internal This method assumes the Edit was constructed with data already filled in from a database query. + */ + public function getForJson( bool $includeProject = false ): array { + $nsId = $this->getPage()->getNamespace(); + $pageTitle = $this->getPage()->getTitle( true ); + + if ( $nsId > 0 ) { + $nsName = $this->getProject()->getNamespaces()[$nsId]; + $pageTitle = preg_replace( "/^$nsName:/", '', $pageTitle ); + } + + $ret = [ + 'page_title' => str_replace( '_', ' ', $pageTitle ), + 'namespace' => $nsId, + ]; + if ( $includeProject ) { + $ret += [ 'project' => $this->getProject()->getDomain() ]; + } + if ( $this->getUser() ) { + $ret += [ 'username' => $this->getUser()->getUsername() ]; + } + $ret += [ + 'rev_id' => $this->id, + 'timestamp' => $this->getUTCTimestamp(), + 'minor' => $this->minor, + 'length' => $this->length, + 'length_change' => $this->lengthChange, + 'comment' => $this->comment, + ]; + if ( $this->reverted !== null ) { + $ret['reverted'] = $this->reverted; + } + + return $ret; + } } diff --git a/src/Model/EditCounter.php b/src/Model/EditCounter.php index 614a97b17..0b64dd0fb 100644 --- a/src/Model/EditCounter.php +++ b/src/Model/EditCounter.php @@ -1,6 +1,6 @@ userRights; - } - - /** - * Get revision and page counts etc. - * @return int[] - */ - public function getPairData(): array - { - if (!isset($this->pairData)) { - $this->pairData = $this->repository->getPairData($this->project, $this->user); - } - return $this->pairData; - } - - /** - * Get revision dates. - * @return array - */ - public function getLogCounts(): array - { - if (!isset($this->logCounts)) { - $this->logCounts = $this->repository->getLogCounts($this->project, $this->user); - } - return $this->logCounts; - } - - /** - * Get the IDs and timestamps of the latest edit and logged action. - * @return string[] With keys 'rev_first', 'rev_latest', 'log_latest', each with 'id' and 'timestamp'. - */ - public function getFirstAndLatestActions(): array - { - if (!isset($this->firstAndLatestActions)) { - $this->firstAndLatestActions = $this->repository->getFirstAndLatestActions( - $this->project, - $this->user - ); - } - return $this->firstAndLatestActions; - } - - /** - * Get the number of times the user was thanked. - * @return int - * @codeCoverageIgnore Simply returns the result of an SQL query. - */ - public function getThanksReceived(): int - { - if (!isset($this->thanksReceived)) { - $this->thanksReceived = $this->repository->getThanksReceived($this->project, $this->user); - } - return $this->thanksReceived; - } - - /** - * Get block data. - * @param string $type Either 'set', 'received' - * @param bool $blocksOnly Whether to include only blocks, and not reblocks and unblocks. - * @return array - */ - protected function getBlocks(string $type, bool $blocksOnly = true): array - { - if (isset($this->blocks[$type]) && is_array($this->blocks[$type])) { - return $this->blocks[$type]; - } - $method = "getBlocks".ucfirst($type); - $blocks = $this->repository->$method($this->project, $this->user); - $this->blocks[$type] = $blocks; - - // Filter out unblocks unless requested. - if ($blocksOnly) { - $blocks = array_filter($blocks, function ($block) { - return ('block' === $block['log_action'] || 'reblock' == $block['log_action']); - }); - } - - return $blocks; - } - - /** - * Get the total number of currently-live revisions. - * @return int - */ - public function countLiveRevisions(): int - { - $revCounts = $this->getPairData(); - return $revCounts['live'] ?? 0; - } - - /** - * Get the total number of the user's revisions that have been deleted. - * @return int - */ - public function countDeletedRevisions(): int - { - $revCounts = $this->getPairData(); - return $revCounts['deleted'] ?? 0; - } - - /** - * Get the total edit count (live + deleted). - * @return int - */ - public function countAllRevisions(): int - { - return $this->countLiveRevisions() + $this->countDeletedRevisions(); - } - - /** - * Get the total number of revisions marked as 'minor' by the user. - * @return int - */ - public function countMinorRevisions(): int - { - $revCounts = $this->getPairData(); - return $revCounts['minor'] ?? 0; - } - - /** - * Get the total number of non-deleted pages edited by the user. - * @return int - */ - public function countLivePagesEdited(): int - { - $pageCounts = $this->getPairData(); - return $pageCounts['edited-live'] ?? 0; - } - - /** - * Get the total number of deleted pages ever edited by the user. - * @return int - */ - public function countDeletedPagesEdited(): int - { - $pageCounts = $this->getPairData(); - return $pageCounts['edited-deleted'] ?? 0; - } - - /** - * Get the total number of pages ever edited by this user (both live and deleted). - * @return int - */ - public function countAllPagesEdited(): int - { - return $this->countLivePagesEdited() + $this->countDeletedPagesEdited(); - } - - /** - * Get the total number of pages (both still live and those that have been deleted) created - * by the user. - * @return int - */ - public function countPagesCreated(): int - { - return $this->countCreatedPagesLive() + $this->countPagesCreatedDeleted(); - } - - /** - * Get the total number of pages created by the user, that have not been deleted. - * @return int - */ - public function countCreatedPagesLive(): int - { - $pageCounts = $this->getPairData(); - return $pageCounts['created-live'] ?? 0; - } - - /** - * Get the total number of pages created by the user, that have since been deleted. - * @return int - */ - public function countPagesCreatedDeleted(): int - { - $pageCounts = $this->getPairData(); - return $pageCounts['created-deleted'] ?? 0; - } - - /** - * Get the total number of pages that have been deleted by the user. - * @return int - */ - public function countPagesDeleted(): int - { - $logCounts = $this->getLogCounts(); - return $logCounts['delete-delete'] ?? 0; - } - - /** - * Get the total number of pages moved by the user. - * @return int - */ - public function countPagesMoved(): int - { - $logCounts = $this->getLogCounts(); - return ($logCounts['move-move'] ?? 0) + - ($logCounts['move-move_redir'] ?? 0); - } - - /** - * Get the total number of times the user has blocked a user. - * @return int - */ - public function countBlocksSet(): int - { - $logCounts = $this->getLogCounts(); - return $logCounts['block-block'] ?? 0; - } - - /** - * Get the total number of times the user has re-blocked a user. - * @return int - */ - public function countReblocksSet(): int - { - $logCounts = $this->getLogCounts(); - return $logCounts['block-reblock'] ?? 0; - } - - /** - * Get the total number of times the user has unblocked a user. - * @return int - */ - public function countUnblocksSet(): int - { - $logCounts = $this->getLogCounts(); - return $logCounts['block-unblock'] ?? 0; - } - - /** - * Get the total number of times the user has been blocked. - * @return int - */ - public function countBlocksReceived(): int - { - $blocks = $this->getBlocks('received'); - return count($blocks); - } - - /** - * Get the length of the longest block the user received, in seconds. - * If the user is blocked, the time since the block is returned. If the block is - * indefinite, -1 is returned. 0 if there was never a block. - * @return int|false Number of seconds or false if it could not be determined. - */ - public function getLongestBlockSeconds() - { - if (isset($this->longestBlockSeconds)) { - return $this->longestBlockSeconds; - } - - $blocks = $this->getBlocks('received', false); - $this->longestBlockSeconds = false; - - // If there was never a block, the longest was zero seconds. - if (empty($blocks)) { - return 0; - } - - /** - * Keep track of the last block so we can determine the duration - * if the current block in the loop is an unblock. - * @var int[] $lastBlock - * [ - * Unix timestamp, - * Duration in seconds (-1 if indefinite) - * ] - */ - $lastBlock = [null, null]; - - foreach (array_values($blocks) as $block) { - [$timestamp, $duration] = $this->parseBlockLogEntry($block); - - if ('block' === $block['log_action']) { - // This is a new block, so first see if the duration of the last - // block exceeded our longest duration. -1 duration means indefinite. - if ($lastBlock[1] > $this->longestBlockSeconds || -1 === $lastBlock[1]) { - $this->longestBlockSeconds = $lastBlock[1]; - } - - // Now set this as the last block. - $lastBlock = [$timestamp, $duration]; - } elseif ('unblock' === $block['log_action']) { - // The last block was lifted. So the duration will be the time from when the - // last block was set to the time of the unblock. - $timeSinceLastBlock = $timestamp - $lastBlock[0]; - if ($timeSinceLastBlock > $this->longestBlockSeconds) { - $this->longestBlockSeconds = $timeSinceLastBlock; - - // Reset the last block, as it has now been accounted for. - $lastBlock = [null, null]; - } - } elseif ('reblock' === $block['log_action'] && -1 !== $lastBlock[1]) { - // The last block was modified. - // $lastBlock is left unchanged if its duration was indefinite. - - // If this reblock set the block to infinite, set lastBlock manually to infinite - if (-1 === $duration) { - $lastBlock[1] = -1; - // Otherwise, we will adjust $lastBlock to include - // the difference of the duration of the new reblock, and time since the last block. - // we can't use this when $duration === -1. - } else { - $timeSinceLastBlock = $timestamp - $lastBlock[0]; - $lastBlock[1] = $timeSinceLastBlock + $duration; - } - } - } - - // If the last block was indefinite, we'll return that as the longest duration. - if (-1 === $lastBlock[1]) { - return -1; - } - - // Test if the last block is still active, and if so use the expiry as the duration. - $lastBlockExpiry = $lastBlock[0] + $lastBlock[1]; - if ($lastBlockExpiry > time() && $lastBlockExpiry > $this->longestBlockSeconds) { - $this->longestBlockSeconds = $lastBlock[1]; - // Otherwise, test if the duration of the last block is now the longest overall. - } elseif ($lastBlock[1] > $this->longestBlockSeconds) { - $this->longestBlockSeconds = $lastBlock[1]; - } - - return $this->longestBlockSeconds; - } - - /** - * Given a block log entry from the database, get the timestamp and duration in seconds. - * @param array $block Block log entry as fetched via self::getBlocks() - * @return int[] [ - * Unix timestamp, - * Duration in seconds (-1 if indefinite, null if unparsable or unblock) - * ] - */ - public function parseBlockLogEntry(array $block): array - { - $timestamp = strtotime($block['log_timestamp']); - $duration = null; - - // log_params may be null, but we need to treat it like a string. - $block['log_params'] = (string)$block['log_params']; - - // First check if the string is serialized, and if so parse it to get the block duration. - if (false !== @unserialize($block['log_params'])) { - $parsedParams = unserialize($block['log_params']); - $durationStr = $parsedParams['5::duration'] ?? ''; - } else { - // Old format, the duration in English + block options separated by new lines. - $durationStr = explode("\n", $block['log_params'])[0]; - } - - if (in_array($durationStr, ['indefinite', 'infinity', 'infinite'])) { - $duration = -1; - } - - // Make sure $durationStr is valid just in case it is in an older, unpredictable format. - // If invalid, $duration is left as null. - if (strtotime($durationStr)) { - $expiry = strtotime($durationStr, $timestamp); - $duration = $expiry - $timestamp; - } - - return [$timestamp, $duration]; - } - - /** - * Get the total number of pages protected by the user. - * @return int - */ - public function countPagesProtected(): int - { - $logCounts = $this->getLogCounts(); - return ($logCounts['protect-protect'] ?? 0) - + ($logCounts['stable-config'] ?? 0); - } - - /** - * Get the total number of pages reprotected by the user. - * @return int - */ - public function countPagesReprotected(): int - { - $logCounts = $this->getLogCounts(); - return ($logCounts['protect-modify'] ?? 0) - + ($logCounts['stable-modify'] ?? 0); - } - - /** - * Get the total number of pages unprotected by the user. - * @return int - */ - public function countPagesUnprotected(): int - { - $logCounts = $this->getLogCounts(); - return ($logCounts['protect-unprotect'] ?? 0) - + ($logCounts['stable-reset'] ?? 0); - } - - /** - * Get the total number of edits deleted by the user. - * @return int - */ - public function countEditsDeleted(): int - { - $logCounts = $this->getLogCounts(); - return $logCounts['delete-revision'] ?? 0; - } - - /** - * Get the total number of log entries deleted by the user. - * @return int - */ - public function countLogsDeleted(): int - { - $revCounts = $this->getLogCounts(); - return $revCounts['delete-event'] ?? 0; - } - - /** - * Get the total number of pages restored by the user. - * @return int - */ - public function countPagesRestored(): int - { - $logCounts = $this->getLogCounts(); - return $logCounts['delete-restore'] ?? 0; - } - - /** - * Get the total number of times the user has modified the rights of a user. - * @return int - */ - public function countRightsModified(): int - { - $logCounts = $this->getLogCounts(); - return $logCounts['rights-rights'] ?? 0; - } - - /** - * Get the total number of pages imported by the user (through any import mechanism: - * interwiki, or XML upload). - * @return int - */ - public function countPagesImported(): int - { - $logCounts = $this->getLogCounts(); - $import = $logCounts['import-import'] ?? 0; - $interwiki = $logCounts['import-interwiki'] ?? 0; - $upload = $logCounts['import-upload'] ?? 0; - return $import + $interwiki + $upload; - } - - /** - * Get the number of changes the user has made to AbuseFilters. - * @return int - */ - public function countAbuseFilterChanges(): int - { - $logCounts = $this->getLogCounts(); - return $logCounts['abusefilter-modify'] ?? 0; - } - - /** - * Get the number of page content model changes made by the user. - * @return int - */ - public function countContentModelChanges(): int - { - $logCounts = $this->getLogCounts(); - $new = $logCounts['contentmodel-new'] ?? 0; - $modified = $logCounts['contentmodel-change'] ?? 0; - return $new + $modified; - } - - /** - * Get the average number of edits per page (including deleted revisions and pages). - * @return float - */ - public function averageRevisionsPerPage(): float - { - if (0 == $this->countAllPagesEdited()) { - return 0; - } - return round($this->countAllRevisions() / $this->countAllPagesEdited(), 3); - } - - /** - * Average number of edits made per day. - * @return float - */ - public function averageRevisionsPerDay(): float - { - if (0 == $this->getDays()) { - return 0; - } - return round($this->countAllRevisions() / $this->getDays(), 3); - } - - /** - * Get the total number of edits made by the user with semi-automating tools. - */ - public function countAutomatedEdits(): int - { - if ($this->autoEditCount) { - return $this->autoEditCount; - } - $this->autoEditCount = $this->repository->countAutomatedEdits($this->project, $this->user); - return $this->autoEditCount; - } - - /** - * Get the count of (non-deleted) edits made in the given timeframe to now. - * @param string $time One of 'day', 'week', 'month', or 'year'. - * @return int The total number of live edits. - */ - public function countRevisionsInLast(string $time): int - { - $revCounts = $this->getPairData(); - return $revCounts[$time] ?? 0; - } - - /** - * Get the number of days between the first and last edits. - * If there's only one edit, this is counted as one day. - * @return int - */ - public function getDays(): int - { - $first = isset($this->getFirstAndLatestActions()['rev_first']['timestamp']) - ? new DateTime($this->getFirstAndLatestActions()['rev_first']['timestamp']) - : false; - $latest = isset($this->getFirstAndLatestActions()['rev_latest']['timestamp']) - ? new DateTime($this->getFirstAndLatestActions()['rev_latest']['timestamp']) - : false; - - if (false === $first || false === $latest) { - return 0; - } - - $days = $latest->diff($first)->days; - - return $days > 0 ? $days : 1; - } - - /** - * Get the total number of files uploaded (including those now deleted). - * @return int - */ - public function countFilesUploaded(): int - { - $logCounts = $this->getLogCounts(); - return $logCounts['upload-upload'] ?: 0; - } - - /** - * Get the total number of files uploaded to Commons (including those now deleted). - * This is only applicable for WMF labs installations. - * @return int - */ - public function countFilesUploadedCommons(): int - { - $fileCounts = $this->repository->getFileCounts($this->project, $this->user); - return $fileCounts['files_uploaded_commons'] ?? 0; - } - - /** - * Get the total number of files that were renamed (including those now deleted). - */ - public function countFilesMoved(): int - { - $fileCounts = $this->repository->getFileCounts($this->project, $this->user); - return $fileCounts['files_moved'] ?? 0; - } - - /** - * Get the total number of files that were renamed on Commons (including those now deleted). - */ - public function countFilesMovedCommons(): int - { - $fileCounts = $this->repository->getFileCounts($this->project, $this->user); - return $fileCounts['files_moved_commons'] ?? 0; - } - - /** - * Get the total number of revisions the user has sent thanks for. - * @return int - */ - public function thanks(): int - { - $logCounts = $this->getLogCounts(); - return $logCounts['thanks-thank'] ?: 0; - } - - /** - * Get the total number of approvals - * @return int - */ - public function approvals(): int - { - $logCounts = $this->getLogCounts(); - return (!empty($logCounts['review-approve']) ? $logCounts['review-approve'] : 0) + - (!empty($logCounts['review-approve2']) ? $logCounts['review-approve2'] : 0) + - (!empty($logCounts['review-approve-i']) ? $logCounts['review-approve-i'] : 0) + - (!empty($logCounts['review-approve2-i']) ? $logCounts['review-approve2-i'] : 0); - } - - /** - * Get the total number of patrols performed by the user. - * @return int - */ - public function patrols(): int - { - $logCounts = $this->getLogCounts(); - return $logCounts['patrol-patrol'] ?: 0; - } - - /** - * Get the total number of PageCurations reviews performed by the user. - * (Only exists on English Wikipedia.) - * @return int - */ - public function reviews(): int - { - $logCounts = $this->getLogCounts(); - $reviewed = $logCounts['pagetriage-curation-reviewed'] ?: 0; - $reviewedRedirect = $logCounts['pagetriage-curation-reviewed-redirect'] ?: 0; - $reviewedArticle = $logCounts['pagetriage-curation-reviewed-article'] ?: 0; - return ($reviewed + $reviewedRedirect + $reviewedArticle); - } - - /** - * Get the total number of accounts created by the user. - * @return int - */ - public function accountsCreated(): int - { - $logCounts = $this->getLogCounts(); - $create2 = $logCounts['newusers-create2'] ?: 0; - $byemail = $logCounts['newusers-byemail'] ?: 0; - return $create2 + $byemail; - } - - /** - * Get the number of history merges performed by the user. - * @return int - */ - public function merges(): int - { - $logCounts = $this->getLogCounts(); - return $logCounts['merge-merge']; - } - - /** - * Get the given user's total edit counts per namespace. - * @return array Array keys are namespace IDs, values are the edit counts. - */ - public function namespaceTotals(): array - { - if (isset($this->namespaceTotals)) { - return $this->namespaceTotals; - } - $counts = $this->repository->getNamespaceTotals($this->project, $this->user); - arsort($counts); - $this->namespaceTotals = $counts; - return $counts; - } - - /** - * Get the total number of live edits by summing the namespace totals. - * This is used in the view for namespace totals so we don't unnecessarily run the self::getPairData() query. - * @return int - */ - public function liveRevisionsFromNamespaces(): int - { - return array_sum($this->namespaceTotals()); - } - - /** - * Get a summary of the times of day and the days of the week that the user has edited. - * @return string[] - */ - public function timeCard(): array - { - if (isset($this->timeCardData)) { - return $this->timeCardData; - } - $totals = $this->repository->getTimeCard($this->project, $this->user); - - // Scale the radii: get the max, then scale each radius. - // This looks inefficient, but there's a max of 72 elements in this array. - $max = 0; - foreach ($totals as $total) { - $max = max($max, $total['value']); - } - foreach ($totals as &$total) { - $total['scale'] = round(($total['value'] / $max) * 20); - } - - // Fill in zeros for timeslots that have no values. - $sortedTotals = []; - $index = 0; - $sortedIndex = 0; - foreach (range(1, 7) as $day) { - foreach (range(0, 23) as $hour) { - if (isset($totals[$index]) && (int)$totals[$index]['hour'] === $hour) { - $sortedTotals[$sortedIndex] = $totals[$index]; - $index++; - } else { - $sortedTotals[$sortedIndex] = [ - 'day_of_week' => $day, - 'hour' => $hour, - 'value' => 0, - ]; - } - $sortedIndex++; - } - } - - $this->timeCardData = $sortedTotals; - return $sortedTotals; - } - - /** - * Get the total numbers of edits per month. - * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING* so we can mock the current DateTime. - * @return array With keys 'yearLabels', 'monthLabels' and 'totals', - * the latter keyed by namespace, then year/month. - */ - public function monthCounts(?DateTime $currentTime = null): array - { - if (isset($this->monthCounts)) { - return $this->monthCounts; - } - - // Set to current month if we're not unit-testing - if (!($currentTime instanceof DateTime)) { - $currentTime = new DateTime('last day of this month'); - } - - $totals = $this->repository->getMonthCounts($this->project, $this->user); - $out = [ - 'yearLabels' => [], // labels for years - 'monthLabels' => [], // labels for months - 'totals' => [], // actual totals, grouped by namespace, year and then month - ]; - - /** Keep track of the date of their first edit. */ - $firstEdit = new DateTime(); - - [$out, $firstEdit] = $this->fillInMonthCounts($out, $totals, $firstEdit); - - $dateRange = new DatePeriod( - $firstEdit, - new DateInterval('P1M'), - $currentTime->modify('first day of this month') - ); - - $out = $this->fillInMonthTotalsAndLabels($out, $dateRange); - - // One more loop to sort by year/month - foreach (array_keys($out['totals']) as $nsId) { - ksort($out['totals'][$nsId]); - } - - // Finally, sort the namespaces - ksort($out['totals']); - - $this->monthCounts = $out; - return $out; - } - - /** - * Get the counts keyed by month and then namespace. - * Basically the opposite of self::monthCounts()['totals']. - * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING* so we can mock the current DateTime. - * @return array Months as keys, values are counts keyed by namesapce. - * @fixme Create API for this! - */ - public function monthCountsWithNamespaces(?DateTime $currentTime = null): array - { - $countsMonthNamespace = array_fill_keys( - array_values($this->monthCounts($currentTime)['monthLabels']), - [] - ); - - foreach ($this->monthCounts($currentTime)['totals'] as $ns => $months) { - foreach ($months as $month => $count) { - $countsMonthNamespace[$month][$ns] = $count; - } - } - - return $countsMonthNamespace; - } - - /** - * Loop through the database results and fill in the values - * for the months that we have data for. - * @param array $out - * @param array $totals - * @param DateTime $firstEdit - * @return array [ - * string[] - Modified $out filled with month stats, - * DateTime - timestamp of first edit - * ] - * Tests covered in self::monthCounts(). - * @codeCoverageIgnore - */ - private function fillInMonthCounts(array $out, array $totals, DateTime $firstEdit): array - { - foreach ($totals as $total) { - // Keep track of first edit - $date = new DateTime($total['year'].'-'.$total['month'].'-01'); - if ($date < $firstEdit) { - $firstEdit = $date; - } - - // Collate the counts by namespace, and then YYYY-MM. - $ns = $total['namespace']; - $out['totals'][$ns][$date->format('Y-m')] = (int)$total['count']; - } - - return [$out, $firstEdit]; - } - - /** - * Given the output array, fill each month's totals and labels. - * @param array $out - * @param DatePeriod $dateRange From first edit to present. - * @return array Modified $out filled with month stats. - * Tests covered in self::monthCounts(). - * @codeCoverageIgnore - */ - private function fillInMonthTotalsAndLabels(array $out, DatePeriod $dateRange): array - { - foreach ($dateRange as $monthObj) { - $yearLabel = $monthObj->format('Y'); - $monthLabel = $monthObj->format('Y-m'); - - // Fill in labels - $out['monthLabels'][] = $monthLabel; - if (!in_array($yearLabel, $out['yearLabels'])) { - $out['yearLabels'][] = $yearLabel; - } - - foreach (array_keys($out['totals']) as $nsId) { - if (!isset($out['totals'][$nsId][$monthLabel])) { - $out['totals'][$nsId][$monthLabel] = 0; - } - } - } - - return $out; - } - - /** - * Get the total numbers of edits per year. - * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING* so we can mock the current DateTime. - * @return array With keys 'yearLabels' and 'totals', the latter keyed by namespace then year. - */ - public function yearCounts(?DateTime $currentTime = null): array - { - if (isset($this->yearCounts)) { - return $this->yearCounts; - } - - $monthCounts = $this->monthCounts($currentTime); - $yearCounts = [ - 'yearLabels' => $monthCounts['yearLabels'], - 'totals' => [], - ]; - - foreach ($monthCounts['totals'] as $nsId => $months) { - foreach ($months as $month => $count) { - $year = substr($month, 0, 4); - if (!isset($yearCounts['totals'][$nsId][$year])) { - $yearCounts['totals'][$nsId][$year] = 0; - } - $yearCounts['totals'][$nsId][$year] += $count; - } - } - - $this->yearCounts = $yearCounts; - return $yearCounts; - } - - /** - * Get the counts keyed by year and then namespace. - * Basically the opposite of self::yearCounts()['totals']. - * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING* - * so we can mock the current DateTime. - * @return array Years as keys, values are counts keyed by namesapce. - */ - public function yearCountsWithNamespaces(?DateTime $currentTime = null): array - { - $countsYearNamespace = array_fill_keys( - array_keys($this->yearTotals($currentTime)), - [] - ); - - foreach ($this->yearCounts($currentTime)['totals'] as $ns => $years) { - foreach ($years as $year => $count) { - $countsYearNamespace[$year][$ns] = $count; - } - } - - return $countsYearNamespace; - } - - /** - * Get total edits for each year. Used in wikitext export. - * @param null|DateTime $currentTime *USED ONLY FOR UNIT TESTING* - * @return array With the years as the keys, counts as the values. - */ - public function yearTotals(?DateTime $currentTime = null): array - { - $years = []; - - foreach ($this->yearCounts($currentTime)['totals'] as $nsData) { - foreach ($nsData as $year => $count) { - if (!isset($years[$year])) { - $years[$year] = 0; - } - $years[$year] += $count; - } - } - - return $years; - } - - /** - * Get average edit size, and number of large and small edits. - * @return array - */ - public function getEditSizeData(): array - { - if (!isset($this->editSizeData)) { - $this->editSizeData = $this->repository - ->getEditSizeData($this->project, $this->user); - } - return $this->editSizeData; - } - - /** - * Get the total edit count of this user or 5,000 if they've made more than 5,000 edits. - * This is used to ensure percentages of small and large edits are computed properly. - * @return int - */ - public function countLast5000(): int - { - return $this->countLiveRevisions() > 5000 ? 5000 : $this->countLiveRevisions(); - } - - /** - * Get the number of edits under 20 bytes of the user's past 5000 edits. - * @return int - */ - public function countSmallEdits(): int - { - $editSizeData = $this->getEditSizeData(); - return isset($editSizeData['small_edits']) ? (int) $editSizeData['small_edits'] : 0; - } - - /** - * Get the total number of edits over 1000 bytes of the user's past 5000 edits. - * @return int - */ - public function countLargeEdits(): int - { - $editSizeData = $this->getEditSizeData(); - return isset($editSizeData['large_edits']) ? (int) $editSizeData['large_edits'] : 0; - } - - /** - * Get the number of edits that have automated tags in the user's past 5000 edits. - * @return int - */ - public function countAutoEdits(): int - { - $editSizeData = $this->getEditSizeData(); - if (!isset($editSizeData['tag_lists'])) { - return 0; - } - $tags = json_decode($editSizeData['tag_lists']); - $autoTags = $this->autoEditsHelper->getTags($this->project); - return count( // Number - array_filter( - $tags, // of revisions - fn($a) => null !== $a && // with tags - count( // where the number of tags - array_filter( - $a, - fn($t) => in_array($t, $autoTags) // that mean these edits are auto - ) - ) > 0 // is greater than 0 - ) - ); - } - - /** - * Get the average size of the user's past 5000 edits. - * @return float Size in bytes. - */ - public function averageEditSize(): float - { - $editSizeData = $this->getEditSizeData(); - if (isset($editSizeData['average_size'])) { - return round((float)$editSizeData['average_size'], 3); - } else { - return 0; - } - } +class EditCounter extends Model { + /** @var int[] Revision and page counts etc. */ + protected array $pairData; + + /** @var string[] The IDs and timestamps of first/latest edit and logged action. */ + protected array $firstAndLatestActions; + + /** @var int[] The lot totals. */ + protected array $logCounts; + + /** @var array Total numbers of edits per month */ + protected array $monthCounts; + + /** @var array Total numbers of edits per year */ + protected array $yearCounts; + + /** @var array Block data, with keys 'set' and 'received'. */ + protected array $blocks; + + /** @var int[] Array keys are namespace IDs, values are the edit counts. */ + protected array $namespaceTotals; + + /** @var int Number of semi-automated edits. */ + protected int $autoEditCount; + + /** @var string[] Data needed for time card chart. */ + protected array $timeCardData; + + /** + * Revision size data, with keys 'average_size', 'large_edits' and 'small_edits'. + * @var string[] As returned by the DB, unconverted to int or float + */ + protected array $editSizeData; + + /** + * Duration of the longest block in seconds; -1 if indefinite, + * or false if could not be parsed from log params + * @var int|bool + */ + protected int|bool $longestBlockSeconds; + + /** @var int Number of times the user has been thanked. */ + protected int $thanksReceived; + + /** + * EditCounter constructor. + * @param Repository|EditCounterRepository $repository + * @param I18nHelper $i18n + * @param UserRights $userRights + * @param Project $project The base project to count edits + * @param ?User $user + * @param ?AutomatedEditsHelper $autoEditsHelper + */ + public function __construct( + protected Repository|EditCounterRepository $repository, + protected I18nHelper $i18n, + protected UserRights $userRights, + protected Project $project, + protected ?User $user, + protected ?AutomatedEditsHelper $autoEditsHelper + ) { + } + + /** + * @return UserRights + */ + public function getUserRights(): UserRights { + return $this->userRights; + } + + /** + * Get revision and page counts etc. + * @return int[] + */ + public function getPairData(): array { + if ( !isset( $this->pairData ) ) { + $this->pairData = $this->repository->getPairData( $this->project, $this->user ); + } + return $this->pairData; + } + + /** + * Get revision dates. + * @return array + */ + public function getLogCounts(): array { + if ( !isset( $this->logCounts ) ) { + $this->logCounts = $this->repository->getLogCounts( $this->project, $this->user ); + } + return $this->logCounts; + } + + /** + * Get the IDs and timestamps of the latest edit and logged action. + * @return string[] With keys 'rev_first', 'rev_latest', 'log_latest', each with 'id' and 'timestamp'. + */ + public function getFirstAndLatestActions(): array { + if ( !isset( $this->firstAndLatestActions ) ) { + $this->firstAndLatestActions = $this->repository->getFirstAndLatestActions( + $this->project, + $this->user + ); + } + return $this->firstAndLatestActions; + } + + /** + * Get the number of times the user was thanked. + * @return int + * @codeCoverageIgnore Simply returns the result of an SQL query. + */ + public function getThanksReceived(): int { + if ( !isset( $this->thanksReceived ) ) { + $this->thanksReceived = $this->repository->getThanksReceived( $this->project, $this->user ); + } + return $this->thanksReceived; + } + + /** + * Get block data. + * @param string $type Either 'set', 'received' + * @param bool $blocksOnly Whether to include only blocks, and not reblocks and unblocks. + * @return array + */ + protected function getBlocks( string $type, bool $blocksOnly = true ): array { + if ( isset( $this->blocks[$type] ) && is_array( $this->blocks[$type] ) ) { + return $this->blocks[$type]; + } + $method = "getBlocks" . ucfirst( $type ); + $blocks = $this->repository->$method( $this->project, $this->user ); + $this->blocks[$type] = $blocks; + + // Filter out unblocks unless requested. + if ( $blocksOnly ) { + $blocks = array_filter( $blocks, static function ( $block ) { + return $block['log_action'] === 'block' || $block['log_action'] === 'reblock'; + } ); + } + + return $blocks; + } + + /** + * Get the total number of currently-live revisions. + * @return int + */ + public function countLiveRevisions(): int { + $revCounts = $this->getPairData(); + return $revCounts['live'] ?? 0; + } + + /** + * Get the total number of the user's revisions that have been deleted. + * @return int + */ + public function countDeletedRevisions(): int { + $revCounts = $this->getPairData(); + return $revCounts['deleted'] ?? 0; + } + + /** + * Get the total edit count (live + deleted). + * @return int + */ + public function countAllRevisions(): int { + return $this->countLiveRevisions() + $this->countDeletedRevisions(); + } + + /** + * Get the total number of revisions marked as 'minor' by the user. + * @return int + */ + public function countMinorRevisions(): int { + $revCounts = $this->getPairData(); + return $revCounts['minor'] ?? 0; + } + + /** + * Get the total number of non-deleted pages edited by the user. + * @return int + */ + public function countLivePagesEdited(): int { + $pageCounts = $this->getPairData(); + return $pageCounts['edited-live'] ?? 0; + } + + /** + * Get the total number of deleted pages ever edited by the user. + * @return int + */ + public function countDeletedPagesEdited(): int { + $pageCounts = $this->getPairData(); + return $pageCounts['edited-deleted'] ?? 0; + } + + /** + * Get the total number of pages ever edited by this user (both live and deleted). + * @return int + */ + public function countAllPagesEdited(): int { + return $this->countLivePagesEdited() + $this->countDeletedPagesEdited(); + } + + /** + * Get the total number of pages (both still live and those that have been deleted) created + * by the user. + * @return int + */ + public function countPagesCreated(): int { + return $this->countCreatedPagesLive() + $this->countPagesCreatedDeleted(); + } + + /** + * Get the total number of pages created by the user, that have not been deleted. + * @return int + */ + public function countCreatedPagesLive(): int { + $pageCounts = $this->getPairData(); + return $pageCounts['created-live'] ?? 0; + } + + /** + * Get the total number of pages created by the user, that have since been deleted. + * @return int + */ + public function countPagesCreatedDeleted(): int { + $pageCounts = $this->getPairData(); + return $pageCounts['created-deleted'] ?? 0; + } + + /** + * Get the total number of pages that have been deleted by the user. + * @return int + */ + public function countPagesDeleted(): int { + $logCounts = $this->getLogCounts(); + return $logCounts['delete-delete'] ?? 0; + } + + /** + * Get the total number of pages moved by the user. + * @return int + */ + public function countPagesMoved(): int { + $logCounts = $this->getLogCounts(); + return ( $logCounts['move-move'] ?? 0 ) + + ( $logCounts['move-move_redir'] ?? 0 ); + } + + /** + * Get the total number of times the user has blocked a user. + * @return int + */ + public function countBlocksSet(): int { + $logCounts = $this->getLogCounts(); + return $logCounts['block-block'] ?? 0; + } + + /** + * Get the total number of times the user has re-blocked a user. + * @return int + */ + public function countReblocksSet(): int { + $logCounts = $this->getLogCounts(); + return $logCounts['block-reblock'] ?? 0; + } + + /** + * Get the total number of times the user has unblocked a user. + * @return int + */ + public function countUnblocksSet(): int { + $logCounts = $this->getLogCounts(); + return $logCounts['block-unblock'] ?? 0; + } + + /** + * Get the total number of times the user has been blocked. + * @return int + */ + public function countBlocksReceived(): int { + $blocks = $this->getBlocks( 'received' ); + return count( $blocks ); + } + + /** + * Get the length of the longest block the user received, in seconds. + * If the user is blocked, the time since the block is returned. If the block is + * indefinite, -1 is returned. 0 if there was never a block. + * @return int|false Number of seconds or false if it could not be determined. + */ + public function getLongestBlockSeconds() { + if ( isset( $this->longestBlockSeconds ) ) { + return $this->longestBlockSeconds; + } + + $blocks = $this->getBlocks( 'received', false ); + $this->longestBlockSeconds = false; + + // If there was never a block, the longest was zero seconds. + if ( empty( $blocks ) ) { + return 0; + } + + /** + * Keep track of the last block so we can determine the duration + * if the current block in the loop is an unblock. + * @var int[] $lastBlock + * [ + * Unix timestamp, + * Duration in seconds (-1 if indefinite) + * ] + */ + $lastBlock = [ null, null ]; + + foreach ( array_values( $blocks ) as $block ) { + [ $timestamp, $duration ] = $this->parseBlockLogEntry( $block ); + + if ( $block['log_action'] === 'block' ) { + // This is a new block, so first see if the duration of the last + // block exceeded our longest duration. -1 duration means indefinite. + if ( $lastBlock[1] > $this->longestBlockSeconds || -1 === $lastBlock[1] ) { + $this->longestBlockSeconds = $lastBlock[1]; + } + + // Now set this as the last block. + $lastBlock = [ $timestamp, $duration ]; + } elseif ( $block['log_action'] === 'unblock' ) { + // The last block was lifted. So the duration will be the time from when the + // last block was set to the time of the unblock. + $timeSinceLastBlock = $timestamp - $lastBlock[0]; + if ( $timeSinceLastBlock > $this->longestBlockSeconds ) { + $this->longestBlockSeconds = $timeSinceLastBlock; + + // Reset the last block, as it has now been accounted for. + $lastBlock = [ null, null ]; + } + } elseif ( $block['log_action'] === 'reblock' && -1 !== $lastBlock[1] ) { + // The last block was modified. + // $lastBlock is left unchanged if its duration was indefinite. + + // If this reblock set the block to infinite, set lastBlock manually to infinite + if ( -1 === $duration ) { + $lastBlock[1] = -1; + // Otherwise, we will adjust $lastBlock to include + // the difference of the duration of the new reblock, and time since the last block. + // we can't use this when $duration === -1. + } else { + $timeSinceLastBlock = $timestamp - $lastBlock[0]; + $lastBlock[1] = $timeSinceLastBlock + $duration; + } + } + } + + // If the last block was indefinite, we'll return that as the longest duration. + if ( -1 === $lastBlock[1] ) { + return -1; + } + + // Test if the last block is still active, and if so use the expiry as the duration. + $lastBlockExpiry = $lastBlock[0] + $lastBlock[1]; + if ( $lastBlockExpiry > time() && $lastBlockExpiry > $this->longestBlockSeconds ) { + $this->longestBlockSeconds = $lastBlock[1]; + // Otherwise, test if the duration of the last block is now the longest overall. + } elseif ( $lastBlock[1] > $this->longestBlockSeconds ) { + $this->longestBlockSeconds = $lastBlock[1]; + } + + return $this->longestBlockSeconds; + } + + /** + * Given a block log entry from the database, get the timestamp and duration in seconds. + * @param array $block Block log entry as fetched via self::getBlocks() + * @return int[] [ + * Unix timestamp, + * Duration in seconds (-1 if indefinite, null if unparsable or unblock) + * ] + */ + public function parseBlockLogEntry( array $block ): array { + $timestamp = strtotime( $block['log_timestamp'] ); + $duration = null; + + // log_params may be null, but we need to treat it like a string. + $block['log_params'] = (string)$block['log_params']; + + // First check if the string is serialized, and if so parse it to get the block duration. + // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged + if ( @unserialize( $block['log_params'] ) !== false ) { + $parsedParams = unserialize( $block['log_params'] ); + $durationStr = $parsedParams['5::duration'] ?? ''; + } else { + // Old format, the duration in English + block options separated by new lines. + $durationStr = explode( "\n", $block['log_params'] )[0]; + } + + if ( in_array( $durationStr, [ 'indefinite', 'infinity', 'infinite' ] ) ) { + $duration = -1; + } + + // Make sure $durationStr is valid just in case it is in an older, unpredictable format. + // If invalid, $duration is left as null. + if ( strtotime( $durationStr ) ) { + $expiry = strtotime( $durationStr, $timestamp ); + $duration = $expiry - $timestamp; + } + + return [ $timestamp, $duration ]; + } + + /** + * Get the total number of pages protected by the user. + * @return int + */ + public function countPagesProtected(): int { + $logCounts = $this->getLogCounts(); + return ( $logCounts['protect-protect'] ?? 0 ) + + ( $logCounts['stable-config'] ?? 0 ); + } + + /** + * Get the total number of pages reprotected by the user. + * @return int + */ + public function countPagesReprotected(): int { + $logCounts = $this->getLogCounts(); + return ( $logCounts['protect-modify'] ?? 0 ) + + ( $logCounts['stable-modify'] ?? 0 ); + } + + /** + * Get the total number of pages unprotected by the user. + * @return int + */ + public function countPagesUnprotected(): int { + $logCounts = $this->getLogCounts(); + return ( $logCounts['protect-unprotect'] ?? 0 ) + + ( $logCounts['stable-reset'] ?? 0 ); + } + + /** + * Get the total number of edits deleted by the user. + * @return int + */ + public function countEditsDeleted(): int { + $logCounts = $this->getLogCounts(); + return $logCounts['delete-revision'] ?? 0; + } + + /** + * Get the total number of log entries deleted by the user. + * @return int + */ + public function countLogsDeleted(): int { + $revCounts = $this->getLogCounts(); + return $revCounts['delete-event'] ?? 0; + } + + /** + * Get the total number of pages restored by the user. + * @return int + */ + public function countPagesRestored(): int { + $logCounts = $this->getLogCounts(); + return $logCounts['delete-restore'] ?? 0; + } + + /** + * Get the total number of times the user has modified the rights of a user. + * @return int + */ + public function countRightsModified(): int { + $logCounts = $this->getLogCounts(); + return $logCounts['rights-rights'] ?? 0; + } + + /** + * Get the total number of pages imported by the user (through any import mechanism: + * interwiki, or XML upload). + * @return int + */ + public function countPagesImported(): int { + $logCounts = $this->getLogCounts(); + $import = $logCounts['import-import'] ?? 0; + $interwiki = $logCounts['import-interwiki'] ?? 0; + $upload = $logCounts['import-upload'] ?? 0; + return $import + $interwiki + $upload; + } + + /** + * Get the number of changes the user has made to AbuseFilters. + * @return int + */ + public function countAbuseFilterChanges(): int { + $logCounts = $this->getLogCounts(); + return $logCounts['abusefilter-modify'] ?? 0; + } + + /** + * Get the number of page content model changes made by the user. + * @return int + */ + public function countContentModelChanges(): int { + $logCounts = $this->getLogCounts(); + $new = $logCounts['contentmodel-new'] ?? 0; + $modified = $logCounts['contentmodel-change'] ?? 0; + return $new + $modified; + } + + /** + * Get the average number of edits per page (including deleted revisions and pages). + * @return float + */ + public function averageRevisionsPerPage(): float { + if ( $this->countAllPagesEdited() == 0 ) { + return 0; + } + return round( $this->countAllRevisions() / $this->countAllPagesEdited(), 3 ); + } + + /** + * Average number of edits made per day. + * @return float + */ + public function averageRevisionsPerDay(): float { + if ( $this->getDays() == 0 ) { + return 0; + } + return round( $this->countAllRevisions() / $this->getDays(), 3 ); + } + + /** + * Get the total number of edits made by the user with semi-automating tools. + */ + public function countAutomatedEdits(): int { + if ( $this->autoEditCount ) { + return $this->autoEditCount; + } + $this->autoEditCount = $this->repository->countAutomatedEdits( $this->project, $this->user ); + return $this->autoEditCount; + } + + /** + * Get the count of (non-deleted) edits made in the given timeframe to now. + * @param string $time One of 'day', 'week', 'month', or 'year'. + * @return int The total number of live edits. + */ + public function countRevisionsInLast( string $time ): int { + $revCounts = $this->getPairData(); + return $revCounts[$time] ?? 0; + } + + /** + * Get the number of days between the first and last edits. + * If there's only one edit, this is counted as one day. + * @return int + */ + public function getDays(): int { + $first = isset( $this->getFirstAndLatestActions()['rev_first']['timestamp'] ) + ? new DateTime( $this->getFirstAndLatestActions()['rev_first']['timestamp'] ) + : false; + $latest = isset( $this->getFirstAndLatestActions()['rev_latest']['timestamp'] ) + ? new DateTime( $this->getFirstAndLatestActions()['rev_latest']['timestamp'] ) + : false; + + if ( $first === false || $latest === false ) { + return 0; + } + + $days = $latest->diff( $first )->days; + + return $days > 0 ? $days : 1; + } + + /** + * Get the total number of files uploaded (including those now deleted). + * @return int + */ + public function countFilesUploaded(): int { + $logCounts = $this->getLogCounts(); + return $logCounts['upload-upload'] ?: 0; + } + + /** + * Get the total number of files uploaded to Commons (including those now deleted). + * This is only applicable for WMF labs installations. + * @return int + */ + public function countFilesUploadedCommons(): int { + $fileCounts = $this->repository->getFileCounts( $this->project, $this->user ); + return $fileCounts['files_uploaded_commons'] ?? 0; + } + + /** + * Get the total number of files that were renamed (including those now deleted). + */ + public function countFilesMoved(): int { + $fileCounts = $this->repository->getFileCounts( $this->project, $this->user ); + return $fileCounts['files_moved'] ?? 0; + } + + /** + * Get the total number of files that were renamed on Commons (including those now deleted). + */ + public function countFilesMovedCommons(): int { + $fileCounts = $this->repository->getFileCounts( $this->project, $this->user ); + return $fileCounts['files_moved_commons'] ?? 0; + } + + /** + * Get the total number of revisions the user has sent thanks for. + * @return int + */ + public function thanks(): int { + $logCounts = $this->getLogCounts(); + return $logCounts['thanks-thank'] ?: 0; + } + + /** + * Get the total number of approvals + * @return int + */ + public function approvals(): int { + $logCounts = $this->getLogCounts(); + return ( !empty( $logCounts['review-approve'] ) ? $logCounts['review-approve'] : 0 ) + + ( !empty( $logCounts['review-approve2'] ) ? $logCounts['review-approve2'] : 0 ) + + ( !empty( $logCounts['review-approve-i'] ) ? $logCounts['review-approve-i'] : 0 ) + + ( !empty( $logCounts['review-approve2-i'] ) ? $logCounts['review-approve2-i'] : 0 ); + } + + /** + * Get the total number of patrols performed by the user. + * @return int + */ + public function patrols(): int { + $logCounts = $this->getLogCounts(); + return $logCounts['patrol-patrol'] ?: 0; + } + + /** + * Get the total number of PageCurations reviews performed by the user. + * (Only exists on English Wikipedia.) + * @return int + */ + public function reviews(): int { + $logCounts = $this->getLogCounts(); + $reviewed = $logCounts['pagetriage-curation-reviewed'] ?: 0; + $reviewedRedirect = $logCounts['pagetriage-curation-reviewed-redirect'] ?: 0; + $reviewedArticle = $logCounts['pagetriage-curation-reviewed-article'] ?: 0; + return ( $reviewed + $reviewedRedirect + $reviewedArticle ); + } + + /** + * Get the total number of accounts created by the user. + * @return int + */ + public function accountsCreated(): int { + $logCounts = $this->getLogCounts(); + $create2 = $logCounts['newusers-create2'] ?: 0; + $byemail = $logCounts['newusers-byemail'] ?: 0; + return $create2 + $byemail; + } + + /** + * Get the number of history merges performed by the user. + * @return int + */ + public function merges(): int { + $logCounts = $this->getLogCounts(); + return $logCounts['merge-merge']; + } + + /** + * Get the given user's total edit counts per namespace. + * @return array Array keys are namespace IDs, values are the edit counts. + */ + public function namespaceTotals(): array { + if ( isset( $this->namespaceTotals ) ) { + return $this->namespaceTotals; + } + $counts = $this->repository->getNamespaceTotals( $this->project, $this->user ); + arsort( $counts ); + $this->namespaceTotals = $counts; + return $counts; + } + + /** + * Get the total number of live edits by summing the namespace totals. + * This is used in the view for namespace totals so we don't unnecessarily run the self::getPairData() query. + * @return int + */ + public function liveRevisionsFromNamespaces(): int { + return array_sum( $this->namespaceTotals() ); + } + + /** + * Get a summary of the times of day and the days of the week that the user has edited. + * @return string[] + */ + public function timeCard(): array { + if ( isset( $this->timeCardData ) ) { + return $this->timeCardData; + } + $totals = $this->repository->getTimeCard( $this->project, $this->user ); + + // Scale the radii: get the max, then scale each radius. + // This looks inefficient, but there's a max of 72 elements in this array. + $max = 0; + foreach ( $totals as $total ) { + $max = max( $max, $total['value'] ); + } + foreach ( $totals as &$total ) { + $total['scale'] = round( ( $total['value'] / $max ) * 20 ); + } + + // Fill in zeros for timeslots that have no values. + $sortedTotals = []; + $index = 0; + $sortedIndex = 0; + foreach ( range( 1, 7 ) as $day ) { + foreach ( range( 0, 23 ) as $hour ) { + if ( isset( $totals[$index] ) && (int)$totals[$index]['hour'] === $hour ) { + $sortedTotals[$sortedIndex] = $totals[$index]; + $index++; + } else { + $sortedTotals[$sortedIndex] = [ + 'day_of_week' => $day, + 'hour' => $hour, + 'value' => 0, + ]; + } + $sortedIndex++; + } + } + + $this->timeCardData = $sortedTotals; + return $sortedTotals; + } + + /** + * Get the total numbers of edits per month. + * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING* so we can mock the current DateTime. + * @return array With keys 'yearLabels', 'monthLabels' and 'totals', + * the latter keyed by namespace, then year/month. + */ + public function monthCounts( ?DateTime $currentTime = null ): array { + if ( isset( $this->monthCounts ) ) { + return $this->monthCounts; + } + + // Set to current month if we're not unit-testing + if ( !( $currentTime instanceof DateTime ) ) { + $currentTime = new DateTime( 'last day of this month' ); + } + + $totals = $this->repository->getMonthCounts( $this->project, $this->user ); + $out = [ + // labels for years + 'yearLabels' => [], + // labels for months + 'monthLabels' => [], + // actual totals, grouped by namespace, year and then month + 'totals' => [], + ]; + + /** Keep track of the date of their first edit. */ + $firstEdit = new DateTime(); + + [ $out, $firstEdit ] = $this->fillInMonthCounts( $out, $totals, $firstEdit ); + + $dateRange = new DatePeriod( + $firstEdit, + new DateInterval( 'P1M' ), + $currentTime->modify( 'first day of this month' ) + ); + + $out = $this->fillInMonthTotalsAndLabels( $out, $dateRange ); + + // One more loop to sort by year/month + foreach ( array_keys( $out['totals'] ) as $nsId ) { + ksort( $out['totals'][$nsId] ); + } + + // Finally, sort the namespaces + ksort( $out['totals'] ); + + $this->monthCounts = $out; + return $out; + } + + /** + * Get the counts keyed by month and then namespace. + * Basically the opposite of self::monthCounts()['totals']. + * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING* so we can mock the current DateTime. + * @return array Months as keys, values are counts keyed by namesapce. + * @fixme Create API for this! + */ + public function monthCountsWithNamespaces( ?DateTime $currentTime = null ): array { + $countsMonthNamespace = array_fill_keys( + array_values( $this->monthCounts( $currentTime )['monthLabels'] ), + [] + ); + + foreach ( $this->monthCounts( $currentTime )['totals'] as $ns => $months ) { + foreach ( $months as $month => $count ) { + $countsMonthNamespace[$month][$ns] = $count; + } + } + + return $countsMonthNamespace; + } + + /** + * Loop through the database results and fill in the values + * for the months that we have data for. + * @param array $out + * @param array $totals + * @param DateTime $firstEdit + * @return array [ + * string[] - Modified $out filled with month stats, + * DateTime - timestamp of first edit + * ] + * Tests covered in self::monthCounts(). + * @codeCoverageIgnore + */ + private function fillInMonthCounts( array $out, array $totals, DateTime $firstEdit ): array { + foreach ( $totals as $total ) { + // Keep track of first edit + $date = new DateTime( $total['year'] . '-' . $total['month'] . '-01' ); + if ( $date < $firstEdit ) { + $firstEdit = $date; + } + + // Collate the counts by namespace, and then YYYY-MM. + $ns = $total['namespace']; + $out['totals'][$ns][$date->format( 'Y-m' )] = (int)$total['count']; + } + + return [ $out, $firstEdit ]; + } + + /** + * Given the output array, fill each month's totals and labels. + * @param array $out + * @param DatePeriod $dateRange From first edit to present. + * @return array Modified $out filled with month stats. + * Tests covered in self::monthCounts(). + * @codeCoverageIgnore + */ + private function fillInMonthTotalsAndLabels( array $out, DatePeriod $dateRange ): array { + foreach ( $dateRange as $monthObj ) { + $yearLabel = $monthObj->format( 'Y' ); + $monthLabel = $monthObj->format( 'Y-m' ); + + // Fill in labels + $out['monthLabels'][] = $monthLabel; + if ( !in_array( $yearLabel, $out['yearLabels'] ) ) { + $out['yearLabels'][] = $yearLabel; + } + + foreach ( array_keys( $out['totals'] ) as $nsId ) { + if ( !isset( $out['totals'][$nsId][$monthLabel] ) ) { + $out['totals'][$nsId][$monthLabel] = 0; + } + } + } + + return $out; + } + + /** + * Get the total numbers of edits per year. + * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING* so we can mock the current DateTime. + * @return array With keys 'yearLabels' and 'totals', the latter keyed by namespace then year. + */ + public function yearCounts( ?DateTime $currentTime = null ): array { + if ( isset( $this->yearCounts ) ) { + return $this->yearCounts; + } + + $monthCounts = $this->monthCounts( $currentTime ); + $yearCounts = [ + 'yearLabels' => $monthCounts['yearLabels'], + 'totals' => [], + ]; + + foreach ( $monthCounts['totals'] as $nsId => $months ) { + foreach ( $months as $month => $count ) { + $year = substr( $month, 0, 4 ); + if ( !isset( $yearCounts['totals'][$nsId][$year] ) ) { + $yearCounts['totals'][$nsId][$year] = 0; + } + $yearCounts['totals'][$nsId][$year] += $count; + } + } + + $this->yearCounts = $yearCounts; + return $yearCounts; + } + + /** + * Get the counts keyed by year and then namespace. + * Basically the opposite of self::yearCounts()['totals']. + * @param null|DateTime $currentTime - *USED ONLY FOR UNIT TESTING* + * so we can mock the current DateTime. + * @return array Years as keys, values are counts keyed by namesapce. + */ + public function yearCountsWithNamespaces( ?DateTime $currentTime = null ): array { + $countsYearNamespace = array_fill_keys( + array_keys( $this->yearTotals( $currentTime ) ), + [] + ); + + foreach ( $this->yearCounts( $currentTime )['totals'] as $ns => $years ) { + foreach ( $years as $year => $count ) { + $countsYearNamespace[$year][$ns] = $count; + } + } + + return $countsYearNamespace; + } + + /** + * Get total edits for each year. Used in wikitext export. + * @param null|DateTime $currentTime *USED ONLY FOR UNIT TESTING* + * @return array With the years as the keys, counts as the values. + */ + public function yearTotals( ?DateTime $currentTime = null ): array { + $years = []; + + foreach ( $this->yearCounts( $currentTime )['totals'] as $nsData ) { + foreach ( $nsData as $year => $count ) { + if ( !isset( $years[$year] ) ) { + $years[$year] = 0; + } + $years[$year] += $count; + } + } + + return $years; + } + + /** + * Get average edit size, and number of large and small edits. + * @return array + */ + public function getEditSizeData(): array { + if ( !isset( $this->editSizeData ) ) { + $this->editSizeData = $this->repository + ->getEditSizeData( $this->project, $this->user ); + } + return $this->editSizeData; + } + + /** + * Get the total edit count of this user or 5,000 if they've made more than 5,000 edits. + * This is used to ensure percentages of small and large edits are computed properly. + * @return int + */ + public function countLast5000(): int { + return $this->countLiveRevisions() > 5000 ? 5000 : $this->countLiveRevisions(); + } + + /** + * Get the number of edits under 20 bytes of the user's past 5000 edits. + * @return int + */ + public function countSmallEdits(): int { + $editSizeData = $this->getEditSizeData(); + return isset( $editSizeData['small_edits'] ) ? (int)$editSizeData['small_edits'] : 0; + } + + /** + * Get the total number of edits over 1000 bytes of the user's past 5000 edits. + * @return int + */ + public function countLargeEdits(): int { + $editSizeData = $this->getEditSizeData(); + return isset( $editSizeData['large_edits'] ) ? (int)$editSizeData['large_edits'] : 0; + } + + /** + * Get the number of edits that have automated tags in the user's past 5000 edits. + * @return int + */ + public function countAutoEdits(): int { + $editSizeData = $this->getEditSizeData(); + if ( !isset( $editSizeData['tag_lists'] ) ) { + return 0; + } + $tags = json_decode( $editSizeData['tag_lists'] ); + $autoTags = $this->autoEditsHelper->getTags( $this->project ); + return count( + // Number + array_filter( + // of revisions + $tags, + // with tags + static fn ( $a ) => $a !== null && + count( + // where the number of tags + array_filter( + $a, + // that mean these edits are auto + static fn ( $t ) => in_array( $t, $autoTags ) + ) + // is greater than 0 + ) > 0 + ) + ); + } + + /** + * Get the average size of the user's past 5000 edits. + * @return float Size in bytes. + */ + public function averageEditSize(): float { + $editSizeData = $this->getEditSizeData(); + if ( isset( $editSizeData['average_size'] ) ) { + return round( (float)$editSizeData['average_size'], 3 ); + } else { + return 0; + } + } } diff --git a/src/Model/EditSummary.php b/src/Model/EditSummary.php index 0e5f9b6dd..92622b785 100644 --- a/src/Model/EditSummary.php +++ b/src/Model/EditSummary.php @@ -1,6 +1,6 @@ 0, - 'recent_edits_major' => 0, - 'total_edits_minor' => 0, - 'total_edits_major' => 0, - 'total_edits' => 0, - 'recent_summaries_minor' => 0, - 'recent_summaries_major' => 0, - 'total_summaries_minor' => 0, - 'total_summaries_major' => 0, - 'total_summaries' => 0, - 'month_counts' => [], - ]; - - /** - * EditSummary constructor. - * - * @param Repository|EditSummaryRepository $repository - * @param Project $project The project we're working with. - * @param ?User $user The user to process. - * @param int|string $namespace Namespace ID or 'all' for all namespaces. - * @param int|false $start Start date as Unix timestamp. - * @param int|false $end End date as Unix timestamp. - * @param int $numEditsRecent Number of edits from present to consider as 'recent'. - */ - public function __construct( - protected Repository|EditSummaryRepository $repository, - protected Project $project, - protected ?User $user, - protected int|string $namespace, - protected int|false $start = false, - protected int|false $end = false, - /** @var int Number of edits from present to consider as 'recent'. */ - protected int $numEditsRecent = 150 - ) { - } - - /** - * Get the total number of edits. - * @return int - */ - public function getTotalEdits(): int - { - return $this->data['total_edits']; - } - - /** - * Get the total number of minor edits. - * @return int - */ - public function getTotalEditsMinor(): int - { - return $this->data['total_edits_minor']; - } - - /** - * Get the total number of major (non-minor) edits. - * @return int - */ - public function getTotalEditsMajor(): int - { - return $this->data['total_edits_major']; - } - - /** - * Get the total number of recent minor edits. - * @return int - */ - public function getRecentEditsMinor(): int - { - return $this->data['recent_edits_minor']; - } - - /** - * Get the total number of recent major (non-minor) edits. - * @return int - */ - public function getRecentEditsMajor(): int - { - return $this->data['recent_edits_major']; - } - - /** - * Get the total number of edits with summaries. - * @return int - */ - public function getTotalSummaries(): int - { - return $this->data['total_summaries']; - } - - /** - * Get the total number of minor edits with summaries. - * @return int - */ - public function getTotalSummariesMinor(): int - { - return $this->data['total_summaries_minor']; - } - - /** - * Get the total number of major (non-minor) edits with summaries. - * @return int - */ - public function getTotalSummariesMajor(): int - { - return $this->data['total_summaries_major']; - } - - /** - * Get the total number of recent minor edits with with summaries. - * @return int - */ - public function getRecentSummariesMinor(): int - { - return $this->data['recent_summaries_minor']; - } - - /** - * Get the total number of recent major (non-minor) edits with with summaries. - * @return int - */ - public function getRecentSummariesMajor(): int - { - return $this->data['recent_summaries_major']; - } - - /** - * Get the month counts. - * @return array Months as 'YYYY-MM' as the keys, - * with key 'total' and 'summaries' as the values. - */ - public function getMonthCounts(): array - { - return $this->data['month_counts']; - } - - /** - * Get the whole blob of counts. - * @return array Counts of summaries, raw edits, and per-month breakdown. - * @codeCoverageIgnore - */ - public function getData(): array - { - return $this->data; - } - - /** - * Fetch the data from the database, process, and put in memory. - * @codeCoverageIgnore - */ - public function prepareData(): array - { - // Do our database work in the Repository, passing in reference - // to $this->processRow so we can do post-processing here. - $ret = $this->repository->prepareData( - [$this, 'processRow'], - $this->project, - $this->user, - $this->namespace, - $this->start, - $this->end - ); - - // We want to keep all the default zero values if there are no contributions. - if (count($ret) > 0) { - $this->data = $ret; - } - - return $ret; - } - - /** - * Process a single row from the database, updating class properties with counts. - * @param string[] $row As retrieved from the revision table. - * @return string[] - */ - public function processRow(array $row): array - { - // Extract the date out of the date field - $timestamp = DateTime::createFromFormat('YmdHis', $row['rev_timestamp']); - - $monthKey = $timestamp->format('Y-m'); - - // Grand total for number of edits - $this->data['total_edits']++; - - // Update total edit count for this month. - $this->updateMonthCounts($monthKey, 'total'); - - // Total edit summaries - if ($this->hasSummary($row)) { - $this->data['total_summaries']++; - - // Update summary count for this month. - $this->updateMonthCounts($monthKey, 'summaries'); - } - - if ($this->isMinor($row)) { - $this->updateMajorMinorCounts($row, 'minor'); - } else { - $this->updateMajorMinorCounts($row, 'major'); - } - - return $this->data; - } - - /** - * Given the row in `revision`, update minor counts. - * @param string[] $row As retrieved from the revision table. - * @param string $type Either 'minor' or 'major'. - * @codeCoverageIgnore - */ - private function updateMajorMinorCounts(array $row, string $type): void - { - $this->data['total_edits_'.$type]++; - - $hasSummary = $this->hasSummary($row); - $isRecent = $this->data['recent_edits_'.$type] < $this->numEditsRecent; - - if ($hasSummary) { - $this->data['total_summaries_'.$type]++; - } - - // Update recent edits counts. - if ($isRecent) { - $this->data['recent_edits_'.$type]++; - - if ($hasSummary) { - $this->data['recent_summaries_'.$type]++; - } - } - } - - /** - * Was the given row in `revision` marked as a minor edit? - * @param string[] $row As retrieved from the revision table. - * @return boolean - */ - private function isMinor(array $row): bool - { - return 1 === (int)$row['rev_minor_edit']; - } - - /** - * Taking into account automated edit summaries, does the given - * row in `revision` have a user-supplied edit summary? - * @param string[] $row As retrieved from the revision table. - * @return boolean - */ - private function hasSummary(array $row): bool - { - $summary = preg_replace("/^\/\* (.*?) \*\/\s*/", '', $row['comment'] ?: ''); - return '' !== $summary; - } - - /** - * Check and see if the month is set for given $monthKey and $type. - * If it is, increment it, otherwise set it to 1. - * @param string $monthKey In the form 'YYYY-MM'. - * @param string $type Either 'total' or 'summaries'. - * @codeCoverageIgnore - */ - private function updateMonthCounts(string $monthKey, string $type): void - { - if (isset($this->data['month_counts'][$monthKey][$type])) { - $this->data['month_counts'][$monthKey][$type]++; - } else { - $this->data['month_counts'][$monthKey][$type] = 1; - } - } +class EditSummary extends Model { + /** + * Counts of summaries, raw edits, and per-month breakdown. + * Keys are underscored because this also is served in the API. + * @var array + */ + protected array $data = [ + 'recent_edits_minor' => 0, + 'recent_edits_major' => 0, + 'total_edits_minor' => 0, + 'total_edits_major' => 0, + 'total_edits' => 0, + 'recent_summaries_minor' => 0, + 'recent_summaries_major' => 0, + 'total_summaries_minor' => 0, + 'total_summaries_major' => 0, + 'total_summaries' => 0, + 'month_counts' => [], + ]; + + /** + * EditSummary constructor. + * + * @param Repository|EditSummaryRepository $repository + * @param Project $project The project we're working with. + * @param ?User $user The user to process. + * @param int|string $namespace Namespace ID or 'all' for all namespaces. + * @param int|false $start Start date as Unix timestamp. + * @param int|false $end End date as Unix timestamp. + * @param int $numEditsRecent Number of edits from present to consider as 'recent'. + */ + public function __construct( + protected Repository|EditSummaryRepository $repository, + protected Project $project, + protected ?User $user, + protected int|string $namespace, + protected int|false $start = false, + protected int|false $end = false, + /** @var int Number of edits from present to consider as 'recent'. */ + protected int $numEditsRecent = 150 + ) { + } + + /** + * Get the total number of edits. + * @return int + */ + public function getTotalEdits(): int { + return $this->data['total_edits']; + } + + /** + * Get the total number of minor edits. + * @return int + */ + public function getTotalEditsMinor(): int { + return $this->data['total_edits_minor']; + } + + /** + * Get the total number of major (non-minor) edits. + * @return int + */ + public function getTotalEditsMajor(): int { + return $this->data['total_edits_major']; + } + + /** + * Get the total number of recent minor edits. + * @return int + */ + public function getRecentEditsMinor(): int { + return $this->data['recent_edits_minor']; + } + + /** + * Get the total number of recent major (non-minor) edits. + * @return int + */ + public function getRecentEditsMajor(): int { + return $this->data['recent_edits_major']; + } + + /** + * Get the total number of edits with summaries. + * @return int + */ + public function getTotalSummaries(): int { + return $this->data['total_summaries']; + } + + /** + * Get the total number of minor edits with summaries. + * @return int + */ + public function getTotalSummariesMinor(): int { + return $this->data['total_summaries_minor']; + } + + /** + * Get the total number of major (non-minor) edits with summaries. + * @return int + */ + public function getTotalSummariesMajor(): int { + return $this->data['total_summaries_major']; + } + + /** + * Get the total number of recent minor edits with with summaries. + * @return int + */ + public function getRecentSummariesMinor(): int { + return $this->data['recent_summaries_minor']; + } + + /** + * Get the total number of recent major (non-minor) edits with with summaries. + * @return int + */ + public function getRecentSummariesMajor(): int { + return $this->data['recent_summaries_major']; + } + + /** + * Get the month counts. + * @return array Months as 'YYYY-MM' as the keys, + * with key 'total' and 'summaries' as the values. + */ + public function getMonthCounts(): array { + return $this->data['month_counts']; + } + + /** + * Get the whole blob of counts. + * @return array Counts of summaries, raw edits, and per-month breakdown. + * @codeCoverageIgnore + */ + public function getData(): array { + return $this->data; + } + + /** + * Fetch the data from the database, process, and put in memory. + * @codeCoverageIgnore + */ + public function prepareData(): array { + // Do our database work in the Repository, passing in reference + // to $this->processRow so we can do post-processing here. + $ret = $this->repository->prepareData( + $this->processRow( ... ), + $this->project, + $this->user, + $this->namespace, + $this->start, + $this->end + ); + + // We want to keep all the default zero values if there are no contributions. + if ( count( $ret ) > 0 ) { + $this->data = $ret; + } + + return $ret; + } + + /** + * Process a single row from the database, updating class properties with counts. + * @param string[] $row As retrieved from the revision table. + * @return string[] + */ + public function processRow( array $row ): array { + // Extract the date out of the date field + $timestamp = DateTime::createFromFormat( 'YmdHis', $row['rev_timestamp'] ); + + $monthKey = $timestamp->format( 'Y-m' ); + + // Grand total for number of edits + $this->data['total_edits']++; + + // Update total edit count for this month. + $this->updateMonthCounts( $monthKey, 'total' ); + + // Total edit summaries + if ( $this->hasSummary( $row ) ) { + $this->data['total_summaries']++; + + // Update summary count for this month. + $this->updateMonthCounts( $monthKey, 'summaries' ); + } + + if ( $this->isMinor( $row ) ) { + $this->updateMajorMinorCounts( $row, 'minor' ); + } else { + $this->updateMajorMinorCounts( $row, 'major' ); + } + + return $this->data; + } + + /** + * Given the row in `revision`, update minor counts. + * @param string[] $row As retrieved from the revision table. + * @param string $type Either 'minor' or 'major'. + * @codeCoverageIgnore + */ + private function updateMajorMinorCounts( array $row, string $type ): void { + $this->data['total_edits_' . $type]++; + + $hasSummary = $this->hasSummary( $row ); + $isRecent = $this->data['recent_edits_' . $type] < $this->numEditsRecent; + + if ( $hasSummary ) { + $this->data['total_summaries_' . $type]++; + } + + // Update recent edits counts. + if ( $isRecent ) { + $this->data['recent_edits_' . $type]++; + + if ( $hasSummary ) { + $this->data['recent_summaries_' . $type]++; + } + } + } + + /** + * Was the given row in `revision` marked as a minor edit? + * @param string[] $row As retrieved from the revision table. + * @return bool + */ + private function isMinor( array $row ): bool { + return (int)$row['rev_minor_edit'] === 1; + } + + /** + * Taking into account automated edit summaries, does the given + * row in `revision` have a user-supplied edit summary? + * @param string[] $row As retrieved from the revision table. + * @return bool + */ + private function hasSummary( array $row ): bool { + $summary = preg_replace( "/^\/\* (.*?) \*\/\s*/", '', $row['comment'] ?: '' ); + return $summary !== ''; + } + + /** + * Check and see if the month is set for given $monthKey and $type. + * If it is, increment it, otherwise set it to 1. + * @param string $monthKey In the form 'YYYY-MM'. + * @param string $type Either 'total' or 'summaries'. + * @codeCoverageIgnore + */ + private function updateMonthCounts( string $monthKey, string $type ): void { + if ( isset( $this->data['month_counts'][$monthKey][$type] ) ) { + $this->data['month_counts'][$monthKey][$type]++; + } else { + $this->data['month_counts'][$monthKey][$type] = 1; + } + } } diff --git a/src/Model/GlobalContribs.php b/src/Model/GlobalContribs.php index dacf0a79c..d795290d9 100644 --- a/src/Model/GlobalContribs.php +++ b/src/Model/GlobalContribs.php @@ -1,6 +1,6 @@ namespace = '' == $namespace ? 0 : $namespace; - $this->limit = $limit ?? self::PAGE_SIZE; - } - - /** - * Get the total edit counts for the top n projects of this user. - * @param int $numProjects - * @return array Each element has 'total' and 'project' keys. - */ - public function globalEditCountsTopN(int $numProjects = 10): array - { - // Get counts. - $editCounts = $this->globalEditCounts(true); - // Truncate, and return. - return array_slice($editCounts, 0, $numProjects); - } - - /** - * Get the total number of edits excluding the top n. - * @param int $numProjects - * @return int - */ - public function globalEditCountWithoutTopN(int $numProjects = 10): int - { - $editCounts = $this->globalEditCounts(true); - $bottomM = array_slice($editCounts, $numProjects); - $total = 0; - foreach ($bottomM as $editCount) { - $total += $editCount['total']; - } - return $total; - } - - /** - * Get the grand total of all edits on all projects. - * @return int - */ - public function globalEditCount(): int - { - $total = 0; - foreach ($this->globalEditCounts() as $editCount) { - $total += $editCount['total']; - } - return $total; - } - - /** - * Get the total revision counts for all projects for this user. - * @param bool $sorted Whether to sort the list by total, or not. - * @return array[] Each element has 'total' and 'project' keys. - */ - public function globalEditCounts(bool $sorted = false): array - { - if (!isset($this->globalEditCounts)) { - $this->globalEditCounts = $this->repository->globalEditCounts($this->user); - } - - if ($sorted) { - // Sort. - uasort($this->globalEditCounts, function ($a, $b) { - return $b['total'] - $a['total']; - }); - } - - return $this->globalEditCounts; - } - - public function numProjectsWithEdits(): int - { - return count($this->repository->getProjectsWithEdits($this->user)); - } - - /** - * Get the most recent revisions across all projects. - * @return Edit[] - */ - public function globalEdits(): array - { - if (isset($this->globalEdits)) { - return $this->globalEdits; - } - - // Get projects with edits. - $projects = $this->repository->getProjectsWithEdits($this->user); - if (0 === count($projects)) { - return []; - } - - // Get all revisions for those projects. - $globalContribsRepo = $this->repository; - $globalRevisionsData = $globalContribsRepo->getRevisions( - array_keys($projects), - $this->user, - $this->namespace, - $this->start, - $this->end, - $this->limit + 1, - $this->offset - ); - $globalEdits = []; - - foreach ($globalRevisionsData as $revision) { - $project = $projects[$revision['dbName']]; - - // Can happen if the project is given from CentralAuth API but the database is not being replicated. - if (null === $project || !$project->exists()) { - continue; - } - - $edit = $this->getEditFromRevision($project, $revision); - $globalEdits[$edit->getTimestamp()->getTimestamp().'-'.$edit->getId()] = $edit; - } - - // Sort and prune, before adding more. - krsort($globalEdits); - $this->globalEdits = array_slice($globalEdits, 0, $this->limit); - - return $this->globalEdits; - } - - private function getEditFromRevision(Project $project, array $revision): Edit - { - $page = Page::newFromRow($this->pageRepo, $project, $revision); - return new Edit($this->editRepo, $this->userRepo, $page, $revision); - } +class GlobalContribs extends Model { + /** @var int Number of results per page. */ + public const PAGE_SIZE = 50; + + /** @var int[] Keys are project DB names. */ + protected array $globalEditCounts; + + /** @var array Most recent revisions across all projects. */ + protected array $globalEdits; + + /** + * GlobalContribs constructor. + * @param Repository|GlobalContribsRepository $repository + * @param PageRepository $pageRepo + * @param UserRepository $userRepo + * @param EditRepository $editRepo + * @param ?User $user + * @param string|int|null $namespace Namespace ID or 'all'. + * @param false|int $start As Unix timestamp. + * @param false|int $end As Unix timestamp. + * @param false|int $offset As Unix timestamp. + * @param int|null $limit Number of results to return. + */ + public function __construct( + protected Repository|GlobalContribsRepository $repository, + protected PageRepository $pageRepo, + protected UserRepository $userRepo, + protected EditRepository $editRepo, + protected ?User $user, + string|int|null $namespace = 'all', + protected false|int $start = false, + protected false|int $end = false, + protected false|int $offset = false, + ?int $limit = null + ) { + $this->namespace = $namespace == '' ? 0 : $namespace; + $this->limit = $limit ?? self::PAGE_SIZE; + } + + /** + * Get the total edit counts for the top n projects of this user. + * @param int $numProjects + * @return array Each element has 'total' and 'project' keys. + */ + public function globalEditCountsTopN( int $numProjects = 10 ): array { + // Get counts. + $editCounts = $this->globalEditCounts( true ); + // Truncate, and return. + return array_slice( $editCounts, 0, $numProjects ); + } + + /** + * Get the total number of edits excluding the top n. + * @param int $numProjects + * @return int + */ + public function globalEditCountWithoutTopN( int $numProjects = 10 ): int { + $editCounts = $this->globalEditCounts( true ); + $bottomM = array_slice( $editCounts, $numProjects ); + $total = 0; + foreach ( $bottomM as $editCount ) { + $total += $editCount['total']; + } + return $total; + } + + /** + * Get the grand total of all edits on all projects. + * @return int + */ + public function globalEditCount(): int { + $total = 0; + foreach ( $this->globalEditCounts() as $editCount ) { + $total += $editCount['total']; + } + return $total; + } + + /** + * Get the total revision counts for all projects for this user. + * @param bool $sorted Whether to sort the list by total, or not. + * @return array[] Each element has 'total' and 'project' keys. + */ + public function globalEditCounts( bool $sorted = false ): array { + if ( !isset( $this->globalEditCounts ) ) { + $this->globalEditCounts = $this->repository->globalEditCounts( $this->user ); + } + + if ( $sorted ) { + // Sort. + uasort( $this->globalEditCounts, static function ( $a, $b ) { + return $b['total'] - $a['total']; + } ); + } + + return $this->globalEditCounts; + } + + public function numProjectsWithEdits(): int { + return count( $this->repository->getProjectsWithEdits( $this->user ) ); + } + + /** + * Get the most recent revisions across all projects. + * @return Edit[] + */ + public function globalEdits(): array { + if ( isset( $this->globalEdits ) ) { + return $this->globalEdits; + } + + // Get projects with edits. + $projects = $this->repository->getProjectsWithEdits( $this->user ); + if ( count( $projects ) === 0 ) { + return []; + } + + // Get all revisions for those projects. + $globalContribsRepo = $this->repository; + $globalRevisionsData = $globalContribsRepo->getRevisions( + array_keys( $projects ), + $this->user, + $this->namespace, + $this->start, + $this->end, + $this->limit + 1, + $this->offset + ); + $globalEdits = []; + + foreach ( $globalRevisionsData as $revision ) { + $project = $projects[$revision['dbName']]; + + // Can happen if the project is given from CentralAuth API but the database is not being replicated. + if ( $project === null || !$project->exists() ) { + continue; + } + + $edit = $this->getEditFromRevision( $project, $revision ); + $globalEdits[$edit->getTimestamp()->getTimestamp() . '-' . $edit->getId()] = $edit; + } + + // Sort and prune, before adding more. + krsort( $globalEdits ); + $this->globalEdits = array_slice( $globalEdits, 0, $this->limit ); + + return $this->globalEdits; + } + + private function getEditFromRevision( Project $project, array $revision ): Edit { + $page = Page::newFromRow( $this->pageRepo, $project, $revision ); + return new Edit( $this->editRepo, $this->userRepo, $page, $revision ); + } } diff --git a/src/Model/LargestPages.php b/src/Model/LargestPages.php index e94f929a5..7bde25c1b 100644 --- a/src/Model/LargestPages.php +++ b/src/Model/LargestPages.php @@ -1,6 +1,6 @@ namespace = '' == $namespace ? 0 : $namespace; - } +class LargestPages extends Model { + /** + * LargestPages constructor. + * @param Repository|LargestPagesRepository $repository + * @param Project $project + * @param string|int|null $namespace Namespace ID or 'all'. + * @param string $includePattern Either regular expression (starts/ends with forward slash), + * or a wildcard pattern with % as the wildcard symbol. + * @param string $excludePattern Either regular expression (starts/ends with forward slash), + * or a wildcard pattern with % as the wildcard symbol. + */ + public function __construct( + protected Repository|LargestPagesRepository $repository, + protected Project $project, + string|int|null $namespace = 'all', + protected string $includePattern = '', + protected string $excludePattern = '' + ) { + $this->namespace = $namespace == '' ? 0 : $namespace; + } - /** - * Get the inclusion pattern. - * @return string - */ - public function getIncludePattern(): string - { - return $this->includePattern; - } + /** + * Get the inclusion pattern. + * @return string + */ + public function getIncludePattern(): string { + return $this->includePattern; + } - /** - * Get the exclusion pattern. - * @return string - */ - public function getExcludePattern(): string - { - return $this->excludePattern; - } + /** + * Get the exclusion pattern. + * @return string + */ + public function getExcludePattern(): string { + return $this->excludePattern; + } - /** - * Get the largest pages on the project. - * @return Page[] - */ - public function getResults(): array - { - return $this->repository->getData( - $this->project, - $this->namespace, - $this->includePattern, - $this->excludePattern - ); - } + /** + * Get the largest pages on the project. + * @return Page[] + */ + public function getResults(): array { + return $this->repository->getData( + $this->project, + $this->namespace, + $this->includePattern, + $this->excludePattern + ); + } } diff --git a/src/Model/Model.php b/src/Model/Model.php index 4f14c6c73..1ad6a85e5 100644 --- a/src/Model/Model.php +++ b/src/Model/Model.php @@ -1,6 +1,6 @@ repository = $repository; - return $this; - } - - /** - * Get this model's repository. - * @return Repository A subclass of Repository. - * @throws Exception If the repository hasn't been set yet. - */ - public function getRepository(): Repository - { - if (!isset($this->repository)) { - $msg = sprintf('The $repository property for class %s must be set before using.', static::class); - throw new Exception($msg); - } - return $this->repository; - } - - /** - * Get the associated Project. - * @return Project - */ - public function getProject(): Project - { - return $this->project; - } - - /** - * Get the associated User. - * @return User|null - */ - public function getUser(): ?User - { - return $this->user; - } - - /** - * Get the associated Page. - * @return Page|null - */ - public function getPage(): ?Page - { - return $this->page; - } - - /** - * Get the associated namespace. - * @return int|string Namespace ID or 'all' for all namespaces. - */ - public function getNamespace() - { - return $this->namespace; - } - - /** - * Get date opening date range as Unix timestamp. - * @return false|int - */ - public function getStart() - { - return $this->start; - } - - /** - * Get date opening date range, formatted as this is used in the views. - * @return string Blank if no value exists. - */ - public function getStartDate(): string - { - return is_int($this->start) ? date('Y-m-d', $this->start) : ''; - } - - /** - * Get date closing date range as Unix timestamp. - * @return false|int - */ - public function getEnd() - { - return $this->end; - } - - /** - * Get date closing date range, formatted as this is used in the views. - * @return string Blank if no value exists. - */ - public function getEndDate(): string - { - return is_int($this->end) ? date('Y-m-d', $this->end) : ''; - } - - /** - * Has date range? - * @return bool - */ - public function hasDateRange(): bool - { - return $this->start || $this->end; - } - - /** - * Get the limit set on number of rows to fetch. - * @return int|null - */ - public function getLimit(): ?int - { - return $this->limit; - } - - /** - * Get the offset timestamp as Unix timestamp. Used for pagination. - * @return false|int - */ - public function getOffset(): false|int - { - return $this->offset; - } - - /** - * Get the offset timestamp as a formatted ISO timestamp. - * @return null|string - */ - public function getOffsetISO(): ?string - { - return is_int($this->offset) ? date('Y-m-d\TH:i:s', $this->offset) : null; - } +abstract class Model { + /** + * Below are the class properties. Some subclasses may not use all of these. + */ + + /** @var Repository The corresponding repository for this model. */ + protected Repository $repository; + + /** @var Project The project. */ + protected Project $project; + + /** @var User|null The user. */ + protected ?User $user; + + /** @var Page|null the page associated with this edit */ + protected ?Page $page = null; + + /** @var int|string Which namespace we are querying for. 'all' for all namespaces. */ + protected int|string $namespace; + + /** @var false|int Start of time period as Unix timestamp. */ + protected false|int $start; + + /** @var false|int End of time period as Unix timestamp. */ + protected false|int $end; + + /** @var false|int Unix timestamp to offset results which acts as a substitute for $end */ + protected false|int $offset = false; + + /** @var int|null Number of rows to fetch. */ + protected ?int $limit = null; + + /** + * Set this model's data repository. + * @param Repository $repository + * @return Model + */ + public function setRepository( Repository $repository ): Model { + $this->repository = $repository; + return $this; + } + + /** + * Get this model's repository. + * @return Repository A subclass of Repository. + * @throws Exception If the repository hasn't been set yet. + */ + public function getRepository(): Repository { + if ( !isset( $this->repository ) ) { + $msg = sprintf( 'The $repository property for class %s must be set before using.', static::class ); + throw new Exception( $msg ); + } + return $this->repository; + } + + /** + * Get the associated Project. + * @return Project + */ + public function getProject(): Project { + return $this->project; + } + + /** + * Get the associated User. + * @return User|null + */ + public function getUser(): ?User { + return $this->user; + } + + /** + * Get the associated Page. + * @return Page|null + */ + public function getPage(): ?Page { + return $this->page; + } + + /** + * Get the associated namespace. + * @return int|string Namespace ID or 'all' for all namespaces. + */ + public function getNamespace() { + return $this->namespace; + } + + /** + * Get date opening date range as Unix timestamp. + * @return false|int + */ + public function getStart() { + return $this->start; + } + + /** + * Get date opening date range, formatted as this is used in the views. + * @return string Blank if no value exists. + */ + public function getStartDate(): string { + return is_int( $this->start ) ? date( 'Y-m-d', $this->start ) : ''; + } + + /** + * Get date closing date range as Unix timestamp. + * @return false|int + */ + public function getEnd() { + return $this->end; + } + + /** + * Get date closing date range, formatted as this is used in the views. + * @return string Blank if no value exists. + */ + public function getEndDate(): string { + return is_int( $this->end ) ? date( 'Y-m-d', $this->end ) : ''; + } + + /** + * Has date range? + * @return bool + */ + public function hasDateRange(): bool { + return $this->start || $this->end; + } + + /** + * Get the limit set on number of rows to fetch. + * @return int|null + */ + public function getLimit(): ?int { + return $this->limit; + } + + /** + * Get the offset timestamp as Unix timestamp. Used for pagination. + * @return false|int + */ + public function getOffset(): false|int { + return $this->offset; + } + + /** + * Get the offset timestamp as a formatted ISO timestamp. + * @return null|string + */ + public function getOffsetISO(): ?string { + return is_int( $this->offset ) ? date( 'Y-m-d\TH:i:s', $this->offset ) : null; + } } diff --git a/src/Model/Page.php b/src/Model/Page.php index 0a537d0f0..6489ba1f6 100644 --- a/src/Model/Page.php +++ b/src/Model/Page.php @@ -1,6 +1,6 @@ getNamespaces(); - $fullPageTitle = $namespaces[$row['namespace']].":$pageTitle"; - } - - $page = new self($repository, $project, $fullPageTitle); - $page->pageInfo['ns'] = $row['namespace']; - if (isset($row['length'])) { - $page->length = (int)$row['length']; - } - - return $page; - } - - /** - * Unique identifier for this Page, to be used in cache keys. - * Use of md5 ensures the cache key does not contain reserved characters. - * @see Repository::getCacheKey() - * @return string - * @codeCoverageIgnore - */ - public function getCacheKey(): string - { - return md5((string)$this->getId()); - } - - /** - * Get basic information about this page from the repository. - * @return array|null - */ - protected function getPageInfo(): ?array - { - if (!isset($this->pageInfo)) { - $this->pageInfo = $this->repository->getPageInfo($this->project, $this->unnormalizedPageName); - } - return $this->pageInfo; - } - - /** - * Get the page's title. - * @param bool $useUnnormalized Use the unnormalized page title to avoid an API call. This should be used only if - * you fetched the page title via other means (SQL query), and is not from user input alone. - * @return string - */ - public function getTitle(bool $useUnnormalized = false): string - { - if ($useUnnormalized) { - return $this->unnormalizedPageName; - } - $info = $this->getPageInfo(); - return $info['title'] ?? $this->unnormalizedPageName; - } - - /** - * Get the page's title without the namespace. - * @return string - */ - public function getTitleWithoutNamespace(): string - { - $info = $this->getPageInfo(); - $title = $info['title'] ?? $this->unnormalizedPageName; - $nsName = $this->getNamespaceName(); - return $nsName - ? str_replace($nsName . ':', '', $title) - : $title; - } - - /** - * Get this page's database ID. - * @return int|null Null if nonexistent. - */ - public function getId(): ?int - { - $info = $this->getPageInfo(); - return isset($info['pageid']) ? (int)$info['pageid'] : null; - } - - /** - * Get this page's length in bytes. - * @return int|null Null if nonexistent. - */ - public function getLength(): ?int - { - if (isset($this->length)) { - return $this->length; - } - $info = $this->getPageInfo(); - $this->length = isset($info['length']) ? (int)$info['length'] : null; - return $this->length; - } - - /** - * Get HTML for the stylized display of the title. - * The text will be the same as Page::getTitle(). - * @return string - */ - public function getDisplayTitle(): string - { - $info = $this->getPageInfo(); - if (isset($info['displaytitle'])) { - return $info['displaytitle']; - } - return $this->getTitle(); - } - - /** - * Get the full URL of this page. - * @return string|null Null if nonexistent. - */ - public function getUrl(): ?string - { - $info = $this->getPageInfo(); - return $info['fullurl'] ?? null; - } - - /** - * Get the numerical ID of the namespace of this page. - * @return int|null Null if page doesn't exist. - */ - public function getNamespace(): ?int - { - if (isset($this->pageInfo['ns']) && is_numeric($this->pageInfo['ns'])) { - return (int)$this->pageInfo['ns']; - } - $info = $this->getPageInfo(); - return isset($info['ns']) ? (int)$info['ns'] : null; - } - - /** - * Get the name of the namespace of this page. - * @return string|null Null if could not be determined. - */ - public function getNamespaceName(): ?string - { - $info = $this->getPageInfo(); - return isset($info['ns']) - ? ($this->getProject()->getNamespaces()[$info['ns']] ?? null) - : null; - } - - /** - * Get the number of page watchers. - * @return int|null Null if unknown. - */ - public function getWatchers(): ?int - { - $info = $this->getPageInfo(); - return isset($info['watchers']) ? (int)$info['watchers'] : null; - } - - /** - * Get the HTML content of the body of the page. - * @param DateTime|int|null $target If a DateTime object, the - * revision at that time will be returned. If an integer, it is - * assumed to be the actual revision ID. - * @return string - */ - public function getHTMLContent(DateTime|int|null $target = null): string - { - if (is_a($target, 'DateTime')) { - $target = $this->repository->getRevisionIdAtDate($this, $target); - } - return $this->repository->getHTMLContent($this, $target); - } - - /** - * Whether or not this page exists. - * @return bool - */ - public function exists(): bool - { - $info = $this->getPageInfo(); - return null !== $info && !isset($info['missing']) && !isset($info['invalid']) && !isset($info['interwiki']); - } - - /** - * Get the Project to which this page belongs. - * @return Project - */ - public function getProject(): Project - { - return $this->project; - } - - /** - * Get the language code for this page. - * If not set, the language code for the project is returned. - * @return string - */ - public function getLang(): string - { - $info = $this->getPageInfo(); - return $info['pagelanguage'] ?? $this->getProject()->getLang(); - } - - /** - * Get the Wikidata ID of this page. - * @return string|null Null if none exists. - */ - public function getWikidataId(): ?string - { - $info = $this->getPageInfo(); - return $info['pageprops']['wikibase_item'] ?? null; - } - - /** - * Get the number of revisions the page has. - * @param ?User $user Optionally limit to those of this user. - * @param false|int $start - * @param false|int $end - * @return int - */ - public function getNumRevisions(?User $user = null, false|int $start = false, false|int $end = false): int - { - // If a user is given, we will not cache the result via instance variable. - if (null !== $user) { - return $this->repository->getNumRevisions($this, $user, $start, $end); - } - - // Return cached value, if present. - if (isset($this->numRevisions)) { - return $this->numRevisions; - } - - // Otherwise, return the count of all revisions if already present. - if (isset($this->revisions)) { - $this->numRevisions = count($this->revisions); - } else { - // Otherwise do a COUNT in the event fetching all revisions is not desired. - $this->numRevisions = $this->repository->getNumRevisions($this, null, $start, $end); - } - - return $this->numRevisions; - } - - /** - * Get all edits made to this page. - * @param User|null $user Specify to get only revisions by the given user. - * @param false|int $start - * @param false|int $end - * @param int|null $limit - * @param int|null $numRevisions - * @return array - */ - public function getRevisions( - ?User $user = null, - false|int $start = false, - false|int $end = false, - ?int $limit = null, - ?int $numRevisions = null - ): array { - if (isset($this->revisions)) { - return $this->revisions; - } - - $this->revisions = $this->repository->getRevisions($this, $user, $start, $end, $limit, $numRevisions); - - return $this->revisions; - } - - /** - * Get the full page wikitext. - * @return string|null Null if nothing was found. - */ - public function getWikitext(): ?string - { - $content = $this->repository->getPagesWikitext( - $this->getProject(), - [ $this->getTitle() ] - ); - - return $content[$this->getTitle()] ?? null; - } - - /** - * Get the statement for a single revision, so that you can iterate row by row. - * @see PageRepository::getRevisionsStmt() - * @param User|null $user Specify to get only revisions by the given user. - * @param ?int $limit Max number of revisions to process. - * @param ?int $numRevisions Number of revisions, if known. This is used solely to determine the - * OFFSET if we are given a $limit. If $limit is set and $numRevisions is not set, a - * separate query is ran to get the nuber of revisions. - * @param false|int $start - * @param false|int $end - * @return Result - */ - public function getRevisionsStmt( - ?User $user = null, - ?int $limit = null, - ?int $numRevisions = null, - false|int $start = false, - false|int $end = false - ): Result { - // If we have a limit, we need to know the total number of revisions so that PageRepo - // will properly set the OFFSET. See PageRepository::getRevisionsStmt() for more info. - if (isset($limit) && null === $numRevisions) { - $numRevisions = $this->getNumRevisions($user, $start, $end); - } - return $this->repository->getRevisionsStmt($this, $user, $limit, $numRevisions, $start, $end); - } - - /** - * Get the revision ID that immediately precedes the given date. - * @param DateTime $date - * @return int|null Null if none found. - */ - public function getRevisionIdAtDate(DateTime $date): ?int - { - return $this->repository->getRevisionIdAtDate($this, $date); - } - - /** - * Get CheckWiki errors for this page - * @return string[] See getErrors() for format - */ - public function getCheckWikiErrors(): array - { - return []; - // FIXME: Re-enable after solving T413013 - // return $this->repository->getCheckWikiErrors($this); - } - - /** - * Get CheckWiki errors, if present - * @return string[][] List of errors in the format: - * [[ - * 'prio' => int, - * 'name' => string, - * 'notice' => string (HTML), - * 'explanation' => string (HTML) - * ], ... ] - */ - public function getErrors(): array - { - return $this->getCheckWikiErrors(); - } - - /** - * Get all wikidata items for the page, not just languages of sister projects - * @return string[] - */ - public function getWikidataItems(): array - { - if (!isset($this->wikidataItems)) { - $this->wikidataItems = $this->repository->getWikidataItems($this); - } - return $this->wikidataItems; - } - - /** - * Count wikidata items for the page, not just languages of sister projects - * @return int Number of records. - */ - public function countWikidataItems(): int - { - if (isset($this->wikidataItems)) { - $this->numWikidataItems = count($this->wikidataItems); - } elseif (!isset($this->numWikidataItems)) { - $this->numWikidataItems = $this->repository->countWikidataItems($this); - } - return $this->numWikidataItems; - } - - /** - * Get number of in and outgoing links and redirects to this page. - * @return string[] Counts with keys 'links_ext_count', 'links_out_count', 'links_in_count' and 'redirects_count'. - */ - public function countLinksAndRedirects(): array - { - return $this->repository->countLinksAndRedirects($this); - } - - /** - * Get the sum of pageviews for the given page and timeframe. - * @param string|DateTime $start In the format YYYYMMDD - * @param string|DateTime $end In the format YYYYMMDD - * @return int|null Total pageviews or null if data is unavailable. - */ - public function getPageviews(string|DateTime $start, string|DateTime $end): ?int - { - try { - $pageviews = $this->repository->getPageviews($this, $start, $end); - } catch (ClientException) { - // 404 means zero pageviews - return 0; - } catch (BadGatewayException) { - // Upstream error, so return null so the view can customize messaging. - return null; - } - - return array_sum(array_map(function ($item) { - return (int)$item['views']; - }, $pageviews['items'])); - } - - /** - * Get the sum of pageviews over the last N days - * @param int $days Default PageInfoApi::PAGEVIEWS_OFFSET - * @return int|null Number of pageviews or null if data is unavailable. - *@see PageInfoApi::PAGEVIEWS_OFFSET - */ - public function getLatestPageviews(int $days = PageInfoApi::PAGEVIEWS_OFFSET): ?int - { - $start = date('Ymd', strtotime("-$days days")); - $end = date('Ymd'); - return $this->getPageviews($start, $end); - } - - /** - * Is the page the project's Main Page? - * @return bool - */ - public function isMainPage(): bool - { - return $this->getProject()->getMainPage() === $this->getTitle(); - } +class Page extends Model { + /** @var string[]|null Metadata about this page. */ + protected ?array $pageInfo; + + /** @var string[] Revision history of this page. */ + protected array $revisions; + + /** @var int Number of revisions for this page. */ + protected int $numRevisions; + + /** @var string[] List of Wikidata sitelinks for this page. */ + protected array $wikidataItems; + + /** @var int Number of Wikidata sitelinks for this page. */ + protected int $numWikidataItems; + + /** @var int Length of the page in bytes. */ + protected int $length; + + /** + * Page constructor. + * @param Repository|PageRepository $repository + * @param Project $project + * @param string $unnormalizedPageName + */ + public function __construct( + protected Repository|PageRepository $repository, + protected Project $project, + /** @var string The page name as provided at instantiation. */ + protected string $unnormalizedPageName + ) { + } + + /** + * Get a Page instance given a database row (either from or JOINed on the page table). + * @param PageRepository $repository + * @param Project $project + * @param array $row Must contain 'page_title' and 'namespace'. May contain 'length'. + * @return static + */ + public static function newFromRow( PageRepository $repository, Project $project, array $row ): self { + $pageTitle = $row['page_title']; + + if ( (int)$row['namespace'] === 0 ) { + $fullPageTitle = $pageTitle; + } else { + $namespaces = $project->getNamespaces(); + $fullPageTitle = $namespaces[$row['namespace']] . ":$pageTitle"; + } + + $page = new self( $repository, $project, $fullPageTitle ); + $page->pageInfo['ns'] = $row['namespace']; + if ( isset( $row['length'] ) ) { + $page->length = (int)$row['length']; + } + + return $page; + } + + /** + * Unique identifier for this Page, to be used in cache keys. + * Use of md5 ensures the cache key does not contain reserved characters. + * @see Repository::getCacheKey() + * @return string + * @codeCoverageIgnore + */ + public function getCacheKey(): string { + return md5( (string)$this->getId() ); + } + + /** + * Get basic information about this page from the repository. + * @return array|null + */ + protected function getPageInfo(): ?array { + if ( !isset( $this->pageInfo ) ) { + $this->pageInfo = $this->repository->getPageInfo( $this->project, $this->unnormalizedPageName ); + } + return $this->pageInfo; + } + + /** + * Get the page's title. + * @param bool $useUnnormalized Use the unnormalized page title to avoid an API call. This should be used only if + * you fetched the page title via other means (SQL query), and is not from user input alone. + * @return string + */ + public function getTitle( bool $useUnnormalized = false ): string { + if ( $useUnnormalized ) { + return $this->unnormalizedPageName; + } + $info = $this->getPageInfo(); + return $info['title'] ?? $this->unnormalizedPageName; + } + + /** + * Get the page's title without the namespace. + * @return string + */ + public function getTitleWithoutNamespace(): string { + $info = $this->getPageInfo(); + $title = $info['title'] ?? $this->unnormalizedPageName; + $nsName = $this->getNamespaceName(); + return $nsName + ? str_replace( $nsName . ':', '', $title ) + : $title; + } + + /** + * Get this page's database ID. + * @return int|null Null if nonexistent. + */ + public function getId(): ?int { + $info = $this->getPageInfo(); + return isset( $info['pageid'] ) ? (int)$info['pageid'] : null; + } + + /** + * Get this page's length in bytes. + * @return int|null Null if nonexistent. + */ + public function getLength(): ?int { + if ( isset( $this->length ) ) { + return $this->length; + } + $info = $this->getPageInfo(); + $this->length = isset( $info['length'] ) ? (int)$info['length'] : null; + return $this->length; + } + + /** + * Get HTML for the stylized display of the title. + * The text will be the same as Page::getTitle(). + * @return string + */ + public function getDisplayTitle(): string { + $info = $this->getPageInfo(); + if ( isset( $info['displaytitle'] ) ) { + return $info['displaytitle']; + } + return $this->getTitle(); + } + + /** + * Get the full URL of this page. + * @return string|null Null if nonexistent. + */ + public function getUrl(): ?string { + $info = $this->getPageInfo(); + return $info['fullurl'] ?? null; + } + + /** + * Get the numerical ID of the namespace of this page. + * @return int|null Null if page doesn't exist. + */ + public function getNamespace(): ?int { + if ( isset( $this->pageInfo['ns'] ) && is_numeric( $this->pageInfo['ns'] ) ) { + return (int)$this->pageInfo['ns']; + } + $info = $this->getPageInfo(); + return isset( $info['ns'] ) ? (int)$info['ns'] : null; + } + + /** + * Get the name of the namespace of this page. + * @return string|null Null if could not be determined. + */ + public function getNamespaceName(): ?string { + $info = $this->getPageInfo(); + return isset( $info['ns'] ) + ? ( $this->getProject()->getNamespaces()[$info['ns']] ?? null ) + : null; + } + + /** + * Get the number of page watchers. + * @return int|null Null if unknown. + */ + public function getWatchers(): ?int { + $info = $this->getPageInfo(); + return isset( $info['watchers'] ) ? (int)$info['watchers'] : null; + } + + /** + * Get the HTML content of the body of the page. + * @param DateTime|int|null $target If a DateTime object, the + * revision at that time will be returned. If an integer, it is + * assumed to be the actual revision ID. + * @return string + */ + // phpcs:ignore MediaWiki.Usage.NullableType.ExplicitNullableTypes + public function getHTMLContent( DateTime|int|null $target = null ): string { + if ( is_a( $target, 'DateTime' ) ) { + $target = $this->repository->getRevisionIdAtDate( $this, $target ); + } + return $this->repository->getHTMLContent( $this, $target ); + } + + /** + * Whether or not this page exists. + * @return bool + */ + public function exists(): bool { + $info = $this->getPageInfo(); + return $info !== null && + !isset( $info['missing'] ) && + !isset( $info['invalid'] ) && + !isset( $info['interwiki'] ); + } + + /** + * Get the Project to which this page belongs. + * @return Project + */ + public function getProject(): Project { + return $this->project; + } + + /** + * Get the language code for this page. + * If not set, the language code for the project is returned. + * @return string + */ + public function getLang(): string { + $info = $this->getPageInfo(); + return $info['pagelanguage'] ?? $this->getProject()->getLang(); + } + + /** + * Get the Wikidata ID of this page. + * @return string|null Null if none exists. + */ + public function getWikidataId(): ?string { + $info = $this->getPageInfo(); + return $info['pageprops']['wikibase_item'] ?? null; + } + + /** + * Get the number of revisions the page has. + * @param ?User $user Optionally limit to those of this user. + * @param false|int $start + * @param false|int $end + * @return int + */ + public function getNumRevisions( ?User $user = null, false|int $start = false, false|int $end = false ): int { + // If a user is given, we will not cache the result via instance variable. + if ( $user !== null ) { + return $this->repository->getNumRevisions( $this, $user, $start, $end ); + } + + // Return cached value, if present. + if ( isset( $this->numRevisions ) ) { + return $this->numRevisions; + } + + // Otherwise, return the count of all revisions if already present. + if ( isset( $this->revisions ) ) { + $this->numRevisions = count( $this->revisions ); + } else { + // Otherwise do a COUNT in the event fetching all revisions is not desired. + $this->numRevisions = $this->repository->getNumRevisions( $this, null, $start, $end ); + } + + return $this->numRevisions; + } + + /** + * Get all edits made to this page. + * @param User|null $user Specify to get only revisions by the given user. + * @param false|int $start + * @param false|int $end + * @param int|null $limit + * @param int|null $numRevisions + * @return array + */ + public function getRevisions( + ?User $user = null, + false|int $start = false, + false|int $end = false, + ?int $limit = null, + ?int $numRevisions = null + ): array { + if ( isset( $this->revisions ) ) { + return $this->revisions; + } + + $this->revisions = $this->repository->getRevisions( $this, $user, $start, $end, $limit, $numRevisions ); + + return $this->revisions; + } + + /** + * Get the full page wikitext. + * @return string|null Null if nothing was found. + */ + public function getWikitext(): ?string { + $content = $this->repository->getPagesWikitext( + $this->getProject(), + [ $this->getTitle() ] + ); + + return $content[$this->getTitle()] ?? null; + } + + /** + * Get the statement for a single revision, so that you can iterate row by row. + * @see PageRepository::getRevisionsStmt() + * @param User|null $user Specify to get only revisions by the given user. + * @param ?int $limit Max number of revisions to process. + * @param ?int $numRevisions Number of revisions, if known. This is used solely to determine the + * OFFSET if we are given a $limit. If $limit is set and $numRevisions is not set, a + * separate query is ran to get the nuber of revisions. + * @param false|int $start + * @param false|int $end + * @return Result + */ + public function getRevisionsStmt( + ?User $user = null, + ?int $limit = null, + ?int $numRevisions = null, + false|int $start = false, + false|int $end = false + ): Result { + // If we have a limit, we need to know the total number of revisions so that PageRepo + // will properly set the OFFSET. See PageRepository::getRevisionsStmt() for more info. + if ( isset( $limit ) && $numRevisions === null ) { + $numRevisions = $this->getNumRevisions( $user, $start, $end ); + } + return $this->repository->getRevisionsStmt( $this, $user, $limit, $numRevisions, $start, $end ); + } + + /** + * Get the revision ID that immediately precedes the given date. + * @param DateTime $date + * @return int|null Null if none found. + */ + public function getRevisionIdAtDate( DateTime $date ): ?int { + return $this->repository->getRevisionIdAtDate( $this, $date ); + } + + /** + * Get CheckWiki errors for this page + * @return string[] See getErrors() for format + */ + public function getCheckWikiErrors(): array { + return []; + // FIXME: Re-enable after solving T413013 + // return $this->repository->getCheckWikiErrors($this); + } + + /** + * Get CheckWiki errors, if present + * @return string[][] List of errors in the format: + * [[ + * 'prio' => int, + * 'name' => string, + * 'notice' => string (HTML), + * 'explanation' => string (HTML) + * ], ... ] + */ + public function getErrors(): array { + return $this->getCheckWikiErrors(); + } + + /** + * Get all wikidata items for the page, not just languages of sister projects + * @return string[] + */ + public function getWikidataItems(): array { + if ( !isset( $this->wikidataItems ) ) { + $this->wikidataItems = $this->repository->getWikidataItems( $this ); + } + return $this->wikidataItems; + } + + /** + * Count wikidata items for the page, not just languages of sister projects + * @return int Number of records. + */ + public function countWikidataItems(): int { + if ( isset( $this->wikidataItems ) ) { + $this->numWikidataItems = count( $this->wikidataItems ); + } elseif ( !isset( $this->numWikidataItems ) ) { + $this->numWikidataItems = $this->repository->countWikidataItems( $this ); + } + return $this->numWikidataItems; + } + + /** + * Get number of in and outgoing links and redirects to this page. + * @return string[] Counts with keys 'links_ext_count', 'links_out_count', 'links_in_count' and 'redirects_count'. + */ + public function countLinksAndRedirects(): array { + return $this->repository->countLinksAndRedirects( $this ); + } + + /** + * Get the sum of pageviews for the given page and timeframe. + * @param string|DateTime $start In the format YYYYMMDD + * @param string|DateTime $end In the format YYYYMMDD + * @return int|null Total pageviews or null if data is unavailable. + */ + public function getPageviews( string|DateTime $start, string|DateTime $end ): ?int { + try { + $pageviews = $this->repository->getPageviews( $this, $start, $end ); + } catch ( ClientException ) { + // 404 means zero pageviews + return 0; + } catch ( BadGatewayException ) { + // Upstream error, so return null so the view can customize messaging. + return null; + } + + return array_sum( array_map( static function ( $item ) { + return (int)$item['views']; + }, $pageviews['items'] ) ); + } + + /** + * Get the sum of pageviews over the last N days + * @param int $days Default PageInfoApi::PAGEVIEWS_OFFSET + * @return int|null Number of pageviews or null if data is unavailable. + * @see PageInfoApi::PAGEVIEWS_OFFSET + */ + public function getLatestPageviews( int $days = PageInfoApi::PAGEVIEWS_OFFSET ): ?int { + $start = date( 'Ymd', strtotime( "-$days days" ) ); + $end = date( 'Ymd' ); + return $this->getPageviews( $start, $end ); + } + + /** + * Is the page the project's Main Page? + * @return bool + */ + public function isMainPage(): bool { + return $this->getProject()->getMainPage() === $this->getTitle(); + } } diff --git a/src/Model/PageAssessments.php b/src/Model/PageAssessments.php index 4aab71f6f..dc59ddbaf 100644 --- a/src/Model/PageAssessments.php +++ b/src/Model/PageAssessments.php @@ -1,6 +1,6 @@ config)) { - return $this->config = $this->repository->getConfig($this->project); - } - - return $this->config; - } - - /** - * Is the given namespace supported in Page Assessments? - * @param int $nsId Namespace ID. - * @return bool - */ - public function isSupportedNamespace(int $nsId): bool - { - return $this->isEnabled() && in_array($nsId, self::SUPPORTED_NAMESPACES); - } - - /** - * Does this project support page assessments? - * @return bool - */ - public function isEnabled(): bool - { - return (bool)$this->getConfig(); - } - - /** - * Does this project have importance ratings through Page Assessments? - * @return bool - */ - public function hasImportanceRatings(): bool - { - $config = $this->getConfig(); - return isset($config['importance']); - } - - /** - * Get the image URL of the badge for the given page assessment. - * @param string|null $class Valid classification for project, such as 'Start', 'GA', etc. Null for unknown. - * @param bool $filenameOnly Get only the filename, not the URL. - * @return string URL to image. - */ - public function getBadgeURL(?string $class, bool $filenameOnly = false): string - { - $config = $this->getConfig(); - - if (isset($config['class'][$class])) { - $url = 'https://upload.wikimedia.org/wikipedia/commons/'.$config['class'][$class]['badge']; - } elseif (isset($config['class']['Unknown'])) { - $url = 'https://upload.wikimedia.org/wikipedia/commons/'.$config['class']['Unknown']['badge']; - } else { - $url = ""; - } - - if ($filenameOnly) { - $parts = explode('/', $url); - return end($parts); - } - - return $url; - } - - /** - * Get the single overall assessment of the given page. - * @param Page $page - * @return string[]|false With keys 'value' and 'badge', or false if assessments are unsupported. - */ - public function getAssessment(Page $page): array|false - { - if (!$this->isEnabled() || !$this->isSupportedNamespace($page->getNamespace())) { - return false; - } - - $data = $this->repository->getAssessments($page, true); - - if (isset($data[0])) { - return $this->getClassFromAssessment($data[0]); - } - - // 'Unknown' class. - return $this->getClassFromAssessment(['class' => '']); - } - - /** - * Get assessments for the given Page. - * @param Page $page - * @return string[]|null null if unsupported, or array in the format of: - * [ - * 'assessment' => [ - * // overall assessment - * 'badge' => 'https://upload.wikimedia.org/wikipedia/commons/b/bc/Featured_article_star.svg', - * 'color' => '#9CBDFF', - * 'category' => 'Category:FA-Class articles', - * 'class' => 'FA', - * ] - * 'wikiprojects' => [ - * 'Biography' => [ - * 'assessment' => 'C', - * 'badge' => 'url', - * ], - * ... - * ], - * 'wikiproject_prefix' => 'Wikipedia:WikiProject_', - * ] - * @todo Add option to get ORES prediction. - */ - public function getAssessments(Page $page): ?array - { - if (!$this->isEnabled() || !$this->isSupportedNamespace($page->getNamespace())) { - return null; - } - - $config = $this->getConfig(); - $data = $this->repository->getAssessments($page); - - // Set the default decorations for the overall assessment. - // This will be replaced with the first valid class defined for any WikiProject. - $overallAssessment = array_merge(['class' => '???'], $config['class']['Unknown']); - $overallAssessment['badge'] = $this->getBadgeURL($overallAssessment['badge']); - - $decoratedAssessments = []; - - // Go through each raw assessment data from the database, and decorate them - // with the colours and badges as retrieved from the XTools assessments config. - foreach ($data as $assessment) { - $assessment['class'] = $this->getClassFromAssessment($assessment); - - // Replace the overall assessment with the first non-empty assessment. - if ('???' === $overallAssessment['class'] && '???' !== $assessment['class']['value']) { - $overallAssessment['class'] = $assessment['class']['value']; - $overallAssessment['color'] = $assessment['class']['color']; - $overallAssessment['category'] = $assessment['class']['category']; - $overallAssessment['badge'] = $assessment['class']['badge']; - } - - $assessment['importance'] = $this->getImportanceFromAssessment($assessment); - - $decoratedAssessments[$assessment['wikiproject']] = $assessment; - } - - // Don't show 'Unknown' assessment outside of the mainspace. - if (0 !== $page->getNamespace() && '???' === $overallAssessment['class']) { - return []; - } - - return [ - 'assessment' => $overallAssessment, - 'wikiprojects' => $decoratedAssessments, - 'wikiproject_prefix' => $config['wikiproject_prefix'], - ]; - } - - /** - * Get the class attributes for the given class value, as fetched from the config. - * @param string|null $classValue Such as 'FA', 'GA', 'Start', etc. - * @return string[] Attributes as fetched from the XTools assessments config. - */ - public function getClassAttrs(?string $classValue): array - { - $classValue = $classValue ?: 'Unknown'; - return $this->getConfig()['class'][$classValue] ?? $this->getConfig()['class']['Unknown']; - } - - /** - * Get the properties of the assessment class, including: - * 'value' (class name in plain text), - * 'color' (as hex RGB), - * 'badge' (full URL to assessment badge), - * 'category' (wiki path to related class category). - * @param array $assessment - * @return array Decorated class assessment. - */ - private function getClassFromAssessment(array $assessment): array - { - $classValue = $assessment['class']; - - // Use ??? as the presented value when the class is unknown or is not defined in the config - if ('Unknown' === $classValue || '' === $classValue || !isset($this->getConfig()['class'][$classValue])) { - return array_merge($this->getClassAttrs('Unknown'), [ - 'value' => '???', - 'badge' => $this->getBadgeURL('Unknown'), - ]); - } - - // Known class. - $classAttrs = $this->getClassAttrs($classValue); - $class = [ - 'value' => $classValue, - 'color' => $classAttrs['color'], - 'category' => $classAttrs['category'], - ]; - - // add full URL to badge icon - if ('' !== $classAttrs['badge']) { - $class['badge'] = $this->getBadgeURL($classValue); - } - - return $class; - } - - /** - * Get the properties of the assessment importance, including: - * 'value' (importance in plain text), - * 'color' (as hex RGB), - * 'weight' (integer, 0 is lowest importance), - * 'category' (wiki path to the related importance category). - * @param array $assessment - * @return array|null Decorated importance assessment. Null if importance could not be determined. - */ - private function getImportanceFromAssessment(array $assessment): ?array - { - $importanceValue = $assessment['importance']; - - if ('' == $importanceValue && !isset($this->getConfig()['importance'])) { - return null; - } - - // Known importance level. - $importanceUnknown = 'Unknown' === $importanceValue || '' === $importanceValue; - - if ($importanceUnknown || !isset($this->getConfig()['importance'][$importanceValue])) { - $importanceAttrs = $this->getConfig()['importance']['Unknown']; - - return array_merge($importanceAttrs, [ - 'value' => '???', - 'category' => $importanceAttrs['category'], - ]); - } else { - $importanceAttrs = $this->getConfig()['importance'][$importanceValue]; - return [ - 'value' => $importanceValue, - 'color' => $importanceAttrs['color'], - 'weight' => $importanceAttrs['weight'], // numerical weight for sorting purposes - 'category' => $importanceAttrs['category'], - ]; - } - } +class PageAssessments extends Model { + /** + * Namespaces in which there may be page assessments. + * @var int[] + * @todo Always JOIN on page_assessments and only display the data if it exists. + */ + public const SUPPORTED_NAMESPACES = [ + // Core namespaces + ...[ 0, 4, 6, 10, 12, 14 ], + // Custom namespaces + ...[ + // Portal + 100, + // WikiProject (T360774) + 102, + // Book + 108, + // Draft + 118, + // Module + 828, + ], + ]; + + /** @var array|null The assessments config. */ + protected ?array $config; + + /** + * Create a new PageAssessments. + * @param Repository|PageAssessmentsRepository $repository + * @param Project $project + */ + public function __construct( + protected Repository|PageAssessmentsRepository $repository, + protected Project $project + ) { + } + + /** + * Get page assessments configuration for the Project and cache in static variable. + * @return string[][][]|null As defined in config/assessments.yaml, or false if none exists. + */ + public function getConfig(): ?array { + if ( !isset( $this->config ) ) { + $this->config = $this->repository->getConfig( $this->project ); + } + + return $this->config; + } + + /** + * Is the given namespace supported in Page Assessments? + * @param int $nsId Namespace ID. + * @return bool + */ + public function isSupportedNamespace( int $nsId ): bool { + return $this->isEnabled() && in_array( $nsId, self::SUPPORTED_NAMESPACES ); + } + + /** + * Does this project support page assessments? + * @return bool + */ + public function isEnabled(): bool { + return (bool)$this->getConfig(); + } + + /** + * Does this project have importance ratings through Page Assessments? + * @return bool + */ + public function hasImportanceRatings(): bool { + $config = $this->getConfig(); + return isset( $config['importance'] ); + } + + /** + * Get the image URL of the badge for the given page assessment. + * @param string|null $class Valid classification for project, such as 'Start', 'GA', etc. Null for unknown. + * @param bool $filenameOnly Get only the filename, not the URL. + * @return string URL to image. + */ + public function getBadgeURL( ?string $class, bool $filenameOnly = false ): string { + $config = $this->getConfig(); + + if ( isset( $config['class'][$class] ) ) { + $url = 'https://upload.wikimedia.org/wikipedia/commons/' . $config['class'][$class]['badge']; + } elseif ( isset( $config['class']['Unknown'] ) ) { + $url = 'https://upload.wikimedia.org/wikipedia/commons/' . $config['class']['Unknown']['badge']; + } else { + $url = ""; + } + + if ( $filenameOnly ) { + $parts = explode( '/', $url ); + return end( $parts ); + } + + return $url; + } + + /** + * Get the single overall assessment of the given page. + * @param Page $page + * @return string[]|false With keys 'value' and 'badge', or false if assessments are unsupported. + */ + public function getAssessment( Page $page ): array|false { + if ( !$this->isEnabled() || !$this->isSupportedNamespace( $page->getNamespace() ) ) { + return false; + } + + $data = $this->repository->getAssessments( $page, true ); + + if ( isset( $data[0] ) ) { + return $this->getClassFromAssessment( $data[0] ); + } + + // 'Unknown' class. + return $this->getClassFromAssessment( [ 'class' => '' ] ); + } + + /** + * Get assessments for the given Page. + * @param Page $page + * @return string[]|null null if unsupported, or array in the format of: + * [ + * 'assessment' => [ + * // overall assessment + * 'badge' => 'https://upload.wikimedia.org/wikipedia/commons/b/bc/Featured_article_star.svg', + * 'color' => '#9CBDFF', + * 'category' => 'Category:FA-Class articles', + * 'class' => 'FA', + * ] + * 'wikiprojects' => [ + * 'Biography' => [ + * 'assessment' => 'C', + * 'badge' => 'url', + * ], + * ... + * ], + * 'wikiproject_prefix' => 'Wikipedia:WikiProject_', + * ] + * @todo Add option to get ORES prediction. + */ + public function getAssessments( Page $page ): ?array { + if ( !$this->isEnabled() || !$this->isSupportedNamespace( $page->getNamespace() ) ) { + return null; + } + + $config = $this->getConfig(); + $data = $this->repository->getAssessments( $page ); + + // Set the default decorations for the overall assessment. + // This will be replaced with the first valid class defined for any WikiProject. + $overallAssessment = array_merge( [ 'class' => '???' ], $config['class']['Unknown'] ); + $overallAssessment['badge'] = $this->getBadgeURL( $overallAssessment['badge'] ); + + $decoratedAssessments = []; + + // Go through each raw assessment data from the database, and decorate them + // with the colours and badges as retrieved from the XTools assessments config. + foreach ( $data as $assessment ) { + $assessment['class'] = $this->getClassFromAssessment( $assessment ); + + // Replace the overall assessment with the first non-empty assessment. + if ( $overallAssessment['class'] === '???' && $assessment['class']['value'] !== '???' ) { + $overallAssessment['class'] = $assessment['class']['value']; + $overallAssessment['color'] = $assessment['class']['color']; + $overallAssessment['category'] = $assessment['class']['category']; + $overallAssessment['badge'] = $assessment['class']['badge']; + } + + $assessment['importance'] = $this->getImportanceFromAssessment( $assessment ); + + $decoratedAssessments[$assessment['wikiproject']] = $assessment; + } + + // Don't show 'Unknown' assessment outside of the mainspace. + if ( $page->getNamespace() !== 0 && $overallAssessment['class'] === '???' ) { + return []; + } + + return [ + 'assessment' => $overallAssessment, + 'wikiprojects' => $decoratedAssessments, + 'wikiproject_prefix' => $config['wikiproject_prefix'], + ]; + } + + /** + * Get the class attributes for the given class value, as fetched from the config. + * @param string|null $classValue Such as 'FA', 'GA', 'Start', etc. + * @return string[] Attributes as fetched from the XTools assessments config. + */ + public function getClassAttrs( ?string $classValue ): array { + $classValue = $classValue ?: 'Unknown'; + return $this->getConfig()['class'][$classValue] ?? $this->getConfig()['class']['Unknown']; + } + + /** + * Get the properties of the assessment class, including: + * 'value' (class name in plain text), + * 'color' (as hex RGB), + * 'badge' (full URL to assessment badge), + * 'category' (wiki path to related class category). + * @param array $assessment + * @return array Decorated class assessment. + */ + private function getClassFromAssessment( array $assessment ): array { + $classValue = $assessment['class']; + + // Use ??? as the presented value when the class is unknown or is not defined in the config + if ( $classValue === 'Unknown' || $classValue === '' || !isset( $this->getConfig()['class'][$classValue] ) ) { + return array_merge( $this->getClassAttrs( 'Unknown' ), [ + 'value' => '???', + 'badge' => $this->getBadgeURL( 'Unknown' ), + ] ); + } + + // Known class. + $classAttrs = $this->getClassAttrs( $classValue ); + $class = [ + 'value' => $classValue, + 'color' => $classAttrs['color'], + 'category' => $classAttrs['category'], + ]; + + // add full URL to badge icon + if ( $classAttrs['badge'] !== '' ) { + $class['badge'] = $this->getBadgeURL( $classValue ); + } + + return $class; + } + + /** + * Get the properties of the assessment importance, including: + * 'value' (importance in plain text), + * 'color' (as hex RGB), + * 'weight' (integer, 0 is lowest importance), + * 'category' (wiki path to the related importance category). + * @param array $assessment + * @return array|null Decorated importance assessment. Null if importance could not be determined. + */ + private function getImportanceFromAssessment( array $assessment ): ?array { + $importanceValue = $assessment['importance']; + + if ( $importanceValue == '' && !isset( $this->getConfig()['importance'] ) ) { + return null; + } + + // Known importance level. + $importanceUnknown = $importanceValue === 'Unknown' || $importanceValue === ''; + + if ( $importanceUnknown || !isset( $this->getConfig()['importance'][$importanceValue] ) ) { + $importanceAttrs = $this->getConfig()['importance']['Unknown']; + + return array_merge( $importanceAttrs, [ + 'value' => '???', + 'category' => $importanceAttrs['category'], + ] ); + } else { + $importanceAttrs = $this->getConfig()['importance'][$importanceValue]; + return [ + 'value' => $importanceValue, + 'color' => $importanceAttrs['color'], + // numerical weight for sorting purposes + 'weight' => $importanceAttrs['weight'], + 'category' => $importanceAttrs['category'], + ]; + } + } } diff --git a/src/Model/PageInfo.php b/src/Model/PageInfo.php index 43ed48d90..bfcd56ec5 100644 --- a/src/Model/PageInfo.php +++ b/src/Model/PageInfo.php @@ -1,6 +1,6 @@ " counts. */ - protected array $countHistory = [ - 'day' => 0, - 'week' => 0, - 'month' => 0, - 'year' => 0, - ]; - - /** @var int Number of revisions with deleted information that could effect accuracy of the stats. */ - protected int $numDeletedRevisions = 0; - - /** - * Get the day of last date we should show in the month/year sections, - * based on $this->end or the current date. - * @return int As Unix timestamp. - */ - private function getLastDay(): int - { - if (is_int($this->end)) { - return (new DateTime("@$this->end")) - ->modify('last day of this month') - ->getTimestamp(); - } else { - return strtotime('last day of this month'); - } - } - - /** - * Return the start/end date values as associative array, with YYYY-MM-DD as the date format. - * This is used mainly as a helper to pass to the pageviews Twig macros. - * @return array - */ - public function getDateParams(): array - { - if (!$this->hasDateRange()) { - return []; - } - - $ret = [ - 'start' => $this->firstEdit->getTimestamp()->format('Y-m-d'), - 'end' => $this->lastEdit->getTimestamp()->format('Y-m-d'), - ]; - - if (is_int($this->start)) { - $ret['start'] = date('Y-m-d', $this->start); - } - if (is_int($this->end)) { - $ret['end'] = date('Y-m-d', $this->end); - } - - return $ret; - } - - /** - * Get the number of revisions that are actually getting processed. This goes by the APP_MAX_PAGE_REVISIONS - * env variable, or the actual number of revisions, whichever is smaller. - * @return int - */ - public function getNumRevisionsProcessed(): int - { - if (isset($this->numRevisionsProcessed)) { - return $this->numRevisionsProcessed; - } - - if ($this->tooManyRevisions()) { - $this->numRevisionsProcessed = $this->repository->getMaxPageRevisions(); - } else { - $this->numRevisionsProcessed = $this->getNumRevisions(); - } - - return $this->numRevisionsProcessed; - } - - /** - * Fetch and store all the data we need to show the PageInfo view. - * @codeCoverageIgnore - */ - public function prepareData(): void - { - $this->parseHistory(); - $this->setLogsEvents(); - - // Bots need to be set before setting top 10 counts. - $this->bots = $this->getBots(); - - $this->doPostPrecessing(); - } - - /** - * Get the number of editors that edited the page. - * @return int - */ - public function getNumEditors(): int - { - return count($this->editors); - } - - /** - * Get the number of days between the first and last edit. - * @return int - */ - public function getTotalDays(): int - { - if (isset($this->totalDays)) { - return $this->totalDays; - } - $dateFirst = $this->firstEdit->getTimestamp(); - $dateLast = $this->lastEdit->getTimestamp(); - $interval = date_diff($dateLast, $dateFirst, true); - $this->totalDays = (int)$interval->format('%a'); - return $this->totalDays; - } - - /** - * Returns length of the page. - * @return int|null - */ - public function getLength(): ?int - { - if ($this->hasDateRange()) { - return $this->lastEdit->getLength(); - } - - return $this->page->getLength(); - } - - /** - * Get the average number of days between edits to the page. - * @return float - */ - public function averageDaysPerEdit(): float - { - return round($this->getTotalDays() / $this->getNumRevisionsProcessed(), 1); - } - - /** - * Get the average number of edits per day to the page. - * @return float - */ - public function editsPerDay(): float - { - $editsPerDay = $this->getTotalDays() - ? $this->getNumRevisionsProcessed() / ($this->getTotalDays() / (365 / 12 / 24)) - : 0; - return round($editsPerDay, 1); - } - - /** - * Get the average number of edits per month to the page. - * @return float - */ - public function editsPerMonth(): float - { - $editsPerMonth = $this->getTotalDays() - ? $this->getNumRevisionsProcessed() / ($this->getTotalDays() / (365 / 12)) - : 0; - return min($this->getNumRevisionsProcessed(), round($editsPerMonth, 1)); - } - - /** - * Get the average number of edits per year to the page. - * @return float - */ - public function editsPerYear(): float - { - $editsPerYear = $this->getTotalDays() - ? $this->getNumRevisionsProcessed() / ($this->getTotalDays() / 365) - : 0; - return min($this->getNumRevisionsProcessed(), round($editsPerYear, 1)); - } - - /** - * Get the average number of edits per editor. - * @return float - */ - public function editsPerEditor(): float - { - if (count($this->editors) > 0) { - return round($this->getNumRevisionsProcessed() / count($this->editors), 1); - } - - // To prevent division by zero error; can happen if all usernames are removed (see T303724). - return 0; - } - - /** - * Get the percentage of minor edits to the page. - * @return float - */ - public function minorPercentage(): float - { - return round( - ($this->minorCount / $this->getNumRevisionsProcessed()) * 100, - 1 - ); - } - - /** - * Get the percentage of anonymous edits to the page. - * @return float - */ - public function anonPercentage(): float - { - return round( - ($this->anonCount / $this->getNumRevisionsProcessed()) * 100, - 1 - ); - } - - /** - * Get the percentage of edits made by the top 10 editors. - * @return float - */ - public function topTenPercentage(): float - { - return round(($this->topTenCount / $this->getNumRevisionsProcessed()) * 100, 1); - } - - /** - * Get the number of automated edits made to the page. - * @return int - */ - public function getAutomatedCount(): int - { - return $this->automatedCount; - } - - /** - * Get the number of mobile edits. - * @return int - */ - public function getMobileCount(): int - { - return $this->mobileCount; - } - - /** - * Get the number of visual edits. - * @return int - */ - public function getVisualCount(): int - { - return $this->visualCount; - } - - /** - * Get the number of edits to the page that were reverted with the subsequent edit. - * @return int - */ - public function getRevertCount(): int - { - return $this->revertCount; - } - - /** - * Get the number of edits to the page made by logged out users. - * @return int - */ - public function getAnonCount(): int - { - return $this->anonCount; - } - - /** - * Get the number of minor edits to the page. - * @return int - */ - public function getMinorCount(): int - { - return $this->minorCount; - } - - /** - * Get the number of edits to the page made in the past day, week, month and year. - * @return int[] With keys 'day', 'week', 'month' and 'year'. - */ - public function getCountHistory(): array - { - return $this->countHistory; - } - - /** - * Get the number of edits to the page made by the top 10 editors. - * @return int - */ - public function getTopTenCount(): int - { - return $this->topTenCount; - } - - /** - * Get the first edit to the page. - * @return Edit - */ - public function getFirstEdit(): Edit - { - return $this->firstEdit; - } - - /** - * Get the last edit to the page. - * @return Edit - */ - public function getLastEdit(): Edit - { - return $this->lastEdit; - } - - /** - * Get the edit that made the largest addition to the page (by number of bytes). - * @return Edit|null - */ - public function getMaxAddition(): ?Edit - { - return $this->maxAddition; - } - - /** - * Get the edit that made the largest removal to the page (by number of bytes). - * @return Edit|null - */ - public function getMaxDeletion(): ?Edit - { - return $this->maxDeletion; - } - - /** - * Get the subpage count. - * @return int - */ - public function getSubpageCount(): int - { - return $this->repository->getSubpageCount($this->page); - } - - /** - * Get the list of editors to the page, including various statistics. - * @return array - */ - public function getEditors(): array - { - return $this->editors; - } - - /** - * Get usernames of human editors (not bots). - * @param int|null $limit - * @return string[] - */ - public function getHumans(?int $limit = null): array - { - return array_slice(array_diff(array_keys($this->getEditors()), array_keys($this->getBots())), 0, $limit); - } - - /** - * Get the list of the top editors to the page (by edits), including various statistics. - * @return array - */ - public function topTenEditorsByEdits(): array - { - return $this->topTenEditorsByEdits; - } - - /** - * Get the list of the top editors to the page (by added text), including various statistics. - * @return array - */ - public function topTenEditorsByAdded(): array - { - return $this->topTenEditorsByAdded; - } - - /** - * Get various counts about each individual year and month of the page's history. - * @return array - */ - public function getYearMonthCounts(): array - { - return $this->yearMonthCounts; - } - - /** - * Get the localized labels for the 'Year counts' chart. - * @return string[] - */ - public function getYearLabels(): array - { - return $this->yearLabels; - } - - /** - * Get the localized labels for the 'Month counts' chart. - * @return string[] - */ - public function getMonthLabels(): array - { - return $this->monthLabels; - } - - /** - * Get the maximum number of edits that were created across all months. This is used as a - * comparison for the bar charts in the months section. - * @return int - */ - public function getMaxEditsPerMonth(): int - { - return $this->maxEditsPerMonth; - } - - /** - * Get a list of (semi-)automated tools that were used to edit the page, including - * the number of times they were used, and a link to the tool's homepage. - * @return string[] - */ - public function getTools(): array - { - return $this->tools; - } - - /** - * Parse the revision history, collecting our core statistics. - * - * Untestable because it relies on getting a PDO statement. All the important - * logic lives in other methods which are tested. - * @codeCoverageIgnore - */ - private function parseHistory(): void - { - $limit = $this->tooManyRevisions() ? $this->repository->getMaxPageRevisions() : null; - - // numRevisions is ignored if $limit is null. - $revs = $this->page->getRevisions( - null, - $this->start, - $this->end, - $limit, - $this->getNumRevisions() - ); - $revCount = 0; - - /** - * Data about previous edits so that we can use them as a basis for comparison. - * @var Edit[] $prevEdits - */ - $prevEdits = [ - // The previous Edit, used to discount content that was reverted. - 'prev' => null, - - // The SHA-1 of the edit *before* the previous edit. Used for more - // accurate revert detection. - 'prevSha' => null, - - // The last edit deemed to be the max addition of content. This is kept track of - // in case we find out the next edit was reverted (and was also a max edit), - // in which case we'll want to discount it and use this one instead. - 'maxAddition' => null, - - // Same as with maxAddition, except the maximum amount of content deleted. - // This is used to discount content that was reverted. - 'maxDeletion' => null, - ]; - - foreach ($revs as $rev) { - /** @var Edit $edit */ - $edit = $this->repository->getEdit($this->page, $rev); - - if (0 !== $edit->getDeleted()) { - $this->numDeletedRevisions++; - } - - if (in_array('mobile edit', $edit->getTags())) { - $this->mobileCount++; - } - - if (in_array('visualeditor', $edit->getTags())) { - $this->visualCount++; - } - - if (0 === $revCount) { - $this->firstEdit = $edit; - } - - // Sometimes, with old revisions (2001 era), the revisions from 2002 come before 2001 - if ($edit->getTimestamp() < $this->firstEdit->getTimestamp()) { - $this->firstEdit = $edit; - } - - $prevEdits = $this->updateCounts($edit, $prevEdits); - - $revCount++; - } - - $this->numRevisionsProcessed = $revCount; - - // Various sorts - arsort($this->editors); - ksort($this->yearMonthCounts); - if ($this->tools) { - arsort($this->tools); - } - } - - /** - * Update various counts based on the current edit. - * @param Edit $edit - * @param Edit[] $prevEdits With 'prev', 'prevSha', 'maxAddition' and 'maxDeletion' - * @return Edit[] Updated version of $prevEdits. - */ - private function updateCounts(Edit $edit, array $prevEdits): array - { - // Update the counts for the year and month of the current edit. - $this->updateYearMonthCounts($edit); - - // Update counts for the user who made the edit. - $this->updateUserCounts($edit); - - // Update the year/month/user counts of anon and minor edits. - $this->updateAnonMinorCounts($edit); - - // Update counts for automated tool usage, if applicable. - $this->updateToolCounts($edit); - - // Increment "edits per
    tag. Null if no comparison found. - */ - public function getDiffHtml(Edit $edit): ?string - { - $params = [ - 'action' => 'compare', - 'fromrev' => $edit->getId(), - 'torelative' => 'prev', - ]; + /** + * Use the Compare API to get HTML for the diff. + * @param Edit $edit + * @return string|null Raw HTML, must be wrapped in a
    tag. Null if no comparison found. + */ + public function getDiffHtml( Edit $edit ): ?string { + $params = [ + 'action' => 'compare', + 'fromrev' => $edit->getId(), + 'torelative' => 'prev', + ]; - $res = $this->executeApiRequest($edit->getProject(), $params); - return $res['compare']['*'] ?? null; - } + $res = $this->executeApiRequest( $edit->getProject(), $params ); + return $res['compare']['*'] ?? null; + } } diff --git a/src/Repository/EditSummaryRepository.php b/src/Repository/EditSummaryRepository.php index 36ee02c52..3cb67d88b 100644 --- a/src/Repository/EditSummaryRepository.php +++ b/src/Repository/EditSummaryRepository.php @@ -1,6 +1,6 @@ getTableName('revision'); - $commentTable = $project->getTableName('comment'); - $pageTable = $project->getTableName('page'); +class EditSummaryRepository extends UserRepository { + /** + * Build and execute SQL to get edit summary usage. + * @param Project $project The project we're working with. + * @param User $user The user to process. + * @param string|int $namespace Namespace ID or 'all' for all namespaces. + * @param int|false $start Start date as Unix timestamp. + * @param int|false $end End date as Unix timestamp. + * @return Result + */ + public function getRevisions( + Project $project, + User $user, + string|int $namespace, + int|false $start = false, + int|false $end = false + ): Result { + $revisionTable = $project->getTableName( 'revision' ); + $commentTable = $project->getTableName( 'comment' ); + $pageTable = $project->getTableName( 'page' ); - $revDateConditions = $this->getDateConditions($start, $end); - $condNamespace = 'all' === $namespace ? '' : 'AND page_namespace = :namespace'; - $pageJoin = 'all' === $namespace ? '' : "JOIN $pageTable ON rev_page = page_id"; - $params = []; - $ipcJoin = ''; - $whereClause = 'rev_actor = :actorId'; + $revDateConditions = $this->getDateConditions( $start, $end ); + $condNamespace = $namespace === 'all' ? '' : 'AND page_namespace = :namespace'; + $pageJoin = $namespace === 'all' ? '' : "JOIN $pageTable ON rev_page = page_id"; + $params = []; + $ipcJoin = ''; + $whereClause = 'rev_actor = :actorId'; - if ($user->isIpRange()) { - $ipcTable = $project->getTableName('ip_changes'); - $ipcJoin = "JOIN $ipcTable ON rev_id = ipc_rev_id"; - $whereClause = 'ipc_hex BETWEEN :startIp AND :endIp'; - [$params['startIp'], $params['endIp']] = IPUtils::parseRange($user->getUsername()); - } + if ( $user->isIpRange() ) { + $ipcTable = $project->getTableName( 'ip_changes' ); + $ipcJoin = "JOIN $ipcTable ON rev_id = ipc_rev_id"; + $whereClause = 'ipc_hex BETWEEN :startIp AND :endIp'; + [ $params['startIp'], $params['endIp'] ] = IPUtils::parseRange( $user->getUsername() ); + } - $sql = "SELECT comment_text AS `comment`, rev_timestamp, rev_minor_edit + $sql = "SELECT comment_text AS `comment`, rev_timestamp, rev_minor_edit FROM $revisionTable $ipcJoin $pageJoin @@ -61,40 +60,41 @@ public function getRevisions( $revDateConditions ORDER BY rev_timestamp DESC"; - return $this->executeQuery($sql, $project, $user, $namespace, $params); - } + return $this->executeQuery( $sql, $project, $user, $namespace, $params ); + } - /** - * Loop through the revisions and tally up totals, based on callback that lives in the EditSummary model. - * @param array $processRow [EditSummary instance, 'method name'] - * @param Project $project - * @param User $user - * @param int|string $namespace Namespace ID or 'all' for all namespaces. - * @param int|false $start Start date as Unix timestamp. - * @param int|false $end End date as Unix timestamp. - * @return array The final results. - */ - public function prepareData( - array $processRow, - Project $project, - User $user, - int|string $namespace, - int|false $start = false, - int|false $end = false - ): array { - $cacheKey = $this->getCacheKey([$project, $user, $namespace, $start, $end], 'edit_summary_usage'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } + /** + * Loop through the revisions and tally up totals, based on callback that lives in the EditSummary model. + * @param callable $processRow + * @param Project $project + * @param User $user + * @param int|string $namespace Namespace ID or 'all' for all namespaces. + * @param int|false $start Start date as Unix timestamp. + * @param int|false $end End date as Unix timestamp. + * @return array The final results. + */ + public function prepareData( + callable $processRow, + Project $project, + User $user, + int|string $namespace, + int|false $start = false, + int|false $end = false + ): array { + $cacheKey = $this->getCacheKey( [ $project, $user, $namespace, $start, $end ], 'edit_summary_usage' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } - $resultQuery = $this->getRevisions($project, $user, $namespace, $start, $end); - $data = []; + $resultQuery = $this->getRevisions( $project, $user, $namespace, $start, $end ); + $data = []; - while ($row = $resultQuery->fetchAssociative()) { - $data = call_user_func($processRow, $row); - } + // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition + while ( $row = $resultQuery->fetchAssociative() ) { + $data = $processRow( $row ); + } - // Cache and return. - return $this->setCache($cacheKey, $data); - } + // Cache and return. + return $this->setCache( $cacheKey, $data ); + } } diff --git a/src/Repository/GlobalContribsRepository.php b/src/Repository/GlobalContribsRepository.php index b7ed2e708..ddc342945 100644 --- a/src/Repository/GlobalContribsRepository.php +++ b/src/Repository/GlobalContribsRepository.php @@ -1,6 +1,6 @@ caProject = new Project($centralAuthProject); - $this->caProject->setRepository($this->projectRepo); - parent::__construct($managerRegistry, $cache, $guzzle, $logger, $parameterBag, $isWMF, $queryTimeout); - } - - /** - * Get a user's edit count for each project. - * @see GlobalContribsRepository::globalEditCountsFromCentralAuth() - * @see GlobalContribsRepository::globalEditCountsFromDatabases() - * @param User $user The user. - * @return ?array Elements are arrays with 'project' (Project), and 'total' (int). Null if anon (too slow). - */ - public function globalEditCounts(User $user): ?array - { - if ($user->isIP()) { - return null; - } - - // Get the edit counts from CentralAuth or database. - $editCounts = $this->globalEditCountsFromCentralAuth($user); - - // Pre-populate all projects' metadata, to prevent each project call from fetching it. - $this->caProject->getRepository()->getAll(); - - // Compile the output. - $out = []; - foreach ($editCounts as $editCount) { - $project = new Project($editCount['dbName']); - $project->setRepository($this->projectRepo); - // Make sure the project exists (new projects may not yet be on db replicas). - if ($project->exists()) { - $out[] = [ - 'dbName' => $editCount['dbName'], - 'total' => $editCount['total'], - 'project' => $project, - ]; - } - } - return $out; - } - - /** - * Get a user's total edit count on one or more project. - * Requires the CentralAuth extension to be installed on the project. - * @param User $user The user. - * @return array|null Elements are arrays with 'dbName' (string), and 'total' (int). Null for logged out users. - */ - protected function globalEditCountsFromCentralAuth(User $user): ?array - { - if (true === $user->isIP()) { - return null; - } - - // Set up cache. - $cacheKey = $this->getCacheKey(func_get_args(), 'gc_globaleditcounts'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $params = [ - 'meta' => 'globaluserinfo', - 'guiprop' => 'editcount|merged', - 'guiuser' => $user->getUsername(), - ]; - $result = $this->executeApiRequest($this->caProject, $params); - if (!isset($result['query']['globaluserinfo']['merged'])) { - return []; - } - $out = []; - foreach ($result['query']['globaluserinfo']['merged'] as $result) { - $out[] = [ - 'dbName' => $result['wiki'], - 'total' => $result['editcount'], - ]; - } - - // Cache and return. - return $this->setCache($cacheKey, $out); - } - - /** - * Loop through the given dbNames and create Project objects for each. - * @param array $dbNames - * @return Project[] Keyed by database name. - */ - private function formatProjects(array $dbNames): array - { - $projects = []; - - foreach ($dbNames as $dbName) { - $projects[$dbName] = $this->projectRepo->getProject($dbName); - } - - return $projects; - } - - /** - * Get all Projects on which the user has made at least one edit. - * @param User $user - * @return Project[] - */ - public function getProjectsWithEdits(User $user): array - { - if ($user->isIP()) { - $dbNames = array_keys($this->getDbNamesAndActorIds($user)); - } else { - $dbNames = []; - - foreach ($this->globalEditCountsFromCentralAuth($user) as $projectMeta) { - if ($projectMeta['total'] > 0) { - $dbNames[] = $projectMeta['dbName']; - } - } - } - - return $this->formatProjects($dbNames); - } - - /** - * Get projects that the user has made at least one edit on, and the associated actor ID. - * @param User $user - * @param string[] $dbNames Loop over these projects instead of all of them. - * @return array Keys are database names, values are actor IDs. - */ - public function getDbNamesAndActorIds(User $user, ?array $dbNames = null): array - { - // Check cache. - $cacheKey = $this->getCacheKey(func_get_args(), 'gc_db_names_actor_ids'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - if (!$dbNames) { - $dbNames = array_column($this->caProject->getRepository()->getAll(), 'dbName'); - } - - if ($user->isIpRange()) { - $username = $user->getIpSubstringFromCidr().'%'; - $whereClause = "actor_name LIKE :actor"; - } else { - $username = $user->getUsername(); - $whereClause = "actor_name = :actor"; - } - - $queriesBySlice = []; - - foreach ($dbNames as $dbName) { - $slice = $this->getDbList()[$dbName]; - // actor_revision table only includes users who have made at least one edit. - $actorTable = $this->getTableName($dbName, 'actor', 'revision'); - $queriesBySlice[$slice][] = "SELECT '$dbName' AS `dbName`, actor_id " . - "FROM $actorTable WHERE $whereClause"; - } - - $actorIds = []; - - foreach ($queriesBySlice as $slice => $queries) { - $sql = implode(' UNION ', $queries); - $resultQuery = $this->executeProjectsQuery($slice, $sql, [ - 'actor' => $username, - ]); - - while ($row = $resultQuery->fetchAssociative()) { - $actorIds[$row['dbName']] = (int)$row['actor_id']; - } - } - - return $this->setCache($cacheKey, $actorIds); - } - - /** - * Get revisions by this user across the given Projects. - * @param string[] $dbNames Database names of projects to iterate over. - * @param User $user The user. - * @param int|string $namespace Namespace ID or 'all' for all namespaces. - * @param int|false $start Unix timestamp or false. - * @param int|false $end Unix timestamp or false. - * @param int $limit The maximum number of revisions to fetch from each project. - * @param int|false $offset Unix timestamp. Used for pagination. - * @return array - */ - public function getRevisions( - array $dbNames, - User $user, - int|string $namespace = 'all', - int|false $start = false, - int|false $end = false, - int $limit = 31, // One extra to know whether there should be another page. - int|false $offset = false - ): array { - // Check cache. - $cacheKey = $this->getCacheKey(func_get_args(), 'gc_revisions'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - // Just need any Connection to use the ->quote() method. - $quoteConn = $this->getProjectsConnection('s1'); - $username = $quoteConn->getDatabasePlatform()->quoteStringLiteral($user->getUsername()); - - // IP range handling. - $startIp = ''; - $endIp = ''; - if ($user->isIpRange()) { - [$startIp, $endIp] = IPUtils::parseRange($user->getUsername()); - $startIp = $quoteConn->getDatabasePlatform()->quoteStringLiteral($startIp); - $endIp = $quoteConn->getDatabasePlatform()->quoteStringLiteral($endIp); - } - - // Fetch actor IDs (for IP ranges, it strips trailing zeros and uses a LIKE query). - $actorIds = $this->getDbNamesAndActorIds($user, $dbNames); - - if (!$actorIds) { - return []; - } - - $namespaceCond = 'all' === $namespace - ? '' - : 'AND page_namespace = '.(int)$namespace; - $revDateConditions = $this->getDateConditions($start, $end, $offset, 'revs.', 'rev_timestamp'); - - // Assemble queries. - $queriesBySlice = []; - $projectRepo = $this->caProject->getRepository(); - foreach ($dbNames as $dbName) { - if (isset($actorIds[$dbName])) { - $revisionTable = $projectRepo->getTableName($dbName, 'revision'); - $pageTable = $projectRepo->getTableName($dbName, 'page'); - $commentTable = $projectRepo->getTableName($dbName, 'comment', 'revision'); - $actorTable = $projectRepo->getTableName($dbName, 'actor', 'revision'); - $tagTable = $projectRepo->getTableName($dbName, 'change_tag'); - $tagDefTable = $projectRepo->getTableName($dbName, 'change_tag_def'); - - if ($user->isIpRange()) { - $ipcTable = $projectRepo->getTableName($dbName, 'ip_changes'); - $ipcJoin = "JOIN $ipcTable ON revs.rev_id = ipc_rev_id"; - $whereClause = "ipc_hex BETWEEN $startIp AND $endIp"; - $username = 'actor_name'; - } else { - $ipcJoin = ''; - $whereClause = 'revs.rev_actor = '.$actorIds[$dbName]; - } - - $slice = $this->getDbList()[$dbName]; - $queriesBySlice[$slice][] = " +class GlobalContribsRepository extends Repository { + + /** @var Project CentralAuth project (meta.wikimedia for WMF installation). */ + protected Project $caProject; + + public function __construct( + protected ManagerRegistry $managerRegistry, + protected CacheItemPoolInterface $cache, + protected Client $guzzle, + protected LoggerInterface $logger, + protected ParameterBagInterface $parameterBag, + protected bool $isWMF, + protected int $queryTimeout, + protected ProjectRepository $projectRepo, + string $centralAuthProject + ) { + $this->caProject = new Project( $centralAuthProject ); + $this->caProject->setRepository( $this->projectRepo ); + parent::__construct( $managerRegistry, $cache, $guzzle, $logger, $parameterBag, $isWMF, $queryTimeout ); + } + + /** + * Get a user's edit count for each project. + * @see GlobalContribsRepository::globalEditCountsFromCentralAuth() + * @see GlobalContribsRepository::globalEditCountsFromDatabases() + * @param User $user The user. + * @return ?array Elements are arrays with 'project' (Project), and 'total' (int). Null if anon (too slow). + */ + public function globalEditCounts( User $user ): ?array { + if ( $user->isIP() ) { + return null; + } + + // Get the edit counts from CentralAuth or database. + $editCounts = $this->globalEditCountsFromCentralAuth( $user ); + + // Pre-populate all projects' metadata, to prevent each project call from fetching it. + $this->caProject->getRepository()->getAll(); + + // Compile the output. + $out = []; + foreach ( $editCounts as $editCount ) { + $project = new Project( $editCount['dbName'] ); + $project->setRepository( $this->projectRepo ); + // Make sure the project exists (new projects may not yet be on db replicas). + if ( $project->exists() ) { + $out[] = [ + 'dbName' => $editCount['dbName'], + 'total' => $editCount['total'], + 'project' => $project, + ]; + } + } + return $out; + } + + /** + * Get a user's total edit count on one or more project. + * Requires the CentralAuth extension to be installed on the project. + * @param User $user The user. + * @return array|null Elements are arrays with 'dbName' (string), and 'total' (int). Null for logged out users. + */ + protected function globalEditCountsFromCentralAuth( User $user ): ?array { + if ( $user->isIP() ) { + return null; + } + + // Set up cache. + $cacheKey = $this->getCacheKey( func_get_args(), 'gc_globaleditcounts' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $params = [ + 'meta' => 'globaluserinfo', + 'guiprop' => 'editcount|merged', + 'guiuser' => $user->getUsername(), + ]; + $result = $this->executeApiRequest( $this->caProject, $params ); + if ( !isset( $result['query']['globaluserinfo']['merged'] ) ) { + return []; + } + $out = []; + foreach ( $result['query']['globaluserinfo']['merged'] as $result ) { + $out[] = [ + 'dbName' => $result['wiki'], + 'total' => $result['editcount'], + ]; + } + + // Cache and return. + return $this->setCache( $cacheKey, $out ); + } + + /** + * Loop through the given dbNames and create Project objects for each. + * @param array $dbNames + * @return Project[] Keyed by database name. + */ + private function formatProjects( array $dbNames ): array { + $projects = []; + + foreach ( $dbNames as $dbName ) { + $projects[$dbName] = $this->projectRepo->getProject( $dbName ); + } + + return $projects; + } + + /** + * Get all Projects on which the user has made at least one edit. + * @param User $user + * @return Project[] + */ + public function getProjectsWithEdits( User $user ): array { + if ( $user->isIP() ) { + $dbNames = array_keys( $this->getDbNamesAndActorIds( $user ) ); + } else { + $dbNames = []; + + foreach ( $this->globalEditCountsFromCentralAuth( $user ) as $projectMeta ) { + if ( $projectMeta['total'] > 0 ) { + $dbNames[] = $projectMeta['dbName']; + } + } + } + + return $this->formatProjects( $dbNames ); + } + + /** + * Get projects that the user has made at least one edit on, and the associated actor ID. + * @param User $user + * @param string[]|null $dbNames Loop over these projects instead of all of them. + * @return array Keys are database names, values are actor IDs. + */ + public function getDbNamesAndActorIds( User $user, ?array $dbNames = null ): array { + // Check cache. + $cacheKey = $this->getCacheKey( func_get_args(), 'gc_db_names_actor_ids' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + if ( !$dbNames ) { + $dbNames = array_column( $this->caProject->getRepository()->getAll(), 'dbName' ); + } + + if ( $user->isIpRange() ) { + $username = $user->getIpSubstringFromCidr() . '%'; + $whereClause = "actor_name LIKE :actor"; + } else { + $username = $user->getUsername(); + $whereClause = "actor_name = :actor"; + } + + $queriesBySlice = []; + + foreach ( $dbNames as $dbName ) { + $slice = $this->getDbList()[$dbName]; + // actor_revision table only includes users who have made at least one edit. + $actorTable = $this->getTableName( $dbName, 'actor', 'revision' ); + $queriesBySlice[$slice][] = "SELECT '$dbName' AS `dbName`, actor_id " . + "FROM $actorTable WHERE $whereClause"; + } + + $actorIds = []; + + foreach ( $queriesBySlice as $slice => $queries ) { + $sql = implode( ' UNION ', $queries ); + $resultQuery = $this->executeProjectsQuery( $slice, $sql, [ + 'actor' => $username, + ] ); + + // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition + while ( $row = $resultQuery->fetchAssociative() ) { + $actorIds[$row['dbName']] = (int)$row['actor_id']; + } + } + + return $this->setCache( $cacheKey, $actorIds ); + } + + /** + * Get revisions by this user across the given Projects. + * @param string[] $dbNames Database names of projects to iterate over. + * @param User $user The user. + * @param int|string $namespace Namespace ID or 'all' for all namespaces. + * @param int|false $start Unix timestamp or false. + * @param int|false $end Unix timestamp or false. + * @param int $limit The maximum number of revisions to fetch from each project. + * @param int|false $offset Unix timestamp. Used for pagination. + * @return array + */ + public function getRevisions( + array $dbNames, + User $user, + int|string $namespace = 'all', + int|false $start = false, + int|false $end = false, + // One extra to know whether there should be another page. + int $limit = 31, + int|false $offset = false + ): array { + // Check cache. + $cacheKey = $this->getCacheKey( func_get_args(), 'gc_revisions' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + // Just need any Connection to use the ->quote() method. + $quoteConn = $this->getProjectsConnection( 's1' ); + $username = $quoteConn->getDatabasePlatform()->quoteStringLiteral( $user->getUsername() ); + + // IP range handling. + $startIp = ''; + $endIp = ''; + if ( $user->isIpRange() ) { + [ $startIp, $endIp ] = IPUtils::parseRange( $user->getUsername() ); + $startIp = $quoteConn->getDatabasePlatform()->quoteStringLiteral( $startIp ); + $endIp = $quoteConn->getDatabasePlatform()->quoteStringLiteral( $endIp ); + } + + // Fetch actor IDs (for IP ranges, it strips trailing zeros and uses a LIKE query). + $actorIds = $this->getDbNamesAndActorIds( $user, $dbNames ); + + if ( !$actorIds ) { + return []; + } + + $namespaceCond = $namespace === 'all' ? '' : 'AND page_namespace = ' . (int)$namespace; + $revDateConditions = $this->getDateConditions( $start, $end, $offset, 'revs.', 'rev_timestamp' ); + + // Assemble queries. + $queriesBySlice = []; + $projectRepo = $this->caProject->getRepository(); + foreach ( $dbNames as $dbName ) { + if ( isset( $actorIds[$dbName] ) ) { + $revisionTable = $projectRepo->getTableName( $dbName, 'revision' ); + $pageTable = $projectRepo->getTableName( $dbName, 'page' ); + $commentTable = $projectRepo->getTableName( $dbName, 'comment', 'revision' ); + $actorTable = $projectRepo->getTableName( $dbName, 'actor', 'revision' ); + $tagTable = $projectRepo->getTableName( $dbName, 'change_tag' ); + $tagDefTable = $projectRepo->getTableName( $dbName, 'change_tag_def' ); + + if ( $user->isIpRange() ) { + $ipcTable = $projectRepo->getTableName( $dbName, 'ip_changes' ); + $ipcJoin = "JOIN $ipcTable ON revs.rev_id = ipc_rev_id"; + $whereClause = "ipc_hex BETWEEN $startIp AND $endIp"; + $username = 'actor_name'; + } else { + $ipcJoin = ''; + $whereClause = 'revs.rev_actor = ' . $actorIds[$dbName]; + } + + $slice = $this->getDbList()[$dbName]; + $queriesBySlice[$slice][] = " SELECT '$dbName' AS dbName, revs.rev_id AS id, @@ -313,30 +307,31 @@ public function getRevisions( WHERE $whereClause $namespaceCond $revDateConditions"; - } - } - - // Re-assemble into UNIONed queries, executing as many per slice as possible. - $revisions = []; - foreach ($queriesBySlice as $slice => $queries) { - $sql = "SELECT * FROM ((\n" . join("\n) UNION (\n", $queries) . ")) a ORDER BY timestamp DESC LIMIT $limit"; - $revisions = array_merge($revisions, $this->executeProjectsQuery($slice, $sql)->fetchAllAssociative()); - } - - // If there are more than $limit results, re-sort by timestamp. - if (count($revisions) > $limit) { - usort($revisions, function ($a, $b) { - if ($a['unix_timestamp'] === $b['unix_timestamp']) { - return 0; - } - return $a['unix_timestamp'] > $b['unix_timestamp'] ? -1 : 1; - }); - - // Truncate size to $limit. - $revisions = array_slice($revisions, 0, $limit); - } - - // Cache and return. - return $this->setCache($cacheKey, $revisions); - } + } + } + + // Re-assemble into UNIONed queries, executing as many per slice as possible. + $revisions = []; + foreach ( $queriesBySlice as $slice => $queries ) { + $sql = "SELECT * FROM ((\n" . implode( "\n) UNION (\n", $queries ) . ")) a " . + "ORDER BY timestamp DESC LIMIT $limit"; + $revisions = array_merge( $revisions, $this->executeProjectsQuery( $slice, $sql )->fetchAllAssociative() ); + } + + // If there are more than $limit results, re-sort by timestamp. + if ( count( $revisions ) > $limit ) { + usort( $revisions, static function ( $a, $b ) { + if ( $a['unix_timestamp'] === $b['unix_timestamp'] ) { + return 0; + } + return $a['unix_timestamp'] > $b['unix_timestamp'] ? -1 : 1; + } ); + + // Truncate size to $limit. + $revisions = array_slice( $revisions, 0, $limit ); + } + + // Cache and return. + return $this->setCache( $cacheKey, $revisions ); + } } diff --git a/src/Repository/LargestPagesRepository.php b/src/Repository/LargestPagesRepository.php index 51e2f937e..db9db6508 100644 --- a/src/Repository/LargestPagesRepository.php +++ b/src/Repository/LargestPagesRepository.php @@ -1,6 +1,6 @@ getTableName('page'); + /** + * Fetches the largest pages for the given project. + * @param Project $project + * @param int|string $namespace Namespace ID or 'all' for all namespaces. + * @param string $includePattern Either regular expression (starts/ends with forward slash), + * or a wildcard pattern with % as the wildcard symbol. + * @param string $excludePattern Either regular expression (starts/ends with forward slash), + * or a wildcard pattern with % as the wildcard symbol. + * @return array + */ + public function getData( + Project $project, + int|string $namespace, + string $includePattern, + string $excludePattern + ): array { + $pageTable = $project->getTableName( 'page' ); - $where = ''; - $likeCond = $this->getLikeSql($includePattern, $excludePattern); - $namespaceCond = ''; - if ('all' !== $namespace) { - $namespaceCond = 'page_namespace = :namespace'; - if ($likeCond) { - $namespaceCond .= ' AND '; - } - } - if ($likeCond || $namespaceCond) { - $where = 'WHERE '; - } + $where = ''; + $likeCond = $this->getLikeSql( $includePattern, $excludePattern ); + $namespaceCond = ''; + if ( $namespace !== 'all' ) { + $namespaceCond = 'page_namespace = :namespace'; + if ( $likeCond ) { + $namespaceCond .= ' AND '; + } + } + if ( $likeCond || $namespaceCond ) { + $where = 'WHERE '; + } - $sql = "SELECT page_namespace AS `namespace`, page_title, page_len AS `length` + $sql = "SELECT page_namespace AS `namespace`, page_title, page_len AS `length` FROM $pageTable $where $namespaceCond $likeCond ORDER BY page_len DESC - LIMIT ".self::MAX_ROWS; + LIMIT " . self::MAX_ROWS; - $rows = $this->executeProjectsQuery($project, $sql, [ - 'namespace' => $namespace, - 'include_pattern' => $includePattern, - 'exclude_pattern' => $excludePattern, - ])->fetchAllAssociative(); + $rows = $this->executeProjectsQuery( $project, $sql, [ + 'namespace' => $namespace, + 'include_pattern' => $includePattern, + 'exclude_pattern' => $excludePattern, + ] )->fetchAllAssociative(); - $pages = []; + $pages = []; - foreach ($rows as $row) { - $pages[] = Page::newFromRow($this->pageRepo, $project, $row); - } + foreach ( $rows as $row ) { + $pages[] = Page::newFromRow( $this->pageRepo, $project, $row ); + } - return $pages; - } + return $pages; + } } diff --git a/src/Repository/PageAssessmentsRepository.php b/src/Repository/PageAssessmentsRepository.php index e78eb84d2..f8a5721d8 100644 --- a/src/Repository/PageAssessmentsRepository.php +++ b/src/Repository/PageAssessmentsRepository.php @@ -1,6 +1,6 @@ assessments)) { - $this->assessments = $this->parameterBag->get('assessments'); - } - return $this->assessments[$project->getDomain()] ?? null; - } - - /** - * Get assessment data for the given pages - * @param Page $page - * @param bool $first Fetch only the first result, not for each WikiProject. - * @return string[][] Assessment data as retrieved from the database. - */ - public function getAssessments(Page $page, bool $first = false): array - { - $cacheKey = $this->getCacheKey(func_get_args(), 'page_assessments'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $paTable = $page->getProject()->getTableName('page_assessments'); - $papTable = $page->getProject()->getTableName('page_assessments_projects'); - $pageId = $page->getId(); - - $sql = "SELECT pap_project_title AS wikiproject, pa_class AS class, pa_importance AS importance +class PageAssessmentsRepository extends Repository { + /** @var array The assessments config. */ + protected array $assessments; + + /** + * Get page assessments configuration for the Project. + * @param Project $project + * @return string[]|null As defined in config/assessments.yaml, or null if none exists. + */ + public function getConfig( Project $project ): ?array { + if ( !isset( $this->assessments ) ) { + $this->assessments = $this->parameterBag->get( 'assessments' ); + } + return $this->assessments[$project->getDomain()] ?? null; + } + + /** + * Get assessment data for the given pages + * @param Page $page + * @param bool $first Fetch only the first result, not for each WikiProject. + * @return string[][] Assessment data as retrieved from the database. + */ + public function getAssessments( Page $page, bool $first = false ): array { + $cacheKey = $this->getCacheKey( func_get_args(), 'page_assessments' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $paTable = $page->getProject()->getTableName( 'page_assessments' ); + $papTable = $page->getProject()->getTableName( 'page_assessments_projects' ); + $pageId = $page->getId(); + + $sql = "SELECT pap_project_title AS wikiproject, pa_class AS class, pa_importance AS importance FROM $paTable LEFT JOIN $papTable ON pa_project_id = pap_project_id WHERE pa_page_id = $pageId"; - if ($first) { - $sql .= "\nAND pa_class != '' LIMIT 1"; - } + if ( $first ) { + $sql .= "\nAND pa_class != '' LIMIT 1"; + } - $result = $this->executeProjectsQuery($page->getProject(), $sql)->fetchAllAssociative(); + $result = $this->executeProjectsQuery( $page->getProject(), $sql )->fetchAllAssociative(); - // Cache and return. - return $this->setCache($cacheKey, $result); - } + // Cache and return. + return $this->setCache( $cacheKey, $result ); + } } diff --git a/src/Repository/PageInfoRepository.php b/src/Repository/PageInfoRepository.php index 5abf4ad08..ac77e0032 100644 --- a/src/Repository/PageInfoRepository.php +++ b/src/Repository/PageInfoRepository.php @@ -1,6 +1,6 @@ maxPageRevisions)) { - $this->maxPageRevisions = (int)$this->parameterBag->get('app.max_page_revisions'); - } - return $this->maxPageRevisions; - } - - /** - * Factory to instantiate a new Edit for the given revision. - * @param Page $page - * @param array $revision - * @return Edit - */ - public function getEdit(Page $page, array $revision): Edit - { - return new Edit($this->editRepo, $this->userRepo, $page, $revision); - } - - /** - * Get the number of edits made to the page by bots or former bots. - * @param Page $page - * @param false|int $start - * @param false|int $end - * @param ?int $limit - * @param bool $count Return a count rather than the full set of rows. - * @return array with rows with keys 'count', 'username' and 'current'. - */ - public function getBotData(Page $page, false|int $start, false|int $end, ?int $limit, bool $count = false): array - { - $cacheKey = $this->getCacheKey(func_get_args(), 'page_bot_data'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getitem($cacheKey)->get(); - } - - $project = $page->getProject(); - $revTable = $project->getTableName('revision'); - $userGroupsTable = $project->getTableName('user_groups'); - $userFormerGroupsTable = $project->getTableName('user_former_groups'); - $actorTable = $project->getTableName('actor', 'revision'); - - $datesConditions = $this->getDateConditions($start, $end); - - if ($count) { - $actorSelect = ''; - $groupBy = ''; - } else { - $actorSelect = 'actor_name AS username, '; - $groupBy = 'GROUP BY actor_user'; - } - - $limitClause = ''; - if (null !== $limit) { - $limitClause = "LIMIT $limit"; - } - - $sql = "SELECT COUNT(DISTINCT rev_id) AS `count`, $actorSelect '0' AS `current` +class PageInfoRepository extends AutoEditsRepository { + /** @var int Maximum number of revisions to process, as configured via APP_MAX_PAGE_REVISIONS */ + protected int $maxPageRevisions; + + public function __construct( + protected ManagerRegistry $managerRegistry, + protected CacheItemPoolInterface $cache, + protected Client $guzzle, + protected LoggerInterface $logger, + protected ParameterBagInterface $parameterBag, + protected bool $isWMF, + protected int $queryTimeout, + protected EditRepository $editRepo, + protected UserRepository $userRepo, + protected ProjectRepository $projectRepo, + protected AutomatedEditsHelper $autoEditsHelper, + protected ?RequestStack $requestStack + ) { + parent::__construct( + $managerRegistry, + $cache, + $guzzle, + $logger, + $parameterBag, + $isWMF, + $queryTimeout, + $projectRepo, + $autoEditsHelper, + $requestStack + ); + } + + /** + * Get the performance maximum on the number of revisions to process. + * @return int + */ + public function getMaxPageRevisions(): int { + if ( !isset( $this->maxPageRevisions ) ) { + $this->maxPageRevisions = (int)$this->parameterBag->get( 'app.max_page_revisions' ); + } + return $this->maxPageRevisions; + } + + /** + * Factory to instantiate a new Edit for the given revision. + * @param Page $page + * @param array $revision + * @return Edit + */ + public function getEdit( Page $page, array $revision ): Edit { + return new Edit( $this->editRepo, $this->userRepo, $page, $revision ); + } + + /** + * Get the number of edits made to the page by bots or former bots. + * @param Page $page + * @param false|int $start + * @param false|int $end + * @param ?int $limit + * @param bool $count Return a count rather than the full set of rows. + * @return array with rows with keys 'count', 'username' and 'current'. + */ + public function getBotData( + Page $page, false|int $start, false|int $end, ?int $limit, bool $count = false + ): array { + $cacheKey = $this->getCacheKey( func_get_args(), 'page_bot_data' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getitem( $cacheKey )->get(); + } + + $project = $page->getProject(); + $revTable = $project->getTableName( 'revision' ); + $userGroupsTable = $project->getTableName( 'user_groups' ); + $userFormerGroupsTable = $project->getTableName( 'user_former_groups' ); + $actorTable = $project->getTableName( 'actor', 'revision' ); + + $datesConditions = $this->getDateConditions( $start, $end ); + + if ( $count ) { + $actorSelect = ''; + $groupBy = ''; + } else { + $actorSelect = 'actor_name AS username, '; + $groupBy = 'GROUP BY actor_user'; + } + + $limitClause = ''; + if ( $limit !== null ) { + $limitClause = "LIMIT $limit"; + } + + $sql = "SELECT COUNT(DISTINCT rev_id) AS `count`, $actorSelect '0' AS `current` FROM ( SELECT rev_id, rev_actor, rev_timestamp FROM $revTable @@ -137,56 +135,54 @@ public function getBotData(Page $page, false|int $start, false|int $end, ?int $l WHERE ug_group = 'bot' $datesConditions $groupBy"; - $statement = $this->executeProjectsQuery($project, $sql, ['pageId' => $page->getId()]) - ->fetchAllAssociative(); - return $this->setCache($cacheKey, $statement); - } - - /** - * Get prior deletions, page moves, and protections to the page. - * @param Page $page - * @param false|int $start - * @param false|int $end - * @return string[] each entry with keys 'log_action', 'log_type' and 'timestamp'. - */ - public function getLogEvents(Page $page, false|int $start, false|int $end): array - { - $cacheKey = $this->getCacheKey(func_get_args(), 'page_logevents'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - $loggingTable = $page->getProject()->getTableName('logging', 'logindex'); - - $datesConditions = $this->getDateConditions($start, $end, false, '', 'log_timestamp'); - - $sql = "SELECT log_action, log_type, log_timestamp AS 'timestamp' + $statement = $this->executeProjectsQuery( $project, $sql, [ 'pageId' => $page->getId() ] ) + ->fetchAllAssociative(); + return $this->setCache( $cacheKey, $statement ); + } + + /** + * Get prior deletions, page moves, and protections to the page. + * @param Page $page + * @param false|int $start + * @param false|int $end + * @return string[] each entry with keys 'log_action', 'log_type' and 'timestamp'. + */ + public function getLogEvents( Page $page, false|int $start, false|int $end ): array { + $cacheKey = $this->getCacheKey( func_get_args(), 'page_logevents' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + $loggingTable = $page->getProject()->getTableName( 'logging', 'logindex' ); + + $datesConditions = $this->getDateConditions( $start, $end, false, '', 'log_timestamp' ); + + $sql = "SELECT log_action, log_type, log_timestamp AS 'timestamp' FROM $loggingTable WHERE log_namespace = '" . $page->getNamespace() . "' AND log_title = :title AND log_timestamp > 1 $datesConditions AND log_type IN ('delete', 'move', 'protect', 'stable')"; - $title = str_replace(' ', '_', $page->getTitle()); - - $result = $this->executeProjectsQuery($page->getProject(), $sql, ['title' => $title]) - ->fetchAllAssociative(); - return $this->setCache($cacheKey, $result); - } - - /** - * Get the number of categories, templates, and files that are on the page. - * @param Page $page - * @return array With keys 'categories', 'templates' and 'files'. - */ - public function getTransclusionData(Page $page): array - { - $cacheKey = $this->getCacheKey(func_get_args(), 'page_transclusions'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $categorylinksTable = $page->getProject()->getTableName('categorylinks'); - $templatelinksTable = $page->getProject()->getTableName('templatelinks'); - $imagelinksTable = $page->getProject()->getTableName('imagelinks'); - $sql = "( + $title = str_replace( ' ', '_', $page->getTitle() ); + + $result = $this->executeProjectsQuery( $page->getProject(), $sql, [ 'title' => $title ] ) + ->fetchAllAssociative(); + return $this->setCache( $cacheKey, $result ); + } + + /** + * Get the number of categories, templates, and files that are on the page. + * @param Page $page + * @return array With keys 'categories', 'templates' and 'files'. + */ + public function getTransclusionData( Page $page ): array { + $cacheKey = $this->getCacheKey( func_get_args(), 'page_transclusions' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $categorylinksTable = $page->getProject()->getTableName( 'categorylinks' ); + $templatelinksTable = $page->getProject()->getTableName( 'templatelinks' ); + $imagelinksTable = $page->getProject()->getTableName( 'imagelinks' ); + $sql = "( SELECT 'categories' AS `key`, COUNT(*) AS val FROM $categorylinksTable WHERE cl_from = :pageId @@ -199,73 +195,73 @@ public function getTransclusionData(Page $page): array FROM $imagelinksTable WHERE il_from = :pageId )"; - $resultQuery = $this->executeProjectsQuery($page->getProject(), $sql, ['pageId' => $page->getId()]); - $transclusionCounts = []; - - while ($result = $resultQuery->fetchAssociative()) { - $transclusionCounts[$result['key']] = (int)$result['val']; - } - - return $this->setCache($cacheKey, $transclusionCounts); - } - - /** - * Get the number of subpages - * @param Page $page - * @return int - */ - public function getSubpageCount(Page $page): int - { - $cacheKey = $this->getCacheKey(func_get_args(), 'page_subpagecount'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $project = $page->getProject(); - $pageTable = $project->getTableName('page'); - $title = str_replace(' ', '_', $page->getTitleWithoutNamespace()); - $ns = $page->getNamespace(); - - $sql = "SELECT COUNT(page_id) as `count` + $resultQuery = $this->executeProjectsQuery( $page->getProject(), $sql, [ 'pageId' => $page->getId() ] ); + $transclusionCounts = []; + + // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition + while ( $result = $resultQuery->fetchAssociative() ) { + $transclusionCounts[$result['key']] = (int)$result['val']; + } + + return $this->setCache( $cacheKey, $transclusionCounts ); + } + + /** + * Get the number of subpages + * @param Page $page + * @return int + */ + public function getSubpageCount( Page $page ): int { + $cacheKey = $this->getCacheKey( func_get_args(), 'page_subpagecount' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $project = $page->getProject(); + $pageTable = $project->getTableName( 'page' ); + $title = str_replace( ' ', '_', $page->getTitleWithoutNamespace() ); + $ns = $page->getNamespace(); + + $sql = "SELECT COUNT(page_id) as `count` FROM $pageTable WHERE page_title LIKE :title AND page_namespace = :namespace"; - $result = $this->executeProjectsQuery($project, $sql, ['title' => $title . '/%', 'namespace' => $ns]) - ->fetchAllAssociative(); - - return $this->setCache($cacheKey, $result[0]['count']); - } - - /** - * Get the top editors to the page by edit count. - * @param Page $page - * @param false|int $start - * @param false|int $end - * @param int $limit - * @param bool $noBots - * @return array - */ - public function getTopEditorsByEditCount( - Page $page, - false|int $start = false, - false|int $end = false, - int $limit = 20, - bool $noBots = false - ): array { - $cacheKey = $this->getCacheKey(func_get_args(), 'page_topeditors'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $project = $page->getProject(); - // Faster to use revision instead of revision_userindex in this case. - $revTable = $project->getTableName('revision', ''); - $actorTable = $project->getTableName('actor'); - - $dateConditions = $this->getDateConditions($start, $end); - - $sql = "SELECT actor_name AS username, + $result = $this->executeProjectsQuery( $project, $sql, [ 'title' => $title . '/%', 'namespace' => $ns ] ) + ->fetchAllAssociative(); + + return $this->setCache( $cacheKey, $result[0]['count'] ); + } + + /** + * Get the top editors to the page by edit count. + * @param Page $page + * @param false|int $start + * @param false|int $end + * @param int $limit + * @param bool $noBots + * @return array + */ + public function getTopEditorsByEditCount( + Page $page, + false|int $start = false, + false|int $end = false, + int $limit = 20, + bool $noBots = false + ): array { + $cacheKey = $this->getCacheKey( func_get_args(), 'page_topeditors' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $project = $page->getProject(); + // Faster to use revision instead of revision_userindex in this case. + $revTable = $project->getTableName( 'revision', '' ); + $actorTable = $project->getTableName( 'actor' ); + + $dateConditions = $this->getDateConditions( $start, $end ); + + $sql = "SELECT actor_name AS username, COUNT(rev_id) AS count, SUM(rev_minor_edit) AS minor, MIN(rev_timestamp) AS first_timestamp, @@ -276,51 +272,50 @@ public function getTopEditorsByEditCount( JOIN $actorTable ON rev_actor = actor_id WHERE rev_page = :pageId $dateConditions"; - if ($noBots) { - $userGroupsTable = $project->getTableName('user_groups'); - $sql .= "AND NOT EXISTS ( + if ( $noBots ) { + $userGroupsTable = $project->getTableName( 'user_groups' ); + $sql .= "AND NOT EXISTS ( SELECT 1 FROM $userGroupsTable WHERE ug_user = actor_user AND ug_group = 'bot' )"; - } + } - $sql .= "GROUP BY actor_id + $sql .= "GROUP BY actor_id ORDER BY count DESC LIMIT $limit"; - $result = $this->executeProjectsQuery($project, $sql, [ - 'pageId' => $page->getId(), - ])->fetchAllAssociative(); - - return $this->setCache($cacheKey, $result); - } - - /** - * Get various basic info used in the API, including the number of revisions, unique authors, initial author - * and edit count of the initial author. This is combined into one query for better performance. Caching is only - * applied if it took considerable time to process, because using the gadget, this will get hit for a different page - * constantly, where the likelihood of cache benefiting us is slim. - * @param Page $page The page. - * @return string[]|false false if the page was not found. - */ - public function getBasicEditingInfo(Page $page): array|false - { - $cacheKey = $this->getCacheKey(func_get_args(), 'page_basicinfo'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $project = $page->getProject(); - $revTable = $project->getTableName('revision'); - // Needed because userindex is missing some revdeleted rows - $revWithoutExtension = $project->getTableName('revision', ''); - $userTable = $project->getTableName('user'); - $pageTable = $project->getTableName('page'); - $actorTable = $project->getTableName('actor'); - - $sql = "SELECT *, ( + $result = $this->executeProjectsQuery( $project, $sql, [ + 'pageId' => $page->getId(), + ] )->fetchAllAssociative(); + + return $this->setCache( $cacheKey, $result ); + } + + /** + * Get various basic info used in the API, including the number of revisions, unique authors, initial author + * and edit count of the initial author. This is combined into one query for better performance. Caching is only + * applied if it took considerable time to process, because using the gadget, this will get hit for a different page + * constantly, where the likelihood of cache benefiting us is slim. + * @param Page $page The page. + * @return string[]|false false if the page was not found. + */ + public function getBasicEditingInfo( Page $page ): array|false { + $cacheKey = $this->getCacheKey( func_get_args(), 'page_basicinfo' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $project = $page->getProject(); + $revTable = $project->getTableName( 'revision' ); + // Needed because userindex is missing some revdeleted rows + $revWithoutExtension = $project->getTableName( 'revision', '' ); + $userTable = $project->getTableName( 'user' ); + $pageTable = $project->getTableName( 'page' ); + $actorTable = $project->getTableName( 'actor' ); + + $sql = "SELECT *, ( SELECT user_editcount FROM $userTable WHERE user_id = creator_user_id @@ -360,65 +355,64 @@ public function getBasicEditingInfo(Page $page): array|false AND rev_timestamp > 0 # Protects from weird revs with rev_timestamp containing only null bytes ) c )"; - $params = ['pageid' => $page->getId()]; - - // Get current time so we can compare timestamps - // and decide whether or to cache the result. - $time1 = time(); - - /** - * This query can sometimes take too long to run for pages with tens of thousands - * of revisions. This query is used by the PageInfo gadget, which shows basic - * data in real-time, so if it takes too long than the user probably didn't even - * wait to see the result. We'll pass 60 as the last parameter to executeProjectsQuery, - * which will set the max_statement_time to 60 seconds. - */ - $result = $this->executeProjectsQuery($project, $sql, $params, 60)->fetchAssociative(); - - $time2 = time(); - - // If it took over 5 seconds, cache the result for 20 minutes. - if ($time2 - $time1 > 5) { - $this->setCache($cacheKey, $result, 'PT20M'); - } - - return $result ?? false; - } - - /** - * Get counts of (semi-)automated tools that were used to edit the page. - * @param Page $page - * @param false|int $start - * @param false|int $end - * @return array - */ - public function getAutoEditsCounts(Page $page, false|int $start, false|int $end): array - { - $cacheKey = $this->getCacheKey(func_get_args(), 'user_autoeditcount'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $project = $page->getProject(); - $tools = $this->getTools($project); - $queries = []; - $revisionTable = $project->getTableName('revision', ''); - $pageTable = $project->getTableName('page'); - $pageJoin = "LEFT JOIN $pageTable ON rev_page = page_id"; - $revDateConditions = $this->getDateConditions($start, $end); - $conn = $this->getProjectsConnection($project); - - foreach ($tools as $toolName => $values) { - [$condTool, $commentJoin, $tagJoin] = $this->getInnerAutomatedCountsSql($project, $toolName, $values); - $toolName = $conn->getDatabasePlatform()->quoteStringLiteral($toolName); - - // No regex or tag provided for this tool. This can happen for tag-only tools that are in the global - // configuration, but no local tag exists on the said project. - if ('' === $condTool) { - continue; - } - - $queries[] .= " + $params = [ 'pageid' => $page->getId() ]; + + // Get current time so we can compare timestamps + // and decide whether or to cache the result. + $time1 = time(); + + /** + * This query can sometimes take too long to run for pages with tens of thousands + * of revisions. This query is used by the PageInfo gadget, which shows basic + * data in real-time, so if it takes too long than the user probably didn't even + * wait to see the result. We'll pass 60 as the last parameter to executeProjectsQuery, + * which will set the max_statement_time to 60 seconds. + */ + $result = $this->executeProjectsQuery( $project, $sql, $params, 60 )->fetchAssociative(); + + $time2 = time(); + + // If it took over 5 seconds, cache the result for 20 minutes. + if ( $time2 - $time1 > 5 ) { + $this->setCache( $cacheKey, $result, 'PT20M' ); + } + + return $result ?? false; + } + + /** + * Get counts of (semi-)automated tools that were used to edit the page. + * @param Page $page + * @param false|int $start + * @param false|int $end + * @return array + */ + public function getAutoEditsCounts( Page $page, false|int $start, false|int $end ): array { + $cacheKey = $this->getCacheKey( func_get_args(), 'user_autoeditcount' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $project = $page->getProject(); + $tools = $this->getTools( $project ); + $queries = []; + $revisionTable = $project->getTableName( 'revision', '' ); + $pageTable = $project->getTableName( 'page' ); + $pageJoin = "LEFT JOIN $pageTable ON rev_page = page_id"; + $revDateConditions = $this->getDateConditions( $start, $end ); + $conn = $this->getProjectsConnection( $project ); + + foreach ( $tools as $toolName => $values ) { + [ $condTool, $commentJoin, $tagJoin ] = $this->getInnerAutomatedCountsSql( $project, $toolName, $values ); + $toolName = $conn->getDatabasePlatform()->quoteStringLiteral( $toolName ); + + // No regex or tag provided for this tool. This can happen for tag-only tools that are in the global + // configuration, but no local tag exists on the said project. + if ( $condTool === '' ) { + continue; + } + + $queries[] .= " SELECT $toolName AS toolname, COUNT(DISTINCT(rev_id)) AS count FROM $revisionTable $pageJoin @@ -427,32 +421,33 @@ public function getAutoEditsCounts(Page $page, false|int $start, false|int $end) WHERE $condTool AND rev_page = :pageId $revDateConditions"; - } - - $sql = implode(' UNION ', $queries); - $resultQuery = $this->executeProjectsQuery($project, $sql, [ - 'pageId' => $page->getId(), - ]); - - $results = []; - - while ($row = $resultQuery->fetchAssociative()) { - // Only track tools that they've used at least once - $tool = $row['toolname']; - if ($row['count'] > 0) { - $results[$tool] = [ - 'link' => $tools[$tool]['link'], - 'label' => $tools[$tool]['label'] ?? $tool, - 'count' => $row['count'], - ]; - } - } - - // Sort the array by count - uasort($results, function ($a, $b) { - return $b['count'] - $a['count']; - }); - - return $this->setCache($cacheKey, $results); - } + } + + $sql = implode( ' UNION ', $queries ); + $resultQuery = $this->executeProjectsQuery( $project, $sql, [ + 'pageId' => $page->getId(), + ] ); + + $results = []; + + // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition + while ( $row = $resultQuery->fetchAssociative() ) { + // Only track tools that they've used at least once + $tool = $row['toolname']; + if ( $row['count'] > 0 ) { + $results[$tool] = [ + 'link' => $tools[$tool]['link'], + 'label' => $tools[$tool]['label'] ?? $tool, + 'count' => $row['count'], + ]; + } + } + + // Sort the array by count + uasort( $results, static function ( $a, $b ) { + return $b['count'] - $a['count']; + } ); + + return $this->setCache( $cacheKey, $results ); + } } diff --git a/src/Repository/PageRepository.php b/src/Repository/PageRepository.php index e0ed13547..f5e07203a 100644 --- a/src/Repository/PageRepository.php +++ b/src/Repository/PageRepository.php @@ -1,6 +1,6 @@ getPagesInfo($project, [$pageTitle]); - return null !== $info ? array_shift($info) : null; - } - - /** - * Get metadata about a set of pages from the API. - * @param Project $project The project to which the pages belong. - * @param string[] $pageTitles Array of page titles. - * @return array|null Array keyed by the page names, each element with some of the following keys: pageid, - * title, missing, displaytitle, url. Returns null if page does not exist. - */ - public function getPagesInfo(Project $project, array $pageTitles): ?array - { - $params = [ - 'prop' => 'info|pageprops', - 'inprop' => 'protection|talkid|watched|watchers|notificationtimestamp|subjectid|url|displaytitle', - 'converttitles' => '', - 'titles' => join('|', $pageTitles), - 'formatversion' => 2, - ]; - - $res = $this->executeApiRequest($project, $params); - $result = []; - if (isset($res['query']['pages'])) { - foreach ($res['query']['pages'] as $pageInfo) { - $result[$pageInfo['title']] = $pageInfo; - } - } else { - return null; - } - return $result; - } - - /** - * Get the full page text of a set of pages. - * @param Project $project The project to which the pages belong. - * @param string[] $pageTitles Array of page titles. - * @return string[] Array keyed by the page names, with the page text as the values. - */ - public function getPagesWikitext(Project $project, array $pageTitles): array - { - $params = [ - 'prop' => 'revisions', - 'rvprop' => 'content', - 'titles' => join('|', $pageTitles), - 'formatversion' => 2, - ]; - $res = $this->executeApiRequest($project, $params); - $result = []; - - if (!isset($res['query']['pages'])) { - return []; - } - - foreach ($res['query']['pages'] as $page) { - if (isset($page['revisions'][0]['content'])) { - $result[$page['title']] = $page['revisions'][0]['content']; - } else { - $result[$page['title']] = ''; - } - } - - return $result; - } - - /** - * Get revisions of a single page. - * @param Page $page The page. - * @param User|null $user Specify to get only revisions by the given user. - * @param false|int $start - * @param false|int $end - * @param int|null $limit - * @param int|null $numRevisions - * @return string[] Each member with keys: id, timestamp, length, - * minor, length_change, user_id, username, comment, sha, deleted, tags. - */ - public function getRevisions( - Page $page, - ?User $user = null, - false|int $start = false, - false|int $end = false, - ?int $limit = null, - ?int $numRevisions = null - ): array { - $cacheKey = $this->getCacheKey(func_get_args(), 'page_revisions'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $stmt = $this->getRevisionsStmt($page, $user, $limit, $numRevisions, $start, $end); - $result = $stmt->fetchAllAssociative(); - - // Cache and return. - return $this->setCache($cacheKey, $result); - } - - /** - * Get the statement for a single revision, so that you can iterate row by row. - * @param Page $page The page. - * @param User|null $user Specify to get only revisions by the given user. - * @param ?int $limit Max number of revisions to process. - * @param ?int $numRevisions Number of revisions, if known. This is used solely to determine the - * OFFSET if we are given a $limit (see below). If $limit is set and $numRevisions is not set, - * a separate query is ran to get the number of revisions. - * @param false|int $start - * @param false|int $end - * @return Result - */ - public function getRevisionsStmt( - Page $page, - ?User $user = null, - ?int $limit = null, - ?int $numRevisions = null, - false|int $start = false, - false|int $end = false - ): Result { - $revTable = $this->getTableName( - $page->getProject()->getDatabaseName(), - 'revision', - $user ? null : '' // Use 'revision' if there's no user, otherwise default to revision_userindex - ); - $slotsTable = $page->getProject()->getTableName('slots'); - $contentTable = $page->getProject()->getTableName('content'); - $commentTable = $page->getProject()->getTableName('comment'); - $actorTable = $page->getProject()->getTableName('actor'); - $ctTable = $page->getProject()->getTableName('change_tag'); - $ctdTable = $page->getProject()->getTableName('change_tag_def'); - $userClause = $user ? "revs.rev_actor = :actorId AND " : ""; - - $limitClause = ''; - if (intval($limit) > 0 && isset($numRevisions)) { - $limitClause = "LIMIT $limit"; - } - - $dateConditions = $this->getDateConditions($start, $end, false, 'revs.'); - - $sql = "SELECT * FROM ( +class PageRepository extends Repository { + /** + * Get metadata about a single page from the API. + * @param Project $project The project to which the page belongs. + * @param string $pageTitle Page title. + * @return string[]|null Array with some of the following keys: pageid, title, missing, displaytitle, url. + * Returns null if page does not exist. + */ + public function getPageInfo( Project $project, string $pageTitle ): ?array { + $info = $this->getPagesInfo( $project, [ $pageTitle ] ); + return $info !== null ? array_shift( $info ) : null; + } + + /** + * Get metadata about a set of pages from the API. + * @param Project $project The project to which the pages belong. + * @param string[] $pageTitles Array of page titles. + * @return array|null Array keyed by the page names, each element with some of the following keys: pageid, + * title, missing, displaytitle, url. Returns null if page does not exist. + */ + public function getPagesInfo( Project $project, array $pageTitles ): ?array { + $params = [ + 'prop' => 'info|pageprops', + 'inprop' => 'protection|talkid|watched|watchers|notificationtimestamp|subjectid|url|displaytitle', + 'converttitles' => '', + 'titles' => implode( '|', $pageTitles ), + 'formatversion' => 2, + ]; + + $res = $this->executeApiRequest( $project, $params ); + $result = []; + if ( isset( $res['query']['pages'] ) ) { + foreach ( $res['query']['pages'] as $pageInfo ) { + $result[$pageInfo['title']] = $pageInfo; + } + } else { + return null; + } + return $result; + } + + /** + * Get the full page text of a set of pages. + * @param Project $project The project to which the pages belong. + * @param string[] $pageTitles Array of page titles. + * @return string[] Array keyed by the page names, with the page text as the values. + */ + public function getPagesWikitext( Project $project, array $pageTitles ): array { + $params = [ + 'prop' => 'revisions', + 'rvprop' => 'content', + 'titles' => implode( '|', $pageTitles ), + 'formatversion' => 2, + ]; + $res = $this->executeApiRequest( $project, $params ); + $result = []; + + if ( !isset( $res['query']['pages'] ) ) { + return []; + } + + foreach ( $res['query']['pages'] as $page ) { + if ( isset( $page['revisions'][0]['content'] ) ) { + $result[$page['title']] = $page['revisions'][0]['content']; + } else { + $result[$page['title']] = ''; + } + } + + return $result; + } + + /** + * Get revisions of a single page. + * @param Page $page The page. + * @param User|null $user Specify to get only revisions by the given user. + * @param false|int $start + * @param false|int $end + * @param int|null $limit + * @param int|null $numRevisions + * @return string[] Each member with keys: id, timestamp, length, + * minor, length_change, user_id, username, comment, sha, deleted, tags. + */ + public function getRevisions( + Page $page, + ?User $user = null, + false|int $start = false, + false|int $end = false, + ?int $limit = null, + ?int $numRevisions = null + ): array { + $cacheKey = $this->getCacheKey( func_get_args(), 'page_revisions' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $stmt = $this->getRevisionsStmt( $page, $user, $limit, $numRevisions, $start, $end ); + $result = $stmt->fetchAllAssociative(); + + // Cache and return. + return $this->setCache( $cacheKey, $result ); + } + + /** + * Get the statement for a single revision, so that you can iterate row by row. + * @param Page $page The page. + * @param User|null $user Specify to get only revisions by the given user. + * @param ?int $limit Max number of revisions to process. + * @param ?int $numRevisions Number of revisions, if known. This is used solely to determine the + * OFFSET if we are given a $limit (see below). If $limit is set and $numRevisions is not set, + * a separate query is ran to get the number of revisions. + * @param false|int $start + * @param false|int $end + * @return Result + */ + public function getRevisionsStmt( + Page $page, + ?User $user = null, + ?int $limit = null, + ?int $numRevisions = null, + false|int $start = false, + false|int $end = false + ): Result { + $revTable = $this->getTableName( + $page->getProject()->getDatabaseName(), + 'revision', + // Use 'revision' if there's no user, otherwise default to revision_userindex + $user ? null : '' + ); + $slotsTable = $page->getProject()->getTableName( 'slots' ); + $contentTable = $page->getProject()->getTableName( 'content' ); + $commentTable = $page->getProject()->getTableName( 'comment' ); + $actorTable = $page->getProject()->getTableName( 'actor' ); + $ctTable = $page->getProject()->getTableName( 'change_tag' ); + $ctdTable = $page->getProject()->getTableName( 'change_tag_def' ); + $userClause = $user ? "revs.rev_actor = :actorId AND " : ""; + + $limitClause = ''; + if ( intval( $limit ) > 0 && isset( $numRevisions ) ) { + $limitClause = "LIMIT $limit"; + } + + $dateConditions = $this->getDateConditions( $start, $end, false, 'revs.' ); + + $sql = "SELECT * FROM ( SELECT revs.rev_id AS `id`, revs.rev_timestamp AS `timestamp`, @@ -199,69 +196,68 @@ public function getRevisionsStmt( ) a ORDER BY `timestamp` ASC"; - $params = ['pageid' => $page->getId()]; - if ($user) { - $params['actorId'] = $user->getActorId($page->getProject()); - } - - return $this->executeProjectsQuery($page->getProject(), $sql, $params); - } - - /** - * Get a count of the number of revisions of a single page - * @param Page $page The page. - * @param User|null $user Specify to only count revisions by the given user. - * @param false|int $start - * @param false|int $end - * @return int - */ - public function getNumRevisions( - Page $page, - ?User $user = null, - false|int $start = false, - false|int $end = false - ): int { - $cacheKey = $this->getCacheKey(func_get_args(), 'page_numrevisions'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - // In this case revision is faster than revision_userindex if we're not querying by user. - $revTable = $page->getProject()->getTableName( - 'revision', - $user && $this->isWMF ? '_userindex' : '' - ); - $userClause = $user ? "rev_actor = :actorId AND " : ""; - - $dateConditions = $this->getDateConditions($start, $end); - - $sql = "SELECT COUNT(*) + $params = [ 'pageid' => $page->getId() ]; + if ( $user ) { + $params['actorId'] = $user->getActorId( $page->getProject() ); + } + + return $this->executeProjectsQuery( $page->getProject(), $sql, $params ); + } + + /** + * Get a count of the number of revisions of a single page + * @param Page $page The page. + * @param User|null $user Specify to only count revisions by the given user. + * @param false|int $start + * @param false|int $end + * @return int + */ + public function getNumRevisions( + Page $page, + ?User $user = null, + false|int $start = false, + false|int $end = false + ): int { + $cacheKey = $this->getCacheKey( func_get_args(), 'page_numrevisions' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + // In this case revision is faster than revision_userindex if we're not querying by user. + $revTable = $page->getProject()->getTableName( + 'revision', + $user && $this->isWMF ? '_userindex' : '' + ); + $userClause = $user ? "rev_actor = :actorId AND " : ""; + + $dateConditions = $this->getDateConditions( $start, $end ); + + $sql = "SELECT COUNT(*) FROM $revTable WHERE $userClause rev_page = :pageid $dateConditions"; - $params = ['pageid' => $page->getId()]; - if ($user) { - $params['rev_actor'] = $user->getActorId($page->getProject()); - } - - $result = (int)$this->executeProjectsQuery($page->getProject(), $sql, $params)->fetchOne(); - - // Cache and return. - return $this->setCache($cacheKey, $result); - } - - /** - * Get any CheckWiki errors of a single page - * @param Page $page - * @return array Results from query - */ - public function getCheckWikiErrors(Page $page): array - { - // Only support mainspace on Labs installations - if (0 !== $page->getNamespace() || !$this->isWMF) { - return []; - } - - $sql = "SELECT error, notice, found, name_trans AS name, prio, text_trans AS explanation + $params = [ 'pageid' => $page->getId() ]; + if ( $user ) { + $params['rev_actor'] = $user->getActorId( $page->getProject() ); + } + + $result = (int)$this->executeProjectsQuery( $page->getProject(), $sql, $params )->fetchOne(); + + // Cache and return. + return $this->setCache( $cacheKey, $result ); + } + + /** + * Get any CheckWiki errors of a single page + * @param Page $page + * @return array Results from query + */ + public function getCheckWikiErrors( Page $page ): array { + // Only support mainspace on Labs installations + if ( $page->getNamespace() !== 0 || !$this->isWMF ) { + return []; + } + + $sql = "SELECT error, notice, found, name_trans AS name, prio, text_trans AS explanation FROM s51080__checkwiki_p.cw_error a JOIN s51080__checkwiki_p.cw_overview_errors b WHERE a.project = b.project @@ -270,57 +266,55 @@ public function getCheckWikiErrors(Page $page): array AND a.error = b.id AND a.ok = 0"; - // remove _p if present - $dbName = preg_replace('/_p$/', '', $page->getProject()->getDatabaseName()); - - // Page title without underscores (str_replace just to be sure) - $pageTitle = str_replace('_', ' ', $page->getTitle()); - - return $this->getToolsConnection()->executeQuery($sql, [ - 'dbName' => $dbName, - 'title' => $pageTitle, - ])->fetchAllAssociative(); - } - - /** - * Get or count all wikidata items for the given page, not just languages of sister projects. - * @param Page $page - * @param bool $count Set to true to get only a COUNT - * @return string[]|int Records as returned by the DB, or raw COUNT of the records. - */ - public function getWikidataItems(Page $page, bool $count = false): array|int - { - if (!$page->getWikidataId()) { - return $count ? 0 : []; - } - - $wikidataId = ltrim($page->getWikidataId(), 'Q'); - - $sql = "SELECT " . ($count ? 'COUNT(*) AS count' : '*') . " + // remove _p if present + $dbName = preg_replace( '/_p$/', '', $page->getProject()->getDatabaseName() ); + + // Page title without underscores (str_replace just to be sure) + $pageTitle = str_replace( '_', ' ', $page->getTitle() ); + + return $this->getToolsConnection()->executeQuery( $sql, [ + 'dbName' => $dbName, + 'title' => $pageTitle, + ] )->fetchAllAssociative(); + } + + /** + * Get or count all wikidata items for the given page, not just languages of sister projects. + * @param Page $page + * @param bool $count Set to true to get only a COUNT + * @return string[]|int Records as returned by the DB, or raw COUNT of the records. + */ + public function getWikidataItems( Page $page, bool $count = false ): array|int { + if ( !$page->getWikidataId() ) { + return $count ? 0 : []; + } + + $wikidataId = ltrim( $page->getWikidataId(), 'Q' ); + + $sql = "SELECT " . ( $count ? 'COUNT(*) AS count' : '*' ) . " FROM wikidatawiki_p.wb_items_per_site WHERE ips_item_id = :wikidataId"; - $result = $this->executeProjectsQuery('wikidatawiki', $sql, [ - 'wikidataId' => $wikidataId, - ])->fetchAllAssociative(); - - return $count ? (int) $result[0]['count'] : $result; - } - - /** - * Get number of in and outgoing links and redirects to the given page. - * @param Page $page - * @return string[] Counts with the keys 'links_ext_count', 'links_out_count', - * 'links_in_count' and 'redirects_count' - */ - public function countLinksAndRedirects(Page $page): array - { - $externalLinksTable = $page->getProject()->getTableName('externallinks'); - $pageLinksTable = $page->getProject()->getTableName('pagelinks'); - $linkTargetTable = $page->getProject()->getTableName('linktarget'); - $redirectTable = $page->getProject()->getTableName('redirect'); - - $sql = "SELECT 'links_ext_count' AS type, COUNT(*) AS value + $result = $this->executeProjectsQuery( 'wikidatawiki', $sql, [ + 'wikidataId' => $wikidataId, + ] )->fetchAllAssociative(); + + return $count ? (int)$result[0]['count'] : $result; + } + + /** + * Get number of in and outgoing links and redirects to the given page. + * @param Page $page + * @return string[] Counts with the keys 'links_ext_count', 'links_out_count', + * 'links_in_count' and 'redirects_count' + */ + public function countLinksAndRedirects( Page $page ): array { + $externalLinksTable = $page->getProject()->getTableName( 'externallinks' ); + $pageLinksTable = $page->getProject()->getTableName( 'pagelinks' ); + $linkTargetTable = $page->getProject()->getTableName( 'linktarget' ); + $redirectTable = $page->getProject()->getTableName( 'redirect' ); + + $sql = "SELECT 'links_ext_count' AS type, COUNT(*) AS value FROM $externalLinksTable WHERE el_from = :id UNION SELECT 'links_out_count' AS type, COUNT(*) AS value @@ -334,175 +328,169 @@ public function countLinksAndRedirects(Page $page): array SELECT 'redirects_count' AS type, COUNT(*) AS value FROM $redirectTable WHERE rd_namespace = :namespace AND rd_title = :title"; - $params = [ - 'id' => $page->getId(), - 'title' => str_replace(' ', '_', $page->getTitleWithoutNamespace()), - 'namespace' => $page->getNamespace(), - ]; - - return $this->executeProjectsQuery($page->getProject(), $sql, $params)->fetchAllKeyValue(); - } - - /** - * Count wikidata items for the given page, not just languages of sister projects - * @param Page $page - * @return int Number of records. - */ - public function countWikidataItems(Page $page): int - { - return $this->getWikidataItems($page, true); - } - - /** - * Get page views for the given page and timeframe. - * @fixme use Symfony Guzzle package. - * @param Page $page - * @param string|DateTime $start In the format YYYYMMDD - * @param string|DateTime $end In the format YYYYMMDD - * @return string[][][] - * @throws BadGatewayException - */ - public function getPageviews(Page $page, string|DateTime $start, string|DateTime $end): array - { - // Pull from cache for each call during the same request. - // FIXME: This is fine for now as we only fetch pageviews for one page at a time, - // but if that ever changes we'll need to use APCu cache or otherwise respect $page, $start and $end. - // Better of course would be to move to a Symfony CachingHttpClient instead of Guzzle across the board. - static $pageviews; - if (isset($pageviews)) { - return $pageviews; - } - - $title = rawurlencode(str_replace(' ', '_', $page->getTitle())); - - if ($start instanceof DateTime) { - $start = $start->format('Ymd'); - } else { - $start = (new DateTime($start))->format('Ymd'); - } - if ($end instanceof DateTime) { - $end = $end->format('Ymd'); - } else { - $end = (new DateTime($end))->format('Ymd'); - } - - $project = $page->getProject()->getDomain(); - - $url = 'https://wikimedia.org/api/rest_v1/metrics/pageviews/per-article/' . - "$project/all-access/user/$title/daily/$start/$end"; - - try { - $res = $this->guzzle->request('GET', $url, [ - // Five seconds should be plenty... - RequestOptions::CONNECT_TIMEOUT => 5, - ]); - $pageviews = json_decode($res->getBody()->getContents(), true); - return $pageviews; - } catch (ServerException|ConnectException $e) { - throw new BadGatewayException('api-error-wikimedia', ['Pageviews'], $e); - } - } - - /** - * Get the full HTML content of the the page. - * @param Page $page - * @param ?int $revId What revision to query for. - * @return string - * @throws BadGatewayException - */ - public function getHTMLContent(Page $page, ?int $revId = null): string - { - if ($this->isWMF) { - $domain = $page->getProject()->getDomain(); - $url = "https://$domain/api/rest_v1/page/html/" . urlencode(str_replace(' ', '_', $page->getTitle())); - if (null !== $revId) { - $url .= "/$revId"; - } - } else { - $url = $page->getUrl(); - if (null !== $revId) { - $url .= "?oldid=$revId"; - } - } - - try { - return $this->guzzle->request('GET', $url) - ->getBody() - ->getContents(); - } catch (ServerException $e) { - throw new BadGatewayException('api-error-wikimedia', ['Wikimedia REST'], $e); - } catch (ClientException $e) { - if ($page->exists() && Response::HTTP_NOT_FOUND === $e->getCode()) { - // Sometimes the REST API throws 404s when the page does in fact exist. - throw new BadGatewayException('api-error-wikimedia', ['Wikimedia REST'], $e); - } - throw $e; - } - } - - /** - * Get the ID of the revision of a page at the time of the given DateTime. - * @param Page $page - * @param DateTime $date - * @return int - */ - public function getRevisionIdAtDate(Page $page, DateTime $date): int - { - $revisionTable = $page->getProject()->getTableName('revision'); - $pageId = $page->getId(); - $datestamp = $date->format('YmdHis'); - $sql = "SELECT MAX(rev_id) + $params = [ + 'id' => $page->getId(), + 'title' => str_replace( ' ', '_', $page->getTitleWithoutNamespace() ), + 'namespace' => $page->getNamespace(), + ]; + + return $this->executeProjectsQuery( $page->getProject(), $sql, $params )->fetchAllKeyValue(); + } + + /** + * Count wikidata items for the given page, not just languages of sister projects + * @param Page $page + * @return int Number of records. + */ + public function countWikidataItems( Page $page ): int { + return $this->getWikidataItems( $page, true ); + } + + /** + * Get page views for the given page and timeframe. + * @fixme use Symfony Guzzle package. + * @param Page $page + * @param string|DateTime $start In the format YYYYMMDD + * @param string|DateTime $end In the format YYYYMMDD + * @return string[][][] + * @throws BadGatewayException + */ + public function getPageviews( Page $page, string|DateTime $start, string|DateTime $end ): array { + // Pull from cache for each call during the same request. + // FIXME: This is fine for now as we only fetch pageviews for one page at a time, + // but if that ever changes we'll need to use APCu cache or otherwise respect $page, $start and $end. + // Better of course would be to move to a Symfony CachingHttpClient instead of Guzzle across the board. + static $pageviews; + if ( isset( $pageviews ) ) { + return $pageviews; + } + + $title = rawurlencode( str_replace( ' ', '_', $page->getTitle() ) ); + + if ( $start instanceof DateTime ) { + $start = $start->format( 'Ymd' ); + } else { + $start = ( new DateTime( $start ) )->format( 'Ymd' ); + } + if ( $end instanceof DateTime ) { + $end = $end->format( 'Ymd' ); + } else { + $end = ( new DateTime( $end ) )->format( 'Ymd' ); + } + + $project = $page->getProject()->getDomain(); + + $url = 'https://wikimedia.org/api/rest_v1/metrics/pageviews/per-article/' . + "$project/all-access/user/$title/daily/$start/$end"; + + try { + $res = $this->guzzle->request( 'GET', $url, [ + // Five seconds should be plenty... + RequestOptions::CONNECT_TIMEOUT => 5, + ] ); + $pageviews = json_decode( $res->getBody()->getContents(), true ); + return $pageviews; + } catch ( ServerException | ConnectException $e ) { + throw new BadGatewayException( 'api-error-wikimedia', [ 'Pageviews' ], $e ); + } + } + + /** + * Get the full HTML content of the the page. + * @param Page $page + * @param ?int $revId What revision to query for. + * @return string + * @throws BadGatewayException + */ + public function getHTMLContent( Page $page, ?int $revId = null ): string { + if ( $this->isWMF ) { + $domain = $page->getProject()->getDomain(); + $url = "https://$domain/api/rest_v1/page/html/" . urlencode( str_replace( ' ', '_', $page->getTitle() ) ); + if ( $revId !== null ) { + $url .= "/$revId"; + } + } else { + $url = $page->getUrl(); + if ( $revId !== null ) { + $url .= "?oldid=$revId"; + } + } + + try { + return $this->guzzle->request( 'GET', $url ) + ->getBody() + ->getContents(); + } catch ( ServerException $e ) { + throw new BadGatewayException( 'api-error-wikimedia', [ 'Wikimedia REST' ], $e ); + } catch ( ClientException $e ) { + if ( $page->exists() && Response::HTTP_NOT_FOUND === $e->getCode() ) { + // Sometimes the REST API throws 404s when the page does in fact exist. + throw new BadGatewayException( 'api-error-wikimedia', [ 'Wikimedia REST' ], $e ); + } + throw $e; + } + } + + /** + * Get the ID of the revision of a page at the time of the given DateTime. + * @param Page $page + * @param DateTime $date + * @return int + */ + public function getRevisionIdAtDate( Page $page, DateTime $date ): int { + $revisionTable = $page->getProject()->getTableName( 'revision' ); + $pageId = $page->getId(); + $datestamp = $date->format( 'YmdHis' ); + $sql = "SELECT MAX(rev_id) FROM $revisionTable WHERE rev_timestamp <= $datestamp AND rev_page = $pageId LIMIT 1;"; - $resultQuery = $this->getProjectsConnection($page->getProject()) - ->executeQuery($sql); - return (int)$resultQuery->fetchOne(); - } - - /** - * Get HTML display titles of a set of pages (or the normal title if there's no display title). - * This will send t/50 API requests where t is the number of titles supplied. - * @param Project $project The project. - * @param string[] $pageTitles The titles to fetch. - * @return string[] Keys are the original supplied title, and values are the display titles. - * @static - */ - public function displayTitles(Project $project, array $pageTitles): array - { - $displayTitles = []; - $numPages = count($pageTitles); - - for ($n = 0; $n < $numPages; $n += 50) { - $titleSlice = array_slice($pageTitles, $n, 50); - $res = $this->guzzle->request('GET', $project->getApiUrl(), ['query' => [ - 'action' => 'query', - 'prop' => 'info|pageprops', - 'inprop' => 'displaytitle', - 'titles' => join('|', $titleSlice), - 'format' => 'json', - ]]); - $result = json_decode($res->getBody()->getContents(), true); - - // Extract normalization info. - $normalized = []; - if (isset($result['query']['normalized'])) { - array_map( - function ($e) use (&$normalized): void { - $normalized[$e['to']] = $e['from']; - }, - $result['query']['normalized'] - ); - } - - // Match up the normalized titles with the display titles and the original titles. - foreach ($result['query']['pages'] as $pageInfo) { - $displayTitle = $pageInfo['pageprops']['displaytitle'] ?? $pageInfo['title']; - $origTitle = $normalized[$pageInfo['title']] ?? $pageInfo['title']; - $displayTitles[$origTitle] = $displayTitle; - } - } - - return $displayTitles; - } + $resultQuery = $this->getProjectsConnection( $page->getProject() ) + ->executeQuery( $sql ); + return (int)$resultQuery->fetchOne(); + } + + /** + * Get HTML display titles of a set of pages (or the normal title if there's no display title). + * This will send t/50 API requests where t is the number of titles supplied. + * @param Project $project The project. + * @param string[] $pageTitles The titles to fetch. + * @return string[] Keys are the original supplied title, and values are the display titles. + */ + public function displayTitles( Project $project, array $pageTitles ): array { + $displayTitles = []; + $numPages = count( $pageTitles ); + + for ( $n = 0; $n < $numPages; $n += 50 ) { + $titleSlice = array_slice( $pageTitles, $n, 50 ); + $res = $this->guzzle->request( 'GET', $project->getApiUrl(), [ 'query' => [ + 'action' => 'query', + 'prop' => 'info|pageprops', + 'inprop' => 'displaytitle', + 'titles' => implode( '|', $titleSlice ), + 'format' => 'json', + ] ] ); + $result = json_decode( $res->getBody()->getContents(), true ); + + // Extract normalization info. + $normalized = []; + if ( isset( $result['query']['normalized'] ) ) { + array_map( + static function ( $e ) use ( &$normalized ): void { + $normalized[$e['to']] = $e['from']; + }, + $result['query']['normalized'] + ); + } + + // Match up the normalized titles with the display titles and the original titles. + foreach ( $result['query']['pages'] as $pageInfo ) { + $displayTitle = $pageInfo['pageprops']['displaytitle'] ?? $pageInfo['title']; + $origTitle = $normalized[$pageInfo['title']] ?? $pageInfo['title']; + $displayTitles[$origTitle] = $displayTitle; + } + } + + return $displayTitles; + } } diff --git a/src/Repository/PagesRepository.php b/src/Repository/PagesRepository.php index e0c8cec7f..74126a25f 100644 --- a/src/Repository/PagesRepository.php +++ b/src/Repository/PagesRepository.php @@ -1,6 +1,6 @@ getCacheKey(func_get_args(), 'num_user_pages_created'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $conditions = [ - 'paSelects' => '', - 'paSelectsArchive' => '', - 'revPageGroupBy' => 'GROUP BY rev_page', - ]; - $conditions = array_merge( - $conditions, - $this->getNamespaceRedirectAndDeletedPagesConditions($namespace, $redirects), - $this->getUserConditions('' !== $start.$end) - ); - - $wasRedirect = $this->getWasRedirectClause($redirects, $deleted); - $summation = Pages::DEL_NONE !== $deleted ? 'redirect OR was_redirect' : 'redirect'; - - $sql = "SELECT `namespace`, +class PagesRepository extends UserRepository { + /** + * Count the number of pages created by a user. + * @param Project $project + * @param User $user + * @param string|int $namespace Namespace ID or 'all'. + * @param string $redirects One of the Pages::REDIR_ constants. + * @param string $deleted One of the Pages::DEL_ constants. + * @param int|false $start Start date as Unix timestamp. + * @param int|false $end End date as Unix timestamp. + * @return string[] Result of query, see below. Includes live and deleted pages. + */ + public function countPagesCreated( + Project $project, + User $user, + string|int $namespace, + string $redirects, + string $deleted, + int|false $start = false, + int|false $end = false + ): array { + $cacheKey = $this->getCacheKey( func_get_args(), 'num_user_pages_created' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $conditions = [ + 'paSelects' => '', + 'paSelectsArchive' => '', + 'revPageGroupBy' => 'GROUP BY rev_page', + ]; + $conditions = array_merge( + $conditions, + $this->getNamespaceRedirectAndDeletedPagesConditions( $namespace, $redirects ), + $this->getUserConditions( ( $start . $end ) !== '' ) + ); + + $wasRedirect = $this->getWasRedirectClause( $redirects, $deleted ); + $summation = Pages::DEL_NONE !== $deleted ? 'redirect OR was_redirect' : 'redirect'; + + $sql = "SELECT `namespace`, COUNT(page_title) AS `count`, SUM(IF(type = 'arc', 1, 0)) AS `deleted`, SUM($summation) AS `redirects`, SUM(rev_length) AS `total_length` FROM (" . - $this->getPagesCreatedInnerSql($project, $conditions, $deleted, $start, $end, false, true)." - ) a ". - $wasRedirect . - "GROUP BY `namespace`"; - - $result = $this->executeQuery($sql, $project, $user, $namespace) - ->fetchAllAssociative(); - - // Cache and return. - return $this->setCache($cacheKey, $result); - } - - /** - * Get pages created by a user. - * @param Project $project - * @param User $user - * @param string|int $namespace Namespace ID or 'all'. - * @param string $redirects One of the Pages::REDIR_ constants. - * @param string $deleted One of the Pages::DEL_ constants. - * @param int|false $start Start date as Unix timestamp. - * @param int|false $end End date as Unix timestamp. - * @param int|null $limit Number of results to return, or blank to return all. - * @param false|int $offset Unix timestamp. Used for pagination. - * @return string[] Result of query, see below. Includes live and deleted pages. - */ - public function getPagesCreated( - Project $project, - User $user, - string|int $namespace, - string $redirects, - string $deleted, - int|false $start = false, - int|false $end = false, - ?int $limit = 1000, - int|false $offset = false - ): array { - $cacheKey = $this->getCacheKey(func_get_args(), 'user_pages_created'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - // always group by rev_page, to address merges where 2 revisions with rev_parent_id=0 - $conditions = [ - 'paSelects' => '', - 'paSelectsArchive' => '', - 'revPageGroupBy' => 'GROUP BY rev_page', - ]; - - $conditions = array_merge( - $conditions, - $this->getNamespaceRedirectAndDeletedPagesConditions($namespace, $redirects), - $this->getUserConditions('' !== $start.$end) - ); - - $hasPageAssessments = $this->isWMF && $project->hasPageAssessments($namespace); - if ($hasPageAssessments) { - $pageAssessmentsTable = $project->getTableName('page_assessments'); - $paProjectsTable = $project->getTableName('page_assessments_projects'); - $conditions['paSelects'] = ", + $this->getPagesCreatedInnerSql( $project, $conditions, $deleted, $start, $end, false, true ) . " + ) a " . + $wasRedirect . + "GROUP BY `namespace`"; + + $result = $this->executeQuery( $sql, $project, $user, $namespace ) + ->fetchAllAssociative(); + + // Cache and return. + return $this->setCache( $cacheKey, $result ); + } + + /** + * Get pages created by a user. + * @param Project $project + * @param User $user + * @param string|int $namespace Namespace ID or 'all'. + * @param string $redirects One of the Pages::REDIR_ constants. + * @param string $deleted One of the Pages::DEL_ constants. + * @param int|false $start Start date as Unix timestamp. + * @param int|false $end End date as Unix timestamp. + * @param int|null $limit Number of results to return, or blank to return all. + * @param false|int $offset Unix timestamp. Used for pagination. + * @return string[] Result of query, see below. Includes live and deleted pages. + */ + public function getPagesCreated( + Project $project, + User $user, + string|int $namespace, + string $redirects, + string $deleted, + int|false $start = false, + int|false $end = false, + ?int $limit = 1000, + int|false $offset = false + ): array { + $cacheKey = $this->getCacheKey( func_get_args(), 'user_pages_created' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + // always group by rev_page, to address merges where 2 revisions with rev_parent_id=0 + $conditions = [ + 'paSelects' => '', + 'paSelectsArchive' => '', + 'revPageGroupBy' => 'GROUP BY rev_page', + ]; + + $conditions = array_merge( + $conditions, + $this->getNamespaceRedirectAndDeletedPagesConditions( $namespace, $redirects ), + $this->getUserConditions( $start . $end !== '' ) + ); + + $hasPageAssessments = $this->isWMF && $project->hasPageAssessments( $namespace ); + if ( $hasPageAssessments ) { + $pageAssessmentsTable = $project->getTableName( 'page_assessments' ); + $paProjectsTable = $project->getTableName( 'page_assessments_projects' ); + $conditions['paSelects'] = ", (SELECT pa_class FROM $pageAssessmentsTable WHERE rev_page = pa_page_id @@ -132,134 +131,132 @@ public function getPagesCreated( ON pa_project_id = pap_project_id WHERE pa_page_id = page_id ) AS pap_project_title"; - $conditions['paSelectsArchive'] = ', NULL AS pa_class, NULL as pap_project_title'; - $conditions['revPageGroupBy'] = 'GROUP BY rev_page'; - } - - $wasRedirect = $this->getWasRedirectClause($redirects, $deleted); - - $sql = "SELECT * FROM (". - $this->getPagesCreatedInnerSql($project, $conditions, $deleted, $start, $end, $offset)." - ) a ". - $wasRedirect . - "ORDER BY `timestamp` DESC - ".(!empty($limit) ? "LIMIT $limit" : ''); - - $result = $this->executeQuery($sql, $project, $user, $namespace) - ->fetchAllAssociative(); - - // Cache and return. - return $this->setCache($cacheKey, $result); - } - - private function getWasRedirectClause(string $redirects, string $deleted): string - { - if (Pages::REDIR_NONE === $redirects) { - return "WHERE was_redirect IS NULL "; - } elseif (Pages::REDIR_ONLY === $redirects && Pages::DEL_ONLY === $deleted) { - return "WHERE was_redirect = 1 "; - } elseif (Pages::REDIR_ONLY === $redirects && Pages::DEL_ALL === $deleted) { - return "WHERE was_redirect = 1 OR redirect = 1 "; - } - return ''; - } - - /** - * Get SQL fragments for the namespace and redirects, - * to be used in self::getPagesCreatedInnerSql(). - * @param string|int $namespace Namespace ID or 'all'. - * @param string $redirects One of the Pages::REDIR_ constants. - * @return string[] With keys 'namespaceRev', 'namespaceArc' and 'redirects' - */ - private function getNamespaceRedirectAndDeletedPagesConditions(string|int $namespace, string $redirects): array - { - $conditions = [ - 'namespaceArc' => '', - 'namespaceRev' => '', - 'redirects' => '', - ]; - - if ('all' !== $namespace) { - $conditions['namespaceRev'] = " AND page_namespace = '".intval($namespace)."' "; - $conditions['namespaceArc'] = " AND ar_namespace = '".intval($namespace)."' "; - } - - if (Pages::REDIR_ONLY == $redirects) { - $conditions['redirects'] = " AND page_is_redirect = '1' "; - } elseif (Pages::REDIR_NONE == $redirects) { - $conditions['redirects'] = " AND page_is_redirect = '0' "; - } - - return $conditions; - } - - /** - * Inner SQL for getting or counting pages created by the user. - * @param Project $project - * @param string[] $conditions Conditions for the SQL, must include 'paSelects', - * 'paSelectsArchive', 'whereRev', 'whereArc', 'namespaceRev', 'namespaceArc', - * 'redirects' and 'revPageGroupBy'. - * @param string $deleted One of the Pages::DEL_ constants. - * @param int|false $start Start date as Unix timestamp. - * @param int|false $end End date as Unix timestamp. - * @param int|false $offset Unix timestamp, used for pagination. - * @param bool $count Omit unneeded columns from the SELECT clause. - * @return string Raw SQL. - */ - private function getPagesCreatedInnerSql( - Project $project, - array $conditions, - string $deleted, - int|false $start, - int|false $end, - int|false $offset = false, - bool $count = false - ): string { - $pageTable = $project->getTableName('page'); - $revisionTable = $project->getTableName('revision'); - $archiveTable = $project->getTableName('archive'); - $logTable = $project->getTableName('logging', 'logindex'); - - // Only SELECT things that are needed, based on whether or not we're doing a COUNT. - $revSelects = "DISTINCT page_namespace AS `namespace`, 'rev' AS `type`, page_title, " - . "page_is_redirect AS `redirect`, rev_len AS `rev_length`"; - if (!$count) { - $revSelects .= ", page_len AS `length`, rev_timestamp AS `timestamp`, " - . "rev_id, NULL AS `recreated` "; - } - - $revDateConditions = $this->getDateConditions($start, $end, $offset); - $arDateConditions = $this->getDateConditions($start, $end, $offset, '', 'ar_timestamp'); - - $tagTable = $project->getTableName('change_tag'); - $tagDefTable = $project->getTableName('change_tag_def'); - - $revisionsSelect = " - SELECT $revSelects ".$conditions['paSelects'].", + $conditions['paSelectsArchive'] = ', NULL AS pa_class, NULL as pap_project_title'; + $conditions['revPageGroupBy'] = 'GROUP BY rev_page'; + } + + $wasRedirect = $this->getWasRedirectClause( $redirects, $deleted ); + + $sql = "SELECT * FROM (" . + $this->getPagesCreatedInnerSql( $project, $conditions, $deleted, $start, $end, $offset ) . " + ) a " . + $wasRedirect . + "ORDER BY `timestamp` DESC + " . ( !empty( $limit ) ? "LIMIT $limit" : '' ); + + $result = $this->executeQuery( $sql, $project, $user, $namespace ) + ->fetchAllAssociative(); + + // Cache and return. + return $this->setCache( $cacheKey, $result ); + } + + private function getWasRedirectClause( string $redirects, string $deleted ): string { + if ( Pages::REDIR_NONE === $redirects ) { + return "WHERE was_redirect IS NULL "; + } elseif ( Pages::REDIR_ONLY === $redirects && Pages::DEL_ONLY === $deleted ) { + return "WHERE was_redirect = 1 "; + } elseif ( Pages::REDIR_ONLY === $redirects && Pages::DEL_ALL === $deleted ) { + return "WHERE was_redirect = 1 OR redirect = 1 "; + } + return ''; + } + + /** + * Get SQL fragments for the namespace and redirects, + * to be used in self::getPagesCreatedInnerSql(). + * @param string|int $namespace Namespace ID or 'all'. + * @param string $redirects One of the Pages::REDIR_ constants. + * @return string[] With keys 'namespaceRev', 'namespaceArc' and 'redirects' + */ + private function getNamespaceRedirectAndDeletedPagesConditions( string|int $namespace, string $redirects ): array { + $conditions = [ + 'namespaceArc' => '', + 'namespaceRev' => '', + 'redirects' => '', + ]; + + if ( $namespace !== 'all' ) { + $conditions['namespaceRev'] = " AND page_namespace = '" . intval( $namespace ) . "' "; + $conditions['namespaceArc'] = " AND ar_namespace = '" . intval( $namespace ) . "' "; + } + + if ( Pages::REDIR_ONLY == $redirects ) { + $conditions['redirects'] = " AND page_is_redirect = '1' "; + } elseif ( Pages::REDIR_NONE == $redirects ) { + $conditions['redirects'] = " AND page_is_redirect = '0' "; + } + + return $conditions; + } + + /** + * Inner SQL for getting or counting pages created by the user. + * @param Project $project + * @param string[] $conditions Conditions for the SQL, must include 'paSelects', + * 'paSelectsArchive', 'whereRev', 'whereArc', 'namespaceRev', 'namespaceArc', + * 'redirects' and 'revPageGroupBy'. + * @param string $deleted One of the Pages::DEL_ constants. + * @param int|false $start Start date as Unix timestamp. + * @param int|false $end End date as Unix timestamp. + * @param int|false $offset Unix timestamp, used for pagination. + * @param bool $count Omit unneeded columns from the SELECT clause. + * @return string Raw SQL. + */ + private function getPagesCreatedInnerSql( + Project $project, + array $conditions, + string $deleted, + int|false $start, + int|false $end, + int|false $offset = false, + bool $count = false + ): string { + $pageTable = $project->getTableName( 'page' ); + $revisionTable = $project->getTableName( 'revision' ); + $archiveTable = $project->getTableName( 'archive' ); + $logTable = $project->getTableName( 'logging', 'logindex' ); + + // Only SELECT things that are needed, based on whether or not we're doing a COUNT. + $revSelects = "DISTINCT page_namespace AS `namespace`, 'rev' AS `type`, page_title, " + . "page_is_redirect AS `redirect`, rev_len AS `rev_length`"; + if ( !$count ) { + $revSelects .= ", page_len AS `length`, rev_timestamp AS `timestamp`, " + . "rev_id, NULL AS `recreated` "; + } + + $revDateConditions = $this->getDateConditions( $start, $end, $offset ); + $arDateConditions = $this->getDateConditions( $start, $end, $offset, '', 'ar_timestamp' ); + + $tagTable = $project->getTableName( 'change_tag' ); + $tagDefTable = $project->getTableName( 'change_tag_def' ); + + $revisionsSelect = " + SELECT $revSelects " . $conditions['paSelects'] . ", NULL AS was_redirect FROM $pageTable JOIN $revisionTable ON page_id = rev_page - WHERE ".$conditions['whereRev']." - AND rev_parent_id = '0'". - $conditions['namespaceRev']. - $conditions['redirects']. - $revDateConditions. - $conditions['revPageGroupBy']; - - // Only SELECT things that are needed, based on whether or not we're doing a COUNT. - $arSelects = "ar_namespace AS `namespace`, 'arc' AS `type`, ar_title AS `page_title`, " - . "'0' AS `redirect`, ar_len AS `rev_length`"; - if (!$count) { - $arSelects .= ", NULL AS `length`, MIN(ar_timestamp) AS `timestamp`, ". - "ar_rev_id AS `rev_id`, EXISTS( + WHERE " . $conditions['whereRev'] . " + AND rev_parent_id = '0'" . + $conditions['namespaceRev'] . + $conditions['redirects'] . + $revDateConditions . + $conditions['revPageGroupBy']; + + // Only SELECT things that are needed, based on whether or not we're doing a COUNT. + $arSelects = "ar_namespace AS `namespace`, 'arc' AS `type`, ar_title AS `page_title`, " + . "'0' AS `redirect`, ar_len AS `rev_length`"; + if ( !$count ) { + $arSelects .= ", NULL AS `length`, MIN(ar_timestamp) AS `timestamp`, " . + "ar_rev_id AS `rev_id`, EXISTS( SELECT 1 FROM $pageTable WHERE page_namespace = ar_namespace AND page_title = ar_title ) AS `recreated`"; - } + } - $archiveSelect = " - SELECT $arSelects ".$conditions['paSelectsArchive'].", + $archiveSelect = " + SELECT $arSelects " . $conditions['paSelectsArchive'] . ", ( SELECT 1 FROM $tagTable @@ -275,59 +272,59 @@ private function getPagesCreatedInnerSql( LEFT JOIN $logTable ON log_namespace = ar_namespace AND log_title = ar_title AND log_actor = ar_actor AND (log_action = 'move' OR log_action = 'move_redir') AND log_type = 'move' - WHERE ".$conditions['whereArc']." - AND ar_parent_id = '0' ". - $conditions['namespaceArc']." + WHERE " . $conditions['whereArc'] . " + AND ar_parent_id = '0' " . + $conditions['namespaceArc'] . " AND log_action IS NULL $arDateConditions GROUP BY ar_namespace, ar_title"; - if ('live' === $deleted) { - return $revisionsSelect; - } elseif ('deleted' === $deleted) { - return $archiveSelect; - } - - return "($revisionsSelect) UNION ($archiveSelect)"; - } - - /** - * Get the number of pages the user created by assessment. - * @param Project $project - * @param User $user - * @param int|string $namespace - * @param string $redirects One of the Pages::REDIR_ constants. - * @param int|false $start Start date as Unix timestamp. - * @param int|false $end End date as Unix timestamp. - * @return array Keys are the assessment class, values are the counts. - */ - public function getAssessmentCounts( - Project $project, - User $user, - int|string $namespace, - string $redirects, - int|false $start = false, - int|false $end = false - ): array { - $cacheKey = $this->getCacheKey(func_get_args(), 'user_pages_created_assessments'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $pageTable = $project->getTableName('page'); - $revisionTable = $project->getTableName('revision'); - $pageAssessmentsTable = $project->getTableName('page_assessments'); - - $conditions = array_merge( - $this->getNamespaceRedirectAndDeletedPagesConditions($namespace, $redirects), - $this->getUserConditions('' !== $start.$end) - ); - $revDateConditions = $this->getDateConditions($start, $end); - - $paNamespaces = $project->getPageAssessments()::SUPPORTED_NAMESPACES; - $paNamespaces = '(' . implode(',', array_map('strval', $paNamespaces)) . ')'; - - $sql = "SELECT pa_class AS `class`, COUNT(page_id) AS `count` FROM ( + if ( $deleted === 'live' ) { + return $revisionsSelect; + } elseif ( $deleted === 'deleted' ) { + return $archiveSelect; + } + + return "($revisionsSelect) UNION ($archiveSelect)"; + } + + /** + * Get the number of pages the user created by assessment. + * @param Project $project + * @param User $user + * @param int|string $namespace + * @param string $redirects One of the Pages::REDIR_ constants. + * @param int|false $start Start date as Unix timestamp. + * @param int|false $end End date as Unix timestamp. + * @return array Keys are the assessment class, values are the counts. + */ + public function getAssessmentCounts( + Project $project, + User $user, + int|string $namespace, + string $redirects, + int|false $start = false, + int|false $end = false + ): array { + $cacheKey = $this->getCacheKey( func_get_args(), 'user_pages_created_assessments' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $pageTable = $project->getTableName( 'page' ); + $revisionTable = $project->getTableName( 'revision' ); + $pageAssessmentsTable = $project->getTableName( 'page_assessments' ); + + $conditions = array_merge( + $this->getNamespaceRedirectAndDeletedPagesConditions( $namespace, $redirects ), + $this->getUserConditions( $start . $end !== '' ) + ); + $revDateConditions = $this->getDateConditions( $start, $end ); + + $paNamespaces = $project->getPageAssessments()::SUPPORTED_NAMESPACES; + $paNamespaces = '(' . implode( ',', array_map( 'strval', $paNamespaces ) ) . ')'; + + $sql = "SELECT pa_class AS `class`, COUNT(page_id) AS `count` FROM ( SELECT page_id, (SELECT pa_class FROM $pageAssessmentsTable @@ -337,99 +334,98 @@ public function getAssessmentCounts( ) AS pa_class FROM $pageTable JOIN $revisionTable ON page_id = rev_page - WHERE ".$conditions['whereRev']." + WHERE " . $conditions['whereRev'] . " AND rev_parent_id = '0' - AND (page_namespace in $paNamespaces)". - $conditions['namespaceRev']. - $conditions['redirects']. - $revDateConditions." + AND (page_namespace in $paNamespaces)" . + $conditions['namespaceRev'] . + $conditions['redirects'] . + $revDateConditions . " GROUP BY page_id ) a GROUP BY pa_class"; - $resultQuery = $this->executeQuery($sql, $project, $user, $namespace); - - $assessments = []; - while ($result = $resultQuery->fetchAssociative()) { - $class = '' == $result['class'] ? '' : $result['class']; - $assessments[$class] = $result['count']; - } - - // Cache and return. - return $this->setCache($cacheKey, $assessments); - } - - /** - * Get the number of pages the user created by WikiProject. - * Max 10 projects. - * @param Project $project - * @param User $user - * @param int|string $namespace - * @param string $redirects One of the Pages::REDIR_ constants. - * @param int|false $start Start date as Unix timestamp. - * @param int|false $end End date as Unix timestamp. - * @return array Each element is an array with keys pap_project_title and count. - */ - public function getWikiprojectCounts( - Project $project, - User $user, - int|string $namespace, - string $redirects, - int|false $start = false, - int|false $end = false - ): array { - $cacheKey = $this->getCacheKey(func_get_args(), 'user_pages_created_wikiprojects'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $pageTable = $project->getTableName('page'); - $revisionTable = $project->getTableName('revision'); - $pageAssessmentsTable = $project->getTableName('page_assessments'); - $paProjectsTable = $project->getTableName('page_assessments_projects'); - - $conditions = array_merge( - $this->getNamespaceRedirectAndDeletedPagesConditions($namespace, $redirects), - $this->getUserConditions('' !== $start.$end) - ); - $revDateConditions = $this->getDateConditions($start, $end); - - $sql = "SELECT pap_project_title, count(pap_project_title) as `count` + $resultQuery = $this->executeQuery( $sql, $project, $user, $namespace ); + + $assessments = []; + foreach ( $resultQuery->fetchAssociative() as $result ) { + $class = $result['class'] == '' ? '' : $result['class']; + $assessments[$class] = $result['count']; + } + + // Cache and return. + return $this->setCache( $cacheKey, $assessments ); + } + + /** + * Get the number of pages the user created by WikiProject. + * Max 10 projects. + * @param Project $project + * @param User $user + * @param int|string $namespace + * @param string $redirects One of the Pages::REDIR_ constants. + * @param int|false $start Start date as Unix timestamp. + * @param int|false $end End date as Unix timestamp. + * @return array Each element is an array with keys pap_project_title and count. + */ + public function getWikiprojectCounts( + Project $project, + User $user, + int|string $namespace, + string $redirects, + int|false $start = false, + int|false $end = false + ): array { + $cacheKey = $this->getCacheKey( func_get_args(), 'user_pages_created_wikiprojects' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $pageTable = $project->getTableName( 'page' ); + $revisionTable = $project->getTableName( 'revision' ); + $pageAssessmentsTable = $project->getTableName( 'page_assessments' ); + $paProjectsTable = $project->getTableName( 'page_assessments_projects' ); + + $conditions = array_merge( + $this->getNamespaceRedirectAndDeletedPagesConditions( $namespace, $redirects ), + $this->getUserConditions( $start . $end !== '' ) + ); + $revDateConditions = $this->getDateConditions( $start, $end ); + + $sql = "SELECT pap_project_title, count(pap_project_title) as `count` FROM $pageTable LEFT JOIN $revisionTable ON page_id = rev_page JOIN $pageAssessmentsTable ON page_id = pa_page_id JOIN $paProjectsTable ON pa_project_id = pap_project_id - WHERE ".$conditions['whereRev']." - AND rev_parent_id = '0'". - $conditions['namespaceRev']. - $conditions['redirects']. - $revDateConditions." + WHERE " . $conditions['whereRev'] . " + AND rev_parent_id = '0'" . + $conditions['namespaceRev'] . + $conditions['redirects'] . + $revDateConditions . " GROUP BY pap_project_title ORDER BY `count` DESC LIMIT 10"; - $totals = $this->executeQuery($sql, $project, $user, $namespace) - ->fetchAllAssociative(); - - // Cache and return. - return $this->setCache($cacheKey, $totals); - } - - /** - * Fetch the closest 'delete' event as of the time of the given $offset. - * - * @param Project $project - * @param int $namespace - * @param string $pageTitle - * @param string $offset - * @return array - */ - public function getDeletionSummary(Project $project, int $namespace, string $pageTitle, string $offset): array - { - $actorTable = $project->getTableName('actor'); - $commentTable = $project->getTableName('comment'); - $loggingTable = $project->getTableName('logging', 'logindex'); - $sql = "SELECT actor_name, comment_text, log_timestamp + $totals = $this->executeQuery( $sql, $project, $user, $namespace ) + ->fetchAllAssociative(); + + // Cache and return. + return $this->setCache( $cacheKey, $totals ); + } + + /** + * Fetch the closest 'delete' event as of the time of the given $offset. + * + * @param Project $project + * @param int $namespace + * @param string $pageTitle + * @param string $offset + * @return array + */ + public function getDeletionSummary( Project $project, int $namespace, string $pageTitle, string $offset ): array { + $actorTable = $project->getTableName( 'actor' ); + $commentTable = $project->getTableName( 'comment' ); + $loggingTable = $project->getTableName( 'logging', 'logindex' ); + $sql = "SELECT actor_name, comment_text, log_timestamp FROM $loggingTable JOIN $actorTable ON actor_id = log_actor JOIN $commentTable ON comment_id = log_comment_id @@ -439,9 +435,9 @@ public function getDeletionSummary(Project $project, int $namespace, string $pag AND log_type = 'delete' AND log_action IN ('delete', 'delete_redir', 'delete_redir2') LIMIT 1"; - $ret = $this->executeProjectsQuery($project, $sql, [ - 'pageTitle' => str_replace(' ', '_', $pageTitle), - ])->fetchAssociative(); - return $ret ?: []; - } + $ret = $this->executeProjectsQuery( $project, $sql, [ + 'pageTitle' => str_replace( ' ', '_', $pageTitle ), + ] )->fetchAssociative(); + return $ret ?: []; + } } diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php index 93084a725..78603d61e 100644 --- a/src/Repository/ProjectRepository.php +++ b/src/Repository/ProjectRepository.php @@ -1,6 +1,6 @@ setRepository($this); - $project->setPageAssessments(new PageAssessments($this->assessmentsRepo, $project)); - - if ($this->singleWiki) { - $this->setSingleBasicInfo([ - 'url' => $this->parameterBag->get('wiki_url'), - 'dbName' => '', // Just so this will pass in CI. - // TODO: this will need to be restored for third party support; KEYWORD: isWMF - // 'dbName' => $this->parameterBag->('database_replica_name'), - ]); - } - - return $project; - } - - /** - * Get the XTools default project. - * @return Project - */ - public function getDefaultProject(): Project - { - return $this->getProject($this->defaultProject); - } - - /** - * Get the global 'meta' project, which is either Meta (if this is Labs) or the default project. - * @return Project - */ - public function getGlobalProject(): Project - { - if ($this->isWMF) { - return $this->getProject('metawiki'); - } else { - return $this->getDefaultProject(); - } - } - - /** - * For single-wiki installations, you must manually set the wiki URL and database name - * (because there's no meta.wiki database to query). - * @param array $metadata - * @throws Exception - */ - public function setSingleBasicInfo(array $metadata): void - { - if (!array_key_exists('url', $metadata) || !array_key_exists('dbName', $metadata)) { - $error = "Single-wiki metadata should contain 'url', 'dbName' and 'lang' keys."; - throw new Exception($error); - } - $this->singleBasicInfo = array_intersect_key($metadata, [ - 'url' => '', - 'dbName' => '', - 'lang' => '', - ]); - } - - /** - * Get the 'dbName', 'url' and 'lang' of all projects. - * @return string[][] Each item has 'dbName', 'url' and 'lang' keys. - */ - public function getAll(): array - { - $this->logger->debug(__METHOD__." Getting all projects' metadata"); - // Single wiki mode? - if (!empty($this->singleBasicInfo)) { - return [$this->getOne('')]; - } - - // Maybe we've already fetched it. - if ($this->cache->hasItem($this->cacheKeyAllProjects)) { - return $this->cache->getItem($this->cacheKeyAllProjects)->get(); - } - - if ($this->parameterBag->has("database_meta_table")) { - $table = $this->parameterBag->get('database_meta_name') . '.' . - $this->parameterBag->get('database_meta_table'); - } else { - $table = "meta_p.wiki"; - } - - // Otherwise, fetch all from the database. - $sql = "SELECT dbname AS dbName, url, lang FROM $table"; - $projects = $this->executeProjectsQuery('meta', $sql) - ->fetchAllAssociative(); - $projectsMetadata = []; - foreach ($projects as $project) { - $projectsMetadata[$project['dbName']] = $project; - } - - // Cache for one day and return. - return $this->setCache( - $this->cacheKeyAllProjects, - $projectsMetadata, - 'P1D' - ); - } - - /** - * Get the 'dbName', 'url' and 'lang' of a project. This is all you need to make database queries. - * More comprehensive metadata can be fetched with getMetadata() at the expense of an API call. - * @param string $project A project URL, domain name, or database name. - * @return string[]|null With 'dbName', 'url' and 'lang' keys; or null if not found. - */ - public function getOne(string $project): ?array - { - $this->logger->debug(__METHOD__." Getting metadata about $project"); - // For single-wiki setups, every project is the same. - if (isset($this->singleBasicInfo)) { - return $this->singleBasicInfo; - } - - // Remove _p suffix. - $project = rtrim($project, '_p'); - - // For multi-wiki setups, first check the cache. - // First the all-projects cache, then the individual one. - if ($this->cache->hasItem($this->cacheKeyAllProjects)) { - foreach ($this->cache->getItem($this->cacheKeyAllProjects)->get() as $projMetadata) { - if ($projMetadata['dbName'] == "$project" - || $projMetadata['url'] == "$project" - || $projMetadata['url'] == "https://$project" - || $projMetadata['url'] == "https://$project.org" - || $projMetadata['url'] == "https://www.$project") { - $this->logger->debug(__METHOD__ . " Using cached data for $project"); - return $projMetadata; - } - } - } - $cacheKey = $this->getCacheKey($project, 'project'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - // TODO: make this configurable if XTools is to work on 3rd party wiki farms - $table = "meta_p.wiki"; - - // Otherwise, fetch the project's metadata from the meta.wiki table. - $sql = "SELECT dbname AS dbName, url, lang +class ProjectRepository extends Repository { + /** @var string[] Basic metadata if XTools is in single-wiki mode. */ + protected array $singleBasicInfo; + + /** @var string The cache key for the 'all project' metadata. */ + protected string $cacheKeyAllProjects = 'allprojects'; + + /** + * @param ManagerRegistry $managerRegistry + * @param CacheItemPoolInterface $cache + * @param Client $guzzle + * @param LoggerInterface $logger + * @param ParameterBagInterface $parameterBag + * @param bool $isWMF + * @param int $queryTimeout + * @param PageAssessmentsRepository $assessmentsRepo + * @param string $defaultProject + * @param bool $singleWiki + * @param array $optedIn + * @param string $apiPath + */ + public function __construct( + protected ManagerRegistry $managerRegistry, + protected CacheItemPoolInterface $cache, + protected Client $guzzle, + protected LoggerInterface $logger, + protected ParameterBagInterface $parameterBag, + protected bool $isWMF, + protected int $queryTimeout, + protected PageAssessmentsRepository $assessmentsRepo, + /** @var string The configured default project. */ + protected string $defaultProject, + /** @var bool Whether XTools is configured to run on a single wiki or not. */ + protected bool $singleWiki, + /** @var array Projects that have opted into showing restricted stats to everyone. */ + protected array $optedIn, + /** @var string The project's API path. */ + protected string $apiPath, + ) { + parent::__construct( $managerRegistry, $cache, $guzzle, $logger, $parameterBag, $isWMF, $queryTimeout ); + } + + /** + * Convenience method to get a new Project object based on a given identification string. + * @param string $projectIdent The domain name, database name, or URL of a project. + * @return Project + */ + public function getProject( string $projectIdent ): Project { + $project = new Project( $projectIdent ); + $project->setRepository( $this ); + $project->setPageAssessments( new PageAssessments( $this->assessmentsRepo, $project ) ); + + if ( $this->singleWiki ) { + $this->setSingleBasicInfo( [ + 'url' => $this->parameterBag->get( 'wiki_url' ), + // Just so this will pass in CI. + 'dbName' => '', + // TODO: this will need to be restored for third party support; KEYWORD: isWMF + // 'dbName' => $this->parameterBag->('database_replica_name'), + ] ); + } + + return $project; + } + + /** + * Get the XTools default project. + * @return Project + */ + public function getDefaultProject(): Project { + return $this->getProject( $this->defaultProject ); + } + + /** + * Get the global 'meta' project, which is either Meta (if this is Labs) or the default project. + * @return Project + */ + public function getGlobalProject(): Project { + if ( $this->isWMF ) { + return $this->getProject( 'metawiki' ); + } else { + return $this->getDefaultProject(); + } + } + + /** + * For single-wiki installations, you must manually set the wiki URL and database name + * (because there's no meta.wiki database to query). + * @param array $metadata + * @throws Exception + */ + public function setSingleBasicInfo( array $metadata ): void { + if ( !array_key_exists( 'url', $metadata ) || !array_key_exists( 'dbName', $metadata ) ) { + $error = "Single-wiki metadata should contain 'url', 'dbName' and 'lang' keys."; + throw new Exception( $error ); + } + $this->singleBasicInfo = array_intersect_key( $metadata, [ + 'url' => '', + 'dbName' => '', + 'lang' => '', + ] ); + } + + /** + * Get the 'dbName', 'url' and 'lang' of all projects. + * @return string[][] Each item has 'dbName', 'url' and 'lang' keys. + */ + public function getAll(): array { + $this->logger->debug( __METHOD__ . " Getting all projects' metadata" ); + // Single wiki mode? + if ( !empty( $this->singleBasicInfo ) ) { + return [ $this->getOne( '' ) ]; + } + + // Maybe we've already fetched it. + if ( $this->cache->hasItem( $this->cacheKeyAllProjects ) ) { + return $this->cache->getItem( $this->cacheKeyAllProjects )->get(); + } + + if ( $this->parameterBag->has( "database_meta_table" ) ) { + $table = $this->parameterBag->get( 'database_meta_name' ) . '.' . + $this->parameterBag->get( 'database_meta_table' ); + } else { + $table = "meta_p.wiki"; + } + + // Otherwise, fetch all from the database. + $sql = "SELECT dbname AS dbName, url, lang FROM $table"; + $projects = $this->executeProjectsQuery( 'meta', $sql ) + ->fetchAllAssociative(); + $projectsMetadata = []; + foreach ( $projects as $project ) { + $projectsMetadata[$project['dbName']] = $project; + } + + // Cache for one day and return. + return $this->setCache( + $this->cacheKeyAllProjects, + $projectsMetadata, + 'P1D' + ); + } + + /** + * Get the 'dbName', 'url' and 'lang' of a project. This is all you need to make database queries. + * More comprehensive metadata can be fetched with getMetadata() at the expense of an API call. + * @param string $project A project URL, domain name, or database name. + * @return string[]|null With 'dbName', 'url' and 'lang' keys; or null if not found. + */ + public function getOne( string $project ): ?array { + $this->logger->debug( __METHOD__ . " Getting metadata about $project" ); + // For single-wiki setups, every project is the same. + if ( isset( $this->singleBasicInfo ) ) { + return $this->singleBasicInfo; + } + + // Remove _p suffix. + $project = rtrim( $project, '_p' ); + + // For multi-wiki setups, first check the cache. + // First the all-projects cache, then the individual one. + if ( $this->cache->hasItem( $this->cacheKeyAllProjects ) ) { + foreach ( $this->cache->getItem( $this->cacheKeyAllProjects )->get() as $projMetadata ) { + if ( $projMetadata['dbName'] == "$project" + || $projMetadata['url'] == "$project" + || $projMetadata['url'] == "https://$project" + || $projMetadata['url'] == "https://$project.org" + || $projMetadata['url'] == "https://www.$project" ) { + $this->logger->debug( __METHOD__ . " Using cached data for $project" ); + return $projMetadata; + } + } + } + $cacheKey = $this->getCacheKey( $project, 'project' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + // TODO: make this configurable if XTools is to work on 3rd party wiki farms + $table = "meta_p.wiki"; + + // Otherwise, fetch the project's metadata from the meta.wiki table. + $sql = "SELECT dbname AS dbName, url, lang FROM $table WHERE dbname = :project OR url LIKE :projectUrl OR url LIKE :projectUrl2 OR url LIKE :projectUrl3 OR url LIKE :projectUrl4"; - $basicInfo = $this->executeProjectsQuery('meta', $sql, [ - 'project' => $project, - 'projectUrl' => "https://$project", - 'projectUrl2' => "https://$project.org", - 'projectUrl3' => "https://www.$project", - 'projectUrl4' => "https://www.$project.org", - ])->fetchAssociative(); - $basicInfo = false === $basicInfo ? null : $basicInfo; - - // Cache for one hour and return. - return $this->setCache($cacheKey, $basicInfo, 'PT1H'); - } - - /** - * Get metadata about a project, including the 'dbName', 'url' and 'lang' - * - * @param string $projectUrl The project's URL. - * @return array|null With 'dbName', 'url', 'lang', 'general' and 'namespaces' keys. - * 'general' contains: 'wikiName', 'articlePath', 'scriptPath', 'script', - * 'timezone', and 'timezoneOffset'; 'namespaces' contains all namespace - * names, keyed by their IDs. If this function returns null, the API call - * failed. - */ - public function getMetadata(string $projectUrl): ?array - { - $cacheKey = $this->getCacheKey(func_get_args(), "project_metadata"); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - try { - $res = json_decode($this->guzzle->request('GET', $projectUrl.$this->getApiPath(), [ - 'query' => [ - 'action' => 'query', - 'meta' => 'siteinfo', - 'siprop' => 'general|namespaces|autocreatetempuser', - 'format' => 'json', - 'formatversion' => '2', - ], - ])->getBody()->getContents(), true); - } catch (Exception) { - return null; - } - - $metadata = [ - 'general' => [], - 'namespaces' => [], - 'tempAccountPatterns' => $res['query']['autocreatetempuser']['matchPatterns'] ?? null, - ]; - - if (isset($res['query']['general'])) { - $info = $res['query']['general']; - - $metadata['dbName'] = $info['wikiid']; - $metadata['url'] = $info['server']; - $metadata['lang'] = $info['lang']; - - $metadata['general'] = [ - 'wikiName' => $info['sitename'], - 'articlePath' => $info['articlepath'], - 'scriptPath' => $info['scriptpath'], - 'script' => $info['script'], - 'timezone' => $info['timezone'], - 'timeOffset' => $info['timeoffset'], - 'mainpage' => $info['mainpage'], - ]; - } - - $this->setNamespaces($res, $metadata); - - // Cache for one hour and return. - return $this->setCache($cacheKey, $metadata, 'PT1H'); - } - - /** - * Set the namespaces on the given $metadata. - * @param array $res As produced by meta=siteinfo API. - * @param array &$metadata The metadata array to modify. - */ - private function setNamespaces(array $res, array &$metadata): void - { - if (!isset($res['query']['namespaces'])) { - return; - } - - foreach ($res['query']['namespaces'] as $namespace) { - if ($namespace['id'] < 0) { - continue; - } - - if (isset($namespace['name'])) { - $name = $namespace['name']; - } elseif (isset($namespace['*'])) { - $name = $namespace['*']; - } else { - continue; - } - - $metadata['namespaces'][$namespace['id']] = $name; - } - } - - /** - * Get a list of projects that have opted in to having all their users' restricted statistics available to anyone. - * @return string[] - */ - public function optedIn(): array - { - return $this->optedIn; - } - - /** - * The path to api.php. - * @return string - */ - public function getApiPath(): string - { - return $this->apiPath; - } - - /** - * Check to see if a page exists on this project and has some content. - * @param Project $project The project. - * @param int $namespaceId The page namespace ID. - * @param string $pageTitle The page title, without namespace. - * @return bool - */ - public function pageHasContent(Project $project, int $namespaceId, string $pageTitle): bool - { - $pageTable = $this->getTableName($project->getDatabaseName(), 'page'); - $query = "SELECT page_id " - . " FROM $pageTable " - . " WHERE page_namespace = :ns AND page_title = :title AND page_len > 0 " - . " LIMIT 1"; - $params = [ - 'ns' => $namespaceId, - 'title' => str_replace(' ', '_', $pageTitle), - ]; - $pages = $this->executeProjectsQuery($project, $query, $params) - ->fetchAllAssociative(); - return count($pages) > 0; - } - - /** - * Get a list of the extensions installed on the wiki. - * @param Project $project - * @return string[] - */ - public function getInstalledExtensions(Project $project): array - { - $res = json_decode($this->guzzle->request('GET', $project->getApiUrl(), ['query' => [ - 'action' => 'query', - 'meta' => 'siteinfo', - 'siprop' => 'extensions', - 'format' => 'json', - ]])->getBody()->getContents(), true); - - $extensions = $res['query']['extensions'] ?? []; - return array_map(function ($extension) { - return $extension['name']; - }, $extensions); - } - - /** - * Get a list of users who are in one of the given user groups. - * @param Project $project - * @param string[] $groups List of user groups to look for. - * @param string[] $globalGroups List of global groups to look for. - * @return string[] with keys 'user_name' and 'ug_group' - */ - public function getUsersInGroups(Project $project, array $groups = [], array $globalGroups = []): array - { - $cacheKey = $this->getCacheKey(func_get_args(), 'project_useringroups'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $userTable = $project->getTableName('user'); - $userGroupsTable = $project->getTableName('user_groups'); - - $sql = "SELECT user_name, ug_group AS user_group + $basicInfo = $this->executeProjectsQuery( 'meta', $sql, [ + 'project' => $project, + 'projectUrl' => "https://$project", + 'projectUrl2' => "https://$project.org", + 'projectUrl3' => "https://www.$project", + 'projectUrl4' => "https://www.$project.org", + ] )->fetchAssociative(); + $basicInfo = $basicInfo === false ? null : $basicInfo; + + // Cache for one hour and return. + return $this->setCache( $cacheKey, $basicInfo, 'PT1H' ); + } + + /** + * Get metadata about a project, including the 'dbName', 'url' and 'lang' + * + * @param string $projectUrl The project's URL. + * @return array|null With 'dbName', 'url', 'lang', 'general' and 'namespaces' keys. + * 'general' contains: 'wikiName', 'articlePath', 'scriptPath', 'script', + * 'timezone', and 'timezoneOffset'; 'namespaces' contains all namespace + * names, keyed by their IDs. If this function returns null, the API call + * failed. + */ + public function getMetadata( string $projectUrl ): ?array { + $cacheKey = $this->getCacheKey( func_get_args(), "project_metadata" ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + try { + $res = json_decode( $this->guzzle->request( 'GET', $projectUrl . $this->getApiPath(), [ + 'query' => [ + 'action' => 'query', + 'meta' => 'siteinfo', + 'siprop' => 'general|namespaces|autocreatetempuser', + 'format' => 'json', + 'formatversion' => '2', + ], + ] )->getBody()->getContents(), true ); + } catch ( Exception ) { + return null; + } + + $metadata = [ + 'general' => [], + 'namespaces' => [], + 'tempAccountPatterns' => $res['query']['autocreatetempuser']['matchPatterns'] ?? null, + ]; + + if ( isset( $res['query']['general'] ) ) { + $info = $res['query']['general']; + + $metadata['dbName'] = $info['wikiid']; + $metadata['url'] = $info['server']; + $metadata['lang'] = $info['lang']; + + $metadata['general'] = [ + 'wikiName' => $info['sitename'], + 'articlePath' => $info['articlepath'], + 'scriptPath' => $info['scriptpath'], + 'script' => $info['script'], + 'timezone' => $info['timezone'], + 'timeOffset' => $info['timeoffset'], + 'mainpage' => $info['mainpage'], + ]; + } + + $this->setNamespaces( $res, $metadata ); + + // Cache for one hour and return. + return $this->setCache( $cacheKey, $metadata, 'PT1H' ); + } + + /** + * Set the namespaces on the given $metadata. + * @param array $res As produced by meta=siteinfo API. + * @param array &$metadata The metadata array to modify. + */ + private function setNamespaces( array $res, array &$metadata ): void { + if ( !isset( $res['query']['namespaces'] ) ) { + return; + } + + foreach ( $res['query']['namespaces'] as $namespace ) { + if ( $namespace['id'] < 0 ) { + continue; + } + + if ( isset( $namespace['name'] ) ) { + $name = $namespace['name']; + } elseif ( isset( $namespace['*'] ) ) { + $name = $namespace['*']; + } else { + continue; + } + + $metadata['namespaces'][$namespace['id']] = $name; + } + } + + /** + * Get a list of projects that have opted in to having all their users' restricted statistics available to anyone. + * @return string[] + */ + public function optedIn(): array { + return $this->optedIn; + } + + /** + * The path to api.php. + * @return string + */ + public function getApiPath(): string { + return $this->apiPath; + } + + /** + * Check to see if a page exists on this project and has some content. + * @param Project $project The project. + * @param int $namespaceId The page namespace ID. + * @param string $pageTitle The page title, without namespace. + * @return bool + */ + public function pageHasContent( Project $project, int $namespaceId, string $pageTitle ): bool { + $pageTable = $this->getTableName( $project->getDatabaseName(), 'page' ); + $query = "SELECT page_id " + . " FROM $pageTable " + . " WHERE page_namespace = :ns AND page_title = :title AND page_len > 0 " + . " LIMIT 1"; + $params = [ + 'ns' => $namespaceId, + 'title' => str_replace( ' ', '_', $pageTitle ), + ]; + $pages = $this->executeProjectsQuery( $project, $query, $params ) + ->fetchAllAssociative(); + return count( $pages ) > 0; + } + + /** + * Get a list of the extensions installed on the wiki. + * @param Project $project + * @return string[] + */ + public function getInstalledExtensions( Project $project ): array { + $res = json_decode( $this->guzzle->request( 'GET', $project->getApiUrl(), [ 'query' => [ + 'action' => 'query', + 'meta' => 'siteinfo', + 'siprop' => 'extensions', + 'format' => 'json', + ] ] )->getBody()->getContents(), true ); + + $extensions = $res['query']['extensions'] ?? []; + return array_map( static function ( $extension ) { + return $extension['name']; + }, $extensions ); + } + + /** + * Get a list of users who are in one of the given user groups. + * @param Project $project + * @param string[] $groups List of user groups to look for. + * @param string[] $globalGroups List of global groups to look for. + * @return string[] with keys 'user_name' and 'ug_group' + */ + public function getUsersInGroups( Project $project, array $groups = [], array $globalGroups = [] ): array { + $cacheKey = $this->getCacheKey( func_get_args(), 'project_useringroups' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $userTable = $project->getTableName( 'user' ); + $userGroupsTable = $project->getTableName( 'user_groups' ); + + $sql = "SELECT user_name, ug_group AS user_group FROM $userTable JOIN $userGroupsTable ON ug_user = user_id WHERE ug_group IN (?) GROUP BY user_name, ug_group"; - $users = $this->getProjectsConnection($project) - ->executeQuery($sql, [$groups], [ArrayParameterType::STRING]) - ->fetchAllAssociative(); + $users = $this->getProjectsConnection( $project ) + ->executeQuery( $sql, [ $groups ], [ ArrayParameterType::STRING ] ) + ->fetchAllAssociative(); - if (count($globalGroups) > 0 && $this->isWMF) { - $sql = "SELECT gu_name AS user_name, gug_group AS user_group + if ( count( $globalGroups ) > 0 && $this->isWMF ) { + $sql = "SELECT gu_name AS user_name, gug_group AS user_group FROM centralauth_p.global_user_groups JOIN centralauth_p.globaluser ON gug_user = gu_id WHERE gug_group IN (?) GROUP BY user_name, user_group"; - $globalUsers = $this->getProjectsConnection('centralauth') - ->executeQuery($sql, [$globalGroups], [ArrayParameterType::STRING]) - ->fetchAllAssociative(); + $globalUsers = $this->getProjectsConnection( 'centralauth' ) + ->executeQuery( $sql, [ $globalGroups ], [ ArrayParameterType::STRING ] ) + ->fetchAllAssociative(); - $users = array_merge($users, $globalUsers); - } + $users = array_merge( $users, $globalUsers ); + } - // Cache for 12 hours and return. - return $this->setCache($cacheKey, $users, 'PT12H'); - } + // Cache for 12 hours and return. + return $this->setCache( $cacheKey, $users, 'PT12H' ); + } } diff --git a/src/Repository/Repository.php b/src/Repository/Repository.php index 6987769cf..e9c2b6b06 100644 --- a/src/Repository/Repository.php +++ b/src/Repository/Repository.php @@ -1,6 +1,6 @@ managerRegistry->getConnection($name); - } - - /** - * Get the database connection for the 'meta' database. - * @return Connection - * @codeCoverageIgnore - */ - protected function getMetaConnection(): Connection - { - if (!isset($this->metaConnection)) { - $this->metaConnection = $this->getProjectsConnection('meta'); - } - return $this->metaConnection; - } - - /** - * Get a database connection for the given database. - * @param Project|string $project Project instance, database name (i.e. 'enwiki'), or slice (i.e. 's1'). - * @return Connection - * @codeCoverageIgnore - */ - protected function getProjectsConnection(Project|string $project): Connection - { - if (is_string($project)) { - if (1 === preg_match('/^s\d+$/', $project)) { - $slice = $project; - } else { - // Assume database name. Remove _p if given. - $db = str_replace('_p', '', $project); - $slice = $this->getDbList()[$db]; - } - } else { - $slice = $this->getDbList()[$project->getDatabaseName()]; - } - - return $this->getConnection('toolforge_'.$slice); - } - - /** - * Get the database connection for the 'tools' database (the one that other tools store data in). - * @return Connection - * @codeCoverageIgnore - */ - protected function getToolsConnection(): Connection - { - if (!isset($this->toolsConnection)) { - $this->toolsConnection = $this->getConnection('toolsdb'); - } - return $this->toolsConnection; - } - - /** - * Fetch and concatenate all the dblists into one array. - * Based on ToolforgeBundle https://github.com/wikimedia/ToolforgeBundle/blob/master/Service/ReplicasClient.php - * License: GPL 3.0 or later - * @return string[] Keys are database names (i.e. 'enwiki'), values are the slices (i.e. 's1'). - * @codeCoverageIgnore - */ - protected function getDbList(): array - { - $cacheKey = 'dblists'; - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $dbList = []; - $exists = true; - $i = 0; - - while (true) { - $i += 1; - $response = $this->guzzle->request('GET', self::DBLISTS_URL."s$i.dblist", ['http_errors' => false]); - $exists = in_array( - $response->getStatusCode(), - [Response::HTTP_OK, Response::HTTP_NOT_MODIFIED] - ) && $i < 50; // Safeguard - - if (!$exists) { - break; - } - - $lines = explode("\n", $response->getBody()->getContents()); - foreach ($lines as $line) { - $line = trim($line); - if (1 !== preg_match('/^#/', $line) && '' !== $line) { - // Skip comments and blank lines. - $dbList[$line] = "s$i"; - } - } - } - - // Manually add the meta and centralauth databases. - $dbList['meta'] = 's7'; - $dbList['centralauth'] = 's7'; - - // Cache for one week. - return $this->setCache($cacheKey, $dbList, 'P1W'); - } - - /***************** - * QUERY HELPERS * - *****************/ - - /** - * Make a request to the MediaWiki API. - * @param Project $project - * @param array $params - * @return array - * @throws BadGatewayException - */ - public function executeApiRequest(Project $project, array $params): array - { - try { - $fullParams = array_merge([ - 'action' => 'query', - 'format' => 'json', - ], $params); - if (null === $this->requestStack) { - $session = false; - } else { - $session = $this->requestStack->getSession(); - } - if ($session && $session->get('logged_in_user')) { - $oauthClient = $session->get('oauth_client'); - $queryString = http_build_query($fullParams); - $requestUrl = $project->getApiUrl() . '?' . $queryString; - $body = $oauthClient->makeOAuthCall( - $session->get('oauth_access_token'), - $requestUrl - ); - return json_decode($body, true); - } else { // Not logged in, default to a not-logged-in query - $req = $this->guzzle->request( - 'GET', - $project->getApiUrl(), - ['query' => $fullParams] - ); - $body = $req->getBody()->getContents(); - return json_decode($body, true); - } - } catch (ConnectException|ServerException|OAuthException $e) { - throw new BadGatewayException('api-error-wikimedia', ['Wikimedia'], $e); - } - } - - /** - * Normalize and quote a table name for use in SQL. - * @param string $databaseName - * @param string $tableName - * @param string|null $tableExtension Optional table extension, which will only get used if we're on labs. - * If null, table extensions are added as defined in table_map.yml. If a blank string, no extension is added. - * @return string Fully-qualified and quoted table name. - */ - public function getTableName(string $databaseName, string $tableName, ?string $tableExtension = null): string - { - $mapped = false; - - // This is a workaround for a one-to-many mapping - // as required by Labs. We combine $tableName with - // $tableExtension in order to generate the new table name - if ($this->isWMF && null !== $tableExtension) { - $mapped = true; - $tableName .=('' === $tableExtension ? '' : '_'.$tableExtension); - } elseif ($this->parameterBag->has("app.table.$tableName")) { - // Use the table specified in the table mapping configuration, if present. - $mapped = true; - $tableName = $this->parameterBag->get("app.table.$tableName"); - } - - // For 'revision' and 'logging' tables (actually views) on Labs, use the indexed versions - // (that have some rows hidden, e.g. for revdeleted users). - // This is a safeguard in case table mapping isn't properly set up. - $isLoggingOrRevision = in_array($tableName, ['revision', 'logging', 'archive']); - if (!$mapped && $isLoggingOrRevision && $this->isWMF) { - $tableName .="_userindex"; - } - - // Figure out database name. - // Use class variable for the database name if not set via function parameter. - if ($this->isWMF && '_p' != substr($databaseName, -2)) { - // Append '_p' if this is labs. - $databaseName .= '_p'; - } - - return "`$databaseName`.`$tableName`"; - } - - /** - * Get a unique cache key for the given list of arguments. Assuming each argument of - * your function should be accounted for, you can pass in them all with func_get_args: - * $this->getCacheKey(func_get_args(), 'unique key for function'); - * Arguments that are a model should implement their own getCacheKey() that returns - * a unique identifier for an instance of that model. See User::getCacheKey() for example. - * @param array|mixed $args Array of arguments or a single argument. - * @param string|null $key Unique key for this function. If omitted the function name itself - * is used, which is determined using `debug_backtrace`. - * @return string - */ - public function getCacheKey($args, ?string $key = null): string - { - if (null === $key) { - $key = debug_backtrace()[1]['function']; - } - - if (!is_array($args)) { - $args = [$args]; - } - - // Start with base key. - $cacheKey = $key; - - // Loop through and determine what values to use based on type of object. - foreach ($args as $arg) { - // Zero is an acceptable value. - if ('' === $arg || null === $arg) { - continue; - } - - $cacheKey .= $this->getCacheKeyFromArg($arg); - } - - // Remove reserved characters. - return preg_replace('/[{}()\/@:"]/', '', $cacheKey); - } - - /** - * Get a cache-friendly string given an argument. - * @param mixed $arg - * @return string - */ - private function getCacheKeyFromArg($arg): string - { - if (is_object($arg) && method_exists($arg, 'getCacheKey')) { - return '.'.$arg->getCacheKey(); - } elseif (is_array($arg)) { - // Assumed to be an array of objects that can be parsed into a string. - return '.'.md5(implode('', $arg)); - } else { - // Assumed to be a string, number or boolean. - return '.'.md5((string)$arg); - } - } - - /** - * Set the cache with given options. - * @param string $cacheKey - * @param mixed $value - * @param string $duration Valid DateInterval string. - * @return mixed The given $value. - */ - public function setCache(string $cacheKey, mixed $value, string $duration = 'PT20M'): mixed - { - $cacheItem = $this->cache - ->getItem($cacheKey) - ->set($value) - ->expiresAfter(new DateInterval($duration)); - $this->cache->save($cacheItem); - return $value; - } - - /******************************** - * DATABASE INTERACTION HELPERS * - ********************************/ - - /** - * Creates WHERE conditions with date range to be put in query. - * @param false|int $start Unix timestamp. - * @param false|int $end Unix timestamp. - * @param false|int $offset Unix timestamp. Used for pagination, will end up replacing $end. - * @param string $tableAlias Alias of table FOLLOWED BY DOT. - * @param string $field - * @return string - */ - public function getDateConditions( - false|int $start, - false|int $end, - false|int $offset = false, - string $tableAlias = '', - string $field = 'rev_timestamp' - ) : string { - $datesConditions = ''; - - if (is_int($start)) { - // Convert to YYYYMMDDHHMMSS. - $start = date('Ymd', $start).'000000'; - $datesConditions .= " AND $tableAlias{$field} >= '$start'"; - } - - // When we're given an $offset, it basically replaces $end, except it's also a full timestamp. - if (is_int($offset)) { - $offset = date('YmdHis', $offset); - $datesConditions .= " AND $tableAlias{$field} <= '$offset'"; - } elseif (is_int($end)) { - $end = date('Ymd', $end) . '235959'; - $datesConditions .= " AND $tableAlias{$field} <= '$end'"; - } - - return $datesConditions; - } - - /** - * Execute a query using the projects connection, handling certain Exceptions. - * @param Project|string $project Project instance, database name (i.e. 'enwiki'), or slice (i.e. 's1'). - * @param string $sql - * @param array $params Parameters to bound to the prepared query. - * @param int|null $timeout Maximum statement time in seconds. null will use the - * default specified by the APP_QUERY_TIMEOUT env variable. - * @return Result - * @throws DriverException - * @codeCoverageIgnore - */ - public function executeProjectsQuery( - Project|string $project, - string $sql, - array $params = [], - ?int $timeout = null - ): Result { - try { - $timeout = $timeout ?? $this->queryTimeout; - $sql = "SET STATEMENT max_statement_time = $timeout FOR\n".$sql; - - return $this->getProjectsConnection($project)->executeQuery($sql, $params); - } catch (DriverException $e) { - $this->handleDriverError($e, $timeout); - } - } - - /** - * Execute a query using the projects connection, handling certain Exceptions. - * @param QueryBuilder $qb - * @param int|null $timeout Maximum statement time in seconds. null will use the - * default specified by the APP_QUERY_TIMEOUT env variable. - * @return Result - * @throws HttpException - * @throws DriverException - * @codeCoverageIgnore - */ - public function executeQueryBuilder(QueryBuilder $qb, ?int $timeout = null): Result - { - try { - $timeout = $timeout ?? $this->queryTimeout; - $sql = "SET STATEMENT max_statement_time = $timeout FOR\n".$qb->getSQL(); - // FIXME - return $qb->executeQuery($sql, $qb->getParameters(), $qb->getParameterTypes()); - } catch (DriverException $e) { - $this->handleDriverError($e, $timeout); - } - } - - /** - * Special handling of some DriverExceptions, otherwise original Exception is thrown. - * @param DriverException $e - * @param int|null $timeout Timeout value, if applicable. This is passed to the i18n message. - * @throws HttpException - * @throws DriverException - * @codeCoverageIgnore - */ - private function handleDriverError(DriverException $e, ?int $timeout): void - { - // If no value was passed for the $timeout, it must be the default. - if (null === $timeout) { - $timeout = $this->queryTimeout; - } - - if (1226 === $e->getCode()) { - throw new ServiceUnavailableHttpException(30, 'error-service-overload', null, 503); - } elseif (in_array($e->getCode(), [2006, 2013])) { - // FIXME: Attempt to reestablish connection on 2006 error (MySQL server has gone away). - throw new HttpException( - Response::HTTP_GATEWAY_TIMEOUT, - 'error-lost-connection', - null, - [], - Response::HTTP_GATEWAY_TIMEOUT - ); - } elseif (1969 == $e->getCode()) { - throw new HttpException( - Response::HTTP_GATEWAY_TIMEOUT, - 'error-query-timeout', - null, - [$timeout], - Response::HTTP_GATEWAY_TIMEOUT - ); - } else { - throw $e; - } - } +abstract class Repository { + /** @var Connection The database connection to the meta database. */ + private Connection $metaConnection; + + /** @var Connection The database connection to other tools' databases. */ + private Connection $toolsConnection; + + /** @var string Prefix URL for where the dblists live. Will be followed by i.e. 's1.dblist' */ + public const DBLISTS_URL = 'https://noc.wikimedia.org/conf/dblists/'; + + /** + * Create a new Repository. + */ + public function __construct( + protected ManagerRegistry $managerRegistry, + protected CacheItemPoolInterface $cache, + protected Client $guzzle, + protected LoggerInterface $logger, + protected ParameterBagInterface $parameterBag, + protected bool $isWMF, + protected int $queryTimeout, + protected ?RequestStack $requestStack = null + ) { + } + + /*************** + * CONNECTIONS * + */ + + /** + * @param string $name + * @return Connection + */ + private function getConnection( string $name ): Connection { + /** @type Connection */ + return $this->managerRegistry->getConnection( $name ); + } + + /** + * Get the database connection for the 'meta' database. + * @return Connection + * @codeCoverageIgnore + */ + protected function getMetaConnection(): Connection { + if ( !isset( $this->metaConnection ) ) { + $this->metaConnection = $this->getProjectsConnection( 'meta' ); + } + return $this->metaConnection; + } + + /** + * Get a database connection for the given database. + * @param Project|string $project Project instance, database name (i.e. 'enwiki'), or slice (i.e. 's1'). + * @return Connection + * @codeCoverageIgnore + */ + protected function getProjectsConnection( Project|string $project ): Connection { + if ( is_string( $project ) ) { + if ( preg_match( '/^s\d+$/', $project ) === 1 ) { + $slice = $project; + } else { + // Assume database name. Remove _p if given. + $db = str_replace( '_p', '', $project ); + $slice = $this->getDbList()[$db]; + } + } else { + $slice = $this->getDbList()[$project->getDatabaseName()]; + } + + return $this->getConnection( 'toolforge_' . $slice ); + } + + /** + * Get the database connection for the 'tools' database (the one that other tools store data in). + * @return Connection + * @codeCoverageIgnore + */ + protected function getToolsConnection(): Connection { + if ( !isset( $this->toolsConnection ) ) { + $this->toolsConnection = $this->getConnection( 'toolsdb' ); + } + return $this->toolsConnection; + } + + /** + * Fetch and concatenate all the dblists into one array. + * Based on ToolforgeBundle https://github.com/wikimedia/ToolforgeBundle/blob/master/Service/ReplicasClient.php + * License: GPL 3.0 or later + * @return string[] Keys are database names (i.e. 'enwiki'), values are the slices (i.e. 's1'). + * @codeCoverageIgnore + */ + protected function getDbList(): array { + $cacheKey = 'dblists'; + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $dbList = []; + $exists = true; + $i = 0; + + while ( true ) { + $i += 1; + $response = $this->guzzle->request( 'GET', self::DBLISTS_URL . "s$i.dblist", [ 'http_errors' => false ] ); + $exists = in_array( + $response->getStatusCode(), + [ Response::HTTP_OK, Response::HTTP_NOT_MODIFIED ] + ) && $i < 50; + + if ( !$exists ) { + break; + } + + $lines = explode( "\n", $response->getBody()->getContents() ); + foreach ( $lines as $line ) { + $line = trim( $line ); + if ( preg_match( '/^#/', $line ) !== 1 && $line !== '' ) { + // Skip comments and blank lines. + $dbList[$line] = "s$i"; + } + } + } + + // Manually add the meta and centralauth databases. + $dbList['meta'] = 's7'; + $dbList['centralauth'] = 's7'; + + // Cache for one week. + return $this->setCache( $cacheKey, $dbList, 'P1W' ); + } + + /***************** + * QUERY HELPERS * + */ + + /** + * Make a request to the MediaWiki API. + * @param Project $project + * @param array $params + * @return array + * @throws BadGatewayException + */ + public function executeApiRequest( Project $project, array $params ): array { + try { + $fullParams = array_merge( [ + 'action' => 'query', + 'format' => 'json', + ], $params ); + if ( $this->requestStack === null ) { + $session = false; + } else { + $session = $this->requestStack->getSession(); + } + if ( $session && $session->get( 'logged_in_user' ) ) { + $oauthClient = $session->get( 'oauth_client' ); + $queryString = http_build_query( $fullParams ); + $requestUrl = $project->getApiUrl() . '?' . $queryString; + $body = $oauthClient->makeOAuthCall( + $session->get( 'oauth_access_token' ), + $requestUrl + ); + return json_decode( $body, true ); + } else { + // Not logged in, default to a not-logged-in query + $req = $this->guzzle->request( + 'GET', + $project->getApiUrl(), + [ 'query' => $fullParams ] + ); + $body = $req->getBody()->getContents(); + return json_decode( $body, true ); + } + } catch ( ConnectException | ServerException | OAuthException $e ) { + throw new BadGatewayException( 'api-error-wikimedia', [ 'Wikimedia' ], $e ); + } + } + + /** + * Normalize and quote a table name for use in SQL. + * @param string $databaseName + * @param string $tableName + * @param string|null $tableExtension Optional table extension, which will only get used if we're on labs. + * If null, table extensions are added as defined in table_map.yml. If a blank string, no extension is added. + * @return string Fully-qualified and quoted table name. + */ + public function getTableName( string $databaseName, string $tableName, ?string $tableExtension = null ): string { + $mapped = false; + + // This is a workaround for a one-to-many mapping + // as required by Labs. We combine $tableName with + // $tableExtension in order to generate the new table name + if ( $this->isWMF && $tableExtension !== null ) { + $mapped = true; + $tableName .= ( $tableExtension === '' ? '' : '_' . $tableExtension ); + } elseif ( $this->parameterBag->has( "app.table.$tableName" ) ) { + // Use the table specified in the table mapping configuration, if present. + $mapped = true; + $tableName = $this->parameterBag->get( "app.table.$tableName" ); + } + + // For 'revision' and 'logging' tables (actually views) on Labs, use the indexed versions + // (that have some rows hidden, e.g. for revdeleted users). + // This is a safeguard in case table mapping isn't properly set up. + $isLoggingOrRevision = in_array( $tableName, [ 'revision', 'logging', 'archive' ] ); + if ( !$mapped && $isLoggingOrRevision && $this->isWMF ) { + $tableName .= "_userindex"; + } + + // Figure out database name. + // Use class variable for the database name if not set via function parameter. + if ( $this->isWMF && !str_ends_with( $databaseName, '_p' ) ) { + // Append '_p' if this is labs. + $databaseName .= '_p'; + } + + return "`$databaseName`.`$tableName`"; + } + + /** + * Get a unique cache key for the given list of arguments. Assuming each argument of + * your function should be accounted for, you can pass in them all with func_get_args: + * $this->getCacheKey(func_get_args(), 'unique key for function'); + * Arguments that are a model should implement their own getCacheKey() that returns + * a unique identifier for an instance of that model. See User::getCacheKey() for example. + * @param array|mixed $args Array of arguments or a single argument. + * @param string|null $key Unique key for this function. If omitted the function name itself + * is used, which is determined using `debug_backtrace`. + * @return string + */ + public function getCacheKey( mixed $args, ?string $key = null ): string { + if ( $key === null ) { + $key = debug_backtrace()[1]['function']; + } + + if ( !is_array( $args ) ) { + $args = [ $args ]; + } + + // Start with base key. + $cacheKey = $key; + + // Loop through and determine what values to use based on type of object. + foreach ( $args as $arg ) { + // Zero is an acceptable value. + if ( $arg === '' || $arg === null ) { + continue; + } + + $cacheKey .= $this->getCacheKeyFromArg( $arg ); + } + + // Remove reserved characters. + return preg_replace( '/[{}()\/@:"]/', '', $cacheKey ); + } + + /** + * Get a cache-friendly string given an argument. + * @param mixed $arg + * @return string + */ + private function getCacheKeyFromArg( mixed $arg ): string { + if ( is_object( $arg ) && method_exists( $arg, 'getCacheKey' ) ) { + return '.' . $arg->getCacheKey(); + } elseif ( is_array( $arg ) ) { + // Assumed to be an array of objects that can be parsed into a string. + return '.' . md5( implode( '', $arg ) ); + } else { + // Assumed to be a string, number or boolean. + return '.' . md5( (string)$arg ); + } + } + + /** + * Set the cache with given options. + * @param string $cacheKey + * @param mixed $value + * @param string $duration Valid DateInterval string. + * @return mixed The given $value. + */ + public function setCache( string $cacheKey, mixed $value, string $duration = 'PT20M' ): mixed { + $cacheItem = $this->cache + ->getItem( $cacheKey ) + ->set( $value ) + ->expiresAfter( new DateInterval( $duration ) ); + $this->cache->save( $cacheItem ); + return $value; + } + + /******************************** + * DATABASE INTERACTION HELPERS * + */ + + /** + * Creates WHERE conditions with date range to be put in query. + * @param false|int $start Unix timestamp. + * @param false|int $end Unix timestamp. + * @param false|int $offset Unix timestamp. Used for pagination, will end up replacing $end. + * @param string $tableAlias Alias of table FOLLOWED BY DOT. + * @param string $field + * @return string + */ + public function getDateConditions( + false|int $start, + false|int $end, + false|int $offset = false, + string $tableAlias = '', + string $field = 'rev_timestamp' + ): string { + $datesConditions = ''; + + if ( is_int( $start ) ) { + // Convert to YYYYMMDDHHMMSS. + $start = date( 'Ymd', $start ) . '000000'; + $datesConditions .= " AND $tableAlias{$field} >= '$start'"; + } + + // When we're given an $offset, it basically replaces $end, except it's also a full timestamp. + if ( is_int( $offset ) ) { + $offset = date( 'YmdHis', $offset ); + $datesConditions .= " AND $tableAlias{$field} <= '$offset'"; + } elseif ( is_int( $end ) ) { + $end = date( 'Ymd', $end ) . '235959'; + $datesConditions .= " AND $tableAlias{$field} <= '$end'"; + } + + return $datesConditions; + } + + /** + * Execute a query using the projects connection, handling certain Exceptions. + * @param Project|string $project Project instance, database name (i.e. 'enwiki'), or slice (i.e. 's1'). + * @param string $sql + * @param array $params Parameters to bound to the prepared query. + * @param int|null $timeout Maximum statement time in seconds. null will use the + * default specified by the APP_QUERY_TIMEOUT env variable. + * @return Result + * @throws DriverException + * @codeCoverageIgnore + */ + public function executeProjectsQuery( + Project|string $project, + string $sql, + array $params = [], + ?int $timeout = null + ): Result { + try { + $timeout = $timeout ?? $this->queryTimeout; + $sql = "SET STATEMENT max_statement_time = $timeout FOR\n" . $sql; + + return $this->getProjectsConnection( $project )->executeQuery( $sql, $params ); + } catch ( DriverException $e ) { + $this->handleDriverError( $e, $timeout ); + } + } + + /** + * Execute a query using the projects connection, handling certain Exceptions. + * @param QueryBuilder $qb + * @param int|null $timeout Maximum statement time in seconds. null will use the + * default specified by the APP_QUERY_TIMEOUT env variable. + * @return Result + * @throws HttpException + * @throws DriverException + * @codeCoverageIgnore + */ + public function executeQueryBuilder( QueryBuilder $qb, ?int $timeout = null ): Result { + try { + $timeout = $timeout ?? $this->queryTimeout; + $sql = "SET STATEMENT max_statement_time = $timeout FOR\n" . $qb->getSQL(); + // FIXME + return $qb->executeQuery( $sql, $qb->getParameters(), $qb->getParameterTypes() ); + } catch ( DriverException $e ) { + $this->handleDriverError( $e, $timeout ); + } + } + + /** + * Special handling of some DriverExceptions, otherwise original Exception is thrown. + * @param DriverException $e + * @param int|null $timeout Timeout value, if applicable. This is passed to the i18n message. + * @throws HttpException + * @throws DriverException + * @codeCoverageIgnore + */ + private function handleDriverError( DriverException $e, ?int $timeout ): void { + // If no value was passed for the $timeout, it must be the default. + if ( $timeout === null ) { + $timeout = $this->queryTimeout; + } + + if ( $e->getCode() === 1226 ) { + throw new ServiceUnavailableHttpException( 30, 'error-service-overload', null, 503 ); + } elseif ( in_array( $e->getCode(), [ 2006, 2013 ] ) ) { + // FIXME: Attempt to reestablish connection on 2006 error (MySQL server has gone away). + throw new HttpException( + Response::HTTP_GATEWAY_TIMEOUT, + 'error-lost-connection', + null, + [], + Response::HTTP_GATEWAY_TIMEOUT + ); + } elseif ( $e->getCode() === 1969 ) { + throw new HttpException( + Response::HTTP_GATEWAY_TIMEOUT, + 'error-query-timeout', + null, + [ $timeout ], + Response::HTTP_GATEWAY_TIMEOUT + ); + } else { + throw $e; + } + } } diff --git a/src/Repository/SimpleEditCounterRepository.php b/src/Repository/SimpleEditCounterRepository.php index c7a4b5fc4..47560c15a 100644 --- a/src/Repository/SimpleEditCounterRepository.php +++ b/src/Repository/SimpleEditCounterRepository.php @@ -1,6 +1,6 @@ getCacheKey(func_get_args(), 'simple_editcount'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - if ($user->isIpRange()) { - $result = $this->fetchDataIpRange($project, $user, $namespace, $start, $end); - } else { - $result = $this->fetchDataNormal($project, $user, $namespace, $start, $end); - } - - // Cache and return. - return $this->setCache($cacheKey, $result); - } - - /** - * @param Project $project - * @param User $user - * @param int|string $namespace - * @param int|false $start - * @param int|false $end - * @return string[] Counts, each row with keys 'source' and 'value'. - */ - private function fetchDataNormal( - Project $project, - User $user, - int|string $namespace = 'all', - int|false $start = false, - int|false $end = false, - ): array { - $userTable = $project->getTableName('user'); - $pageTable = $project->getTableName('page'); - $archiveTable = $project->getTableName('archive'); - $revisionTable = $project->getTableName('revision'); - $userGroupsTable = $project->getTableName('user_groups'); - - $arDateConditions = $this->getDateConditions($start, $end, false, '', 'ar_timestamp'); - $revDateConditions = $this->getDateConditions($start, $end); - - // Always JOIN on page, see T325492 - $revNamespaceJoinSql = "JOIN $pageTable ON rev_page = page_id"; - $revNamespaceWhereSql = 'all' === $namespace ? '' : "AND page_namespace = $namespace"; - $arNamespaceWhereSql = 'all' === $namespace ? '' : "AND ar_namespace = $namespace"; - - $sql = "SELECT 'id' AS source, user_id as value +class SimpleEditCounterRepository extends Repository { + /** + * Execute and return results of the query used for the Simple Edit Counter. + * @param Project $project + * @param User $user + * @param int|string $namespace Namespace ID or 'all' for all namespaces. + * @param int|false $start Unix timestamp. + * @param int|false $end Unix timestamp. + * @return string[] Counts, each row with keys 'source' and 'value'. + */ + public function fetchData( + Project $project, + User $user, + int|string $namespace = 'all', + int|false $start = false, + int|false $end = false + ): array { + $cacheKey = $this->getCacheKey( func_get_args(), 'simple_editcount' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + if ( $user->isIpRange() ) { + $result = $this->fetchDataIpRange( $project, $user, $namespace, $start, $end ); + } else { + $result = $this->fetchDataNormal( $project, $user, $namespace, $start, $end ); + } + + // Cache and return. + return $this->setCache( $cacheKey, $result ); + } + + /** + * @param Project $project + * @param User $user + * @param int|string $namespace + * @param int|false $start + * @param int|false $end + * @return string[] Counts, each row with keys 'source' and 'value'. + */ + private function fetchDataNormal( + Project $project, + User $user, + int|string $namespace = 'all', + int|false $start = false, + int|false $end = false, + ): array { + $userTable = $project->getTableName( 'user' ); + $pageTable = $project->getTableName( 'page' ); + $archiveTable = $project->getTableName( 'archive' ); + $revisionTable = $project->getTableName( 'revision' ); + $userGroupsTable = $project->getTableName( 'user_groups' ); + + $arDateConditions = $this->getDateConditions( $start, $end, false, '', 'ar_timestamp' ); + $revDateConditions = $this->getDateConditions( $start, $end ); + + // Always JOIN on page, see T325492 + $revNamespaceJoinSql = "JOIN $pageTable ON rev_page = page_id"; + $revNamespaceWhereSql = $namespace === 'all' ? '' : "AND page_namespace = $namespace"; + $arNamespaceWhereSql = $namespace === 'all' ? '' : "AND ar_namespace = $namespace"; + + $sql = "SELECT 'id' AS source, user_id as value FROM $userTable WHERE user_name = :username UNION @@ -105,48 +104,48 @@ private function fetchDataNormal( JOIN $userTable ON user_id = ug_user WHERE user_name = :username"; - return $this->executeProjectsQuery($project, $sql, [ - 'username' => $user->getUsername(), - 'actorId' => $user->getActorId($project), - ])->fetchAllAssociative(); - } - - /** - * @param Project $project - * @param User $user - * @param int|string $namespace - * @param int|false $start - * @param int|false $end - * @return string[] Counts, each row with keys 'source' and 'value'. - */ - private function fetchDataIpRange( - Project $project, - User $user, - int|string $namespace = 'all', - int|false $start = false, - int|false $end = false - ): array { - $ipcTable = $project->getTableName('ip_changes'); - $revTable = $project->getTableName('revision', ''); - $pageTable = $project->getTableName('page'); - - $revDateConditions = $this->getDateConditions($start, $end, false, "$ipcTable.", 'ipc_rev_timestamp'); - [$startHex, $endHex] = IPUtils::parseRange($user->getUsername()); - - $revNamespaceJoinSql = 'all' === $namespace ? '' : "JOIN $revTable ON rev_id = ipc_rev_id " . - "JOIN $pageTable ON rev_page = page_id"; - $revNamespaceWhereSql = 'all' === $namespace ? '' : "AND page_namespace = $namespace"; - - $sql = "SELECT 'rev' AS source, COUNT(*) AS value + return $this->executeProjectsQuery( $project, $sql, [ + 'username' => $user->getUsername(), + 'actorId' => $user->getActorId( $project ), + ] )->fetchAllAssociative(); + } + + /** + * @param Project $project + * @param User $user + * @param int|string $namespace + * @param int|false $start + * @param int|false $end + * @return string[] Counts, each row with keys 'source' and 'value'. + */ + private function fetchDataIpRange( + Project $project, + User $user, + int|string $namespace = 'all', + int|false $start = false, + int|false $end = false + ): array { + $ipcTable = $project->getTableName( 'ip_changes' ); + $revTable = $project->getTableName( 'revision', '' ); + $pageTable = $project->getTableName( 'page' ); + + $revDateConditions = $this->getDateConditions( $start, $end, false, "$ipcTable.", 'ipc_rev_timestamp' ); + [ $startHex, $endHex ] = IPUtils::parseRange( $user->getUsername() ); + + $revNamespaceJoinSql = $namespace === 'all' ? '' : "JOIN $revTable ON rev_id = ipc_rev_id " . + "JOIN $pageTable ON rev_page = page_id"; + $revNamespaceWhereSql = $namespace === 'all' ? '' : "AND page_namespace = $namespace"; + + $sql = "SELECT 'rev' AS source, COUNT(*) AS value FROM $ipcTable $revNamespaceJoinSql WHERE ipc_hex BETWEEN :start AND :end $revDateConditions $revNamespaceWhereSql"; - return $this->executeProjectsQuery($project, $sql, [ - 'start' => $startHex, - 'end' => $endHex, - ])->fetchAllAssociative(); - } + return $this->executeProjectsQuery( $project, $sql, [ + 'start' => $startHex, + 'end' => $endHex, + ] )->fetchAllAssociative(); + } } diff --git a/src/Repository/TopEditsRepository.php b/src/Repository/TopEditsRepository.php index 15fcbc12c..65aa35c48 100644 --- a/src/Repository/TopEditsRepository.php +++ b/src/Repository/TopEditsRepository.php @@ -1,6 +1,6 @@ editRepo, $this->userRepo, $page, $revision); - } - - /** - * Get the top edits by a user in a single namespace. - * @param Project $project - * @param User $user - * @param int $namespace Namespace ID. - * @param int|false $start Start date as Unix timestamp. - * @param int|false $end End date as Unix timestamp. - * @param int $limit Number of edits to fetch. - * @param int $pagination Which page of results to return. - * @return string[] namespace, page_title, redirect, count (number of edits), assessment (page assessment). - */ - public function getTopEditsNamespace( - Project $project, - User $user, - int $namespace = 0, - int|false $start = false, - int|false $end = false, - int $limit = 1000, - int $pagination = 0 - ): array { - // Set up cache. - $cacheKey = $this->getCacheKey(func_get_args(), 'topedits_ns'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $revDateConditions = $this->getDateConditions($start, $end); - $pageTable = $project->getTableName('page'); - $revisionTable = $project->getTableName('revision'); - - $hasPageAssessments = $this->isWMF && $project->hasPageAssessments($namespace); - $paTable = $project->getTableName('page_assessments'); - $paSelect = $hasPageAssessments - ? ", ( +class TopEditsRepository extends UserRepository { + public function __construct( + protected ManagerRegistry $managerRegistry, + protected CacheItemPoolInterface $cache, + protected Client $guzzle, + protected LoggerInterface $logger, + protected ParameterBagInterface $parameterBag, + protected bool $isWMF, + protected int $queryTimeout, + protected ProjectRepository $projectRepo, + protected EditRepository $editRepo, + protected UserRepository $userRepo, + protected ?RequestStack $requestStack + ) { + parent::__construct( + $managerRegistry, + $cache, + $guzzle, + $logger, + $parameterBag, + $isWMF, + $queryTimeout, + $projectRepo, + $requestStack + ); + } + + /** + * Factory to instantiate a new Edit for the given revision. + * @param Page $page + * @param array $revision + * @return Edit + */ + public function getEdit( Page $page, array $revision ): Edit { + return new Edit( $this->editRepo, $this->userRepo, $page, $revision ); + } + + /** + * Get the top edits by a user in a single namespace. + * @param Project $project + * @param User $user + * @param int $namespace Namespace ID. + * @param int|false $start Start date as Unix timestamp. + * @param int|false $end End date as Unix timestamp. + * @param int $limit Number of edits to fetch. + * @param int $pagination Which page of results to return. + * @return string[] namespace, page_title, redirect, count (number of edits), assessment (page assessment). + */ + public function getTopEditsNamespace( + Project $project, + User $user, + int $namespace = 0, + int|false $start = false, + int|false $end = false, + int $limit = 1000, + int $pagination = 0 + ): array { + // Set up cache. + $cacheKey = $this->getCacheKey( func_get_args(), 'topedits_ns' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $revDateConditions = $this->getDateConditions( $start, $end ); + $pageTable = $project->getTableName( 'page' ); + $revisionTable = $project->getTableName( 'revision' ); + + $hasPageAssessments = $this->isWMF && $project->hasPageAssessments( $namespace ); + $paTable = $project->getTableName( 'page_assessments' ); + $paSelect = $hasPageAssessments + ? ", ( SELECT pa_class FROM $paTable WHERE pa_page_id = page_id AND pa_class != '' LIMIT 1 ) AS pa_class" - : ''; - - $ipcJoin = ''; - $whereClause = 'rev_actor = :actorId'; - $params = []; - if ($user->isIpRange()) { - $ipcTable = $project->getTableName('ip_changes'); - $ipcJoin = "JOIN $ipcTable ON rev_id = ipc_rev_id"; - $whereClause = 'ipc_hex BETWEEN :startIp AND :endIp'; - [$params['startIp'], $params['endIp']] = IPUtils::parseRange($user->getUsername()); - } - - $offset = $pagination * $limit; - $sql = "SELECT page_namespace AS `namespace`, page_title, + : ''; + + $ipcJoin = ''; + $whereClause = 'rev_actor = :actorId'; + $params = []; + if ( $user->isIpRange() ) { + $ipcTable = $project->getTableName( 'ip_changes' ); + $ipcJoin = "JOIN $ipcTable ON rev_id = ipc_rev_id"; + $whereClause = 'ipc_hex BETWEEN :startIp AND :endIp'; + [ $params['startIp'], $params['endIp'] ] = IPUtils::parseRange( $user->getUsername() ); + } + + $offset = $pagination * $limit; + $sql = "SELECT page_namespace AS `namespace`, page_title, page_is_redirect AS `redirect`, COUNT(page_title) AS `count` $paSelect FROM $pageTable @@ -129,51 +127,51 @@ public function getTopEditsNamespace( LIMIT $limit OFFSET $offset"; - $resultQuery = $this->executeQuery($sql, $project, $user, $namespace, $params); - $result = $resultQuery->fetchAllAssociative(); - - // Cache and return. - return $this->setCache($cacheKey, $result); - } - - /** - * Count the number of pages edited in the given namespace. - * @param Project $project - * @param User $user - * @param int|string $namespace - * @param int|false $start Start date as Unix timestamp. - * @param int|false $end End date as Unix timestamp. - * @return mixed - */ - public function countPagesNamespace( - Project $project, - User $user, - int|string $namespace, - int|false $start = false, - int|false $end = false - ) { - // Set up cache. - $cacheKey = $this->getCacheKey(func_get_args(), 'topedits_count_ns'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $revDateConditions = $this->getDateConditions($start, $end); - $pageTable = $project->getTableName('page'); - $revisionTable = $project->getTableName('revision'); - $nsCondition = is_numeric($namespace) ? 'AND page_namespace = :namespace' : ''; - - $ipcJoin = ''; - $whereClause = 'rev_actor = :actorId'; - $params = []; - if ($user->isIpRange()) { - $ipcTable = $project->getTableName('ip_changes'); - $ipcJoin = "JOIN $ipcTable ON rev_id = ipc_rev_id"; - $whereClause = 'ipc_hex BETWEEN :startIp AND :endIp'; - [$params['startIp'], $params['endIp']] = IPUtils::parseRange($user->getUsername()); - } - - $sql = "SELECT COUNT(DISTINCT page_id) + $resultQuery = $this->executeQuery( $sql, $project, $user, $namespace, $params ); + $result = $resultQuery->fetchAllAssociative(); + + // Cache and return. + return $this->setCache( $cacheKey, $result ); + } + + /** + * Count the number of pages edited in the given namespace. + * @param Project $project + * @param User $user + * @param int|string $namespace + * @param int|false $start Start date as Unix timestamp. + * @param int|false $end End date as Unix timestamp. + * @return mixed + */ + public function countPagesNamespace( + Project $project, + User $user, + int|string $namespace, + int|false $start = false, + int|false $end = false + ) { + // Set up cache. + $cacheKey = $this->getCacheKey( func_get_args(), 'topedits_count_ns' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $revDateConditions = $this->getDateConditions( $start, $end ); + $pageTable = $project->getTableName( 'page' ); + $revisionTable = $project->getTableName( 'revision' ); + $nsCondition = is_numeric( $namespace ) ? 'AND page_namespace = :namespace' : ''; + + $ipcJoin = ''; + $whereClause = 'rev_actor = :actorId'; + $params = []; + if ( $user->isIpRange() ) { + $ipcTable = $project->getTableName( 'ip_changes' ); + $ipcJoin = "JOIN $ipcTable ON rev_id = ipc_rev_id"; + $whereClause = 'ipc_hex BETWEEN :startIp AND :endIp'; + [ $params['startIp'], $params['endIp'] ] = IPUtils::parseRange( $user->getUsername() ); + } + + $sql = "SELECT COUNT(DISTINCT page_id) FROM $pageTable JOIN $revisionTable ON page_id = rev_page $ipcJoin @@ -181,50 +179,50 @@ public function countPagesNamespace( $nsCondition $revDateConditions"; - $resultQuery = $this->executeQuery($sql, $project, $user, $namespace, $params); - - // Cache and return. - return $this->setCache($cacheKey, $resultQuery->fetchOne()); - } - - /** - * Get the 10 Wikiprojects within which the user has the most edits. - * @param Project $project - * @param User $user - * @param int $ns - * @param int|false $start - * @param int|false $end - * @return array - */ - public function getProjectTotals( - Project $project, - User $user, - int $ns, - int|false $start = false, - int|false $end = false - ): array { - $cacheKey = $this->getCacheKey(func_get_args(), 'top_edits_wikiprojects'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $revDateConditions = $this->getDateConditions($start, $end); - $pageTable = $project->getTableName('page'); - $revisionTable = $project->getTableName('revision'); - $pageAssessmentsTable = $project->getTableName('page_assessments'); - $paProjectsTable = $project->getTableName('page_assessments_projects'); - - $ipcJoin = ''; - $whereClause = 'rev_actor = :actorId'; - $params = []; - if ($user->isIpRange()) { - $ipcTable = $project->getTableName('ip_changes'); - $ipcJoin = "JOIN $ipcTable ON rev_id = ipc_rev_id"; - $whereClause = 'ipc_hex BETWEEN :startIp AND :endIp'; - [$params['startIp'], $params['endIp']] = IPUtils::parseRange($user->getUsername()); - } - - $sql = "SELECT pap_project_title, SUM(`edit_count`) AS `count` + $resultQuery = $this->executeQuery( $sql, $project, $user, $namespace, $params ); + + // Cache and return. + return $this->setCache( $cacheKey, $resultQuery->fetchOne() ); + } + + /** + * Get the 10 Wikiprojects within which the user has the most edits. + * @param Project $project + * @param User $user + * @param int $ns + * @param int|false $start + * @param int|false $end + * @return array + */ + public function getProjectTotals( + Project $project, + User $user, + int $ns, + int|false $start = false, + int|false $end = false + ): array { + $cacheKey = $this->getCacheKey( func_get_args(), 'top_edits_wikiprojects' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $revDateConditions = $this->getDateConditions( $start, $end ); + $pageTable = $project->getTableName( 'page' ); + $revisionTable = $project->getTableName( 'revision' ); + $pageAssessmentsTable = $project->getTableName( 'page_assessments' ); + $paProjectsTable = $project->getTableName( 'page_assessments_projects' ); + + $ipcJoin = ''; + $whereClause = 'rev_actor = :actorId'; + $params = []; + if ( $user->isIpRange() ) { + $ipcTable = $project->getTableName( 'ip_changes' ); + $ipcJoin = "JOIN $ipcTable ON rev_id = ipc_rev_id"; + $whereClause = 'ipc_hex BETWEEN :startIp AND :endIp'; + [ $params['startIp'], $params['endIp'] ] = IPUtils::parseRange( $user->getUsername() ); + } + + $sql = "SELECT pap_project_title, SUM(`edit_count`) AS `count` FROM ( SELECT page_id, COUNT(page_id) AS `edit_count` FROM $revisionTable @@ -241,61 +239,61 @@ public function getProjectTotals( ORDER BY `count` DESC LIMIT 10"; - $totals = $this->executeQuery($sql, $project, $user, $ns) - ->fetchAllAssociative(); - - // Cache and return. - return $this->setCache($cacheKey, $totals); - } - - /** - * Get the top edits by a user across all namespaces. - * @param Project $project - * @param User $user - * @param int|false $start Start date as Unix timestamp. - * @param int|false $end End date as Unix timestamp. - * @param int $limit Number of edits to fetch. - * @return string[] namespace, page_title, redirect, count (number of edits), assessment (page assessment). - */ - public function getTopEditsAllNamespaces( - Project $project, - User $user, - int|false $start = false, - int|false $end = false, - int $limit = 10 - ): array { - // Set up cache. - $cacheKey = $this->getCacheKey(func_get_args(), 'topedits_all'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $revDateConditions = $this->getDateConditions($start, $end); - $pageTable = $this->getTableName($project->getDatabaseName(), 'page'); - $revisionTable = $this->getTableName($project->getDatabaseName(), 'revision'); - $hasPageAssessments = $this->isWMF && $project->hasPageAssessments(); - $pageAssessmentsTable = $this->getTableName($project->getDatabaseName(), 'page_assessments'); - $paSelect = $hasPageAssessments - ? ", ( + $totals = $this->executeQuery( $sql, $project, $user, $ns ) + ->fetchAllAssociative(); + + // Cache and return. + return $this->setCache( $cacheKey, $totals ); + } + + /** + * Get the top edits by a user across all namespaces. + * @param Project $project + * @param User $user + * @param int|false $start Start date as Unix timestamp. + * @param int|false $end End date as Unix timestamp. + * @param int $limit Number of edits to fetch. + * @return string[] namespace, page_title, redirect, count (number of edits), assessment (page assessment). + */ + public function getTopEditsAllNamespaces( + Project $project, + User $user, + int|false $start = false, + int|false $end = false, + int $limit = 10 + ): array { + // Set up cache. + $cacheKey = $this->getCacheKey( func_get_args(), 'topedits_all' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $revDateConditions = $this->getDateConditions( $start, $end ); + $pageTable = $this->getTableName( $project->getDatabaseName(), 'page' ); + $revisionTable = $this->getTableName( $project->getDatabaseName(), 'revision' ); + $hasPageAssessments = $this->isWMF && $project->hasPageAssessments(); + $pageAssessmentsTable = $this->getTableName( $project->getDatabaseName(), 'page_assessments' ); + $paSelect = $hasPageAssessments + ? ", ( SELECT pa_class FROM $pageAssessmentsTable WHERE pa_page_id = e.page_id AND pa_class != '' LIMIT 1 ) AS pa_class" - : ''; - - $ipcJoin = ''; - $whereClause = 'rev_actor = :actorId'; - $params = []; - if ($user->isIpRange()) { - $ipcTable = $project->getTableName('ip_changes'); - $ipcJoin = "JOIN $ipcTable ON rev_id = ipc_rev_id"; - $whereClause = 'ipc_hex BETWEEN :startIp AND :endIp'; - [$params['startIp'], $params['endIp']] = IPUtils::parseRange($user->getUsername()); - } - - $sql = "SELECT c.page_namespace AS `namespace`, e.page_title, + : ''; + + $ipcJoin = ''; + $whereClause = 'rev_actor = :actorId'; + $params = []; + if ( $user->isIpRange() ) { + $ipcTable = $project->getTableName( 'ip_changes' ); + $ipcJoin = "JOIN $ipcTable ON rev_id = ipc_rev_id"; + $whereClause = 'ipc_hex BETWEEN :startIp AND :endIp'; + [ $params['startIp'], $params['endIp'] ] = IPUtils::parseRange( $user->getUsername() ); + } + + $sql = "SELECT c.page_namespace AS `namespace`, e.page_title, c.page_is_redirect AS `redirect`, c.count $paSelect FROM ( @@ -317,70 +315,69 @@ public function getTopEditsAllNamespaces( ) AS c JOIN $pageTable e ON e.page_id = c.rev_page WHERE c.`row_number` <= $limit"; - $resultQuery = $this->executeQuery($sql, $project, $user, 'all', $params); - $result = $resultQuery->fetchAllAssociative(); - - // Cache and return. - return $this->setCache($cacheKey, $result); - } - - /** - * Get the top edits by a user to a single page. - * @param Page $page - * @param User $user - * @param int|false $start Start date as Unix timestamp. - * @param int|false $end End date as Unix timestamp. - * @return string[][] Each row with keys 'id', 'timestamp', 'minor', 'length', - * 'length_change', 'reverted', 'user_id', 'username', 'comment', 'parent_comment' - */ - public function getTopEditsPage(Page $page, User $user, int|false $start = false, int|false $end = false): array - { - // Set up cache. - $cacheKey = $this->getCacheKey(func_get_args(), 'topedits_page'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $results = $this->queryTopEditsPage($page, $user, $start, $end, true); - - // Now we need to get the most recent revision, since the childrevs stuff excludes it. - $lastRev = $this->queryTopEditsPage($page, $user, $start, $end, false); - if (empty($results) || $lastRev[0]['id'] !== $results[0]['id']) { - $results = array_merge($lastRev, $results); - } - - // Cache and return. - return $this->setCache($cacheKey, $results); - } - - /** - * The actual query to get the top edits by the user to the page. - * Because of the way the main query works, we aren't given the most recent revision, - * so we have to call this twice, once with $childRevs set to true and once with false. - * @param Page $page - * @param User $user - * @param int|false $start Start date as Unix timestamp. - * @param int|false $end End date as Unix timestamp. - * @param boolean $childRevs Whether to include child revisions. - * @return array Each row with keys 'id', 'timestamp', 'minor', 'length', - * 'length_change', 'reverted', 'user_id', 'username', 'comment', 'parent_comment' - */ - private function queryTopEditsPage( - Page $page, - User $user, - int|false $start = false, - int|false $end = false, - bool $childRevs = false - ): array { - $project = $page->getProject(); - $revDateConditions = $this->getDateConditions($start, $end, false, 'revs.'); - $revTable = $project->getTableName('revision'); - $commentTable = $project->getTableName('comment'); - $tagTable = $project->getTableName('change_tag'); - $tagDefTable = $project->getTableName('change_tag_def'); - // sha1 temporarily disabled, see T407814/T389026 - if ($childRevs) { - $childSelect = ", ( + $resultQuery = $this->executeQuery( $sql, $project, $user, 'all', $params ); + $result = $resultQuery->fetchAllAssociative(); + + // Cache and return. + return $this->setCache( $cacheKey, $result ); + } + + /** + * Get the top edits by a user to a single page. + * @param Page $page + * @param User $user + * @param int|false $start Start date as Unix timestamp. + * @param int|false $end End date as Unix timestamp. + * @return string[][] Each row with keys 'id', 'timestamp', 'minor', 'length', + * 'length_change', 'reverted', 'user_id', 'username', 'comment', 'parent_comment' + */ + public function getTopEditsPage( Page $page, User $user, int|false $start = false, int|false $end = false ): array { + // Set up cache. + $cacheKey = $this->getCacheKey( func_get_args(), 'topedits_page' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $results = $this->queryTopEditsPage( $page, $user, $start, $end, true ); + + // Now we need to get the most recent revision, since the childrevs stuff excludes it. + $lastRev = $this->queryTopEditsPage( $page, $user, $start, $end, false ); + if ( empty( $results ) || $lastRev[0]['id'] !== $results[0]['id'] ) { + $results = array_merge( $lastRev, $results ); + } + + // Cache and return. + return $this->setCache( $cacheKey, $results ); + } + + /** + * The actual query to get the top edits by the user to the page. + * Because of the way the main query works, we aren't given the most recent revision, + * so we have to call this twice, once with $childRevs set to true and once with false. + * @param Page $page + * @param User $user + * @param int|false $start Start date as Unix timestamp. + * @param int|false $end End date as Unix timestamp. + * @param bool $childRevs Whether to include child revisions. + * @return array Each row with keys 'id', 'timestamp', 'minor', 'length', + * 'length_change', 'reverted', 'user_id', 'username', 'comment', 'parent_comment' + */ + private function queryTopEditsPage( + Page $page, + User $user, + int|false $start = false, + int|false $end = false, + bool $childRevs = false + ): array { + $project = $page->getProject(); + $revDateConditions = $this->getDateConditions( $start, $end, false, 'revs.' ); + $revTable = $project->getTableName( 'revision' ); + $commentTable = $project->getTableName( 'comment' ); + $tagTable = $project->getTableName( 'change_tag' ); + $tagDefTable = $project->getTableName( 'change_tag_def' ); + // sha1 temporarily disabled, see T407814/T389026 + if ( $childRevs ) { + $childSelect = ", ( CASE WHEN /* childrevs.rev_sha1 = parentrevs.rev_sha1 OR */ ( @@ -398,33 +395,33 @@ private function queryTopEditsPage( END ) AS `reverted`, childcomments.comment_text AS `parent_comment`"; - $childJoin = "LEFT JOIN $revTable AS childrevs ON (revs.rev_id = childrevs.rev_parent_id) + $childJoin = "LEFT JOIN $revTable AS childrevs ON (revs.rev_id = childrevs.rev_parent_id) LEFT OUTER JOIN $commentTable AS childcomments ON (childrevs.rev_comment_id = childcomments.comment_id)"; - $childWhere = 'AND childrevs.rev_page = :pageid'; - $childLimit = ''; - } else { - $childSelect = ', "" AS parent_comment, 0 AS reverted'; - $childJoin = ''; - $childWhere = ''; - $childLimit = 'LIMIT 1'; - } - - $userId = $user->getId($page->getProject()); - $username = $this->getProjectsConnection($project)->quote($user->getUsername()); - - // IP range handling. - $ipcJoin = ''; - $whereClause = 'revs.rev_actor = :actorId'; - $params = ['pageid' => $page->getId()]; - if ($user->isIpRange()) { - $ipcTable = $project->getTableName('ip_changes'); - $ipcJoin = "JOIN $ipcTable ON revs.rev_id = ipc_rev_id"; - $whereClause = 'ipc_hex BETWEEN :startIp AND :endIp'; - [$params['startIp'], $params['endIp']] = IPUtils::parseRange($user->getUsername()); - } - - $sql = "SELECT * FROM ( + $childWhere = 'AND childrevs.rev_page = :pageid'; + $childLimit = ''; + } else { + $childSelect = ', "" AS parent_comment, 0 AS reverted'; + $childJoin = ''; + $childWhere = ''; + $childLimit = 'LIMIT 1'; + } + + $userId = $user->getId( $page->getProject() ); + $username = $this->getProjectsConnection( $project )->quote( $user->getUsername() ); + + // IP range handling. + $ipcJoin = ''; + $whereClause = 'revs.rev_actor = :actorId'; + $params = [ 'pageid' => $page->getId() ]; + if ( $user->isIpRange() ) { + $ipcTable = $project->getTableName( 'ip_changes' ); + $ipcJoin = "JOIN $ipcTable ON revs.rev_id = ipc_rev_id"; + $whereClause = 'ipc_hex BETWEEN :startIp AND :endIp'; + [ $params['startIp'], $params['endIp'] ] = IPUtils::parseRange( $user->getUsername() ); + } + + $sql = "SELECT * FROM ( SELECT revs.rev_id AS id, revs.rev_timestamp AS timestamp, @@ -448,7 +445,7 @@ private function queryTopEditsPage( ORDER BY timestamp DESC $childLimit"; - $resultQuery = $this->executeQuery($sql, $project, $user, null, $params); - return $resultQuery->fetchAllAssociative(); - } + $resultQuery = $this->executeQuery( $sql, $project, $user, null, $params ); + return $resultQuery->fetchAllAssociative(); + } } diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index e2fbe3036..97d8e1c77 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -1,6 +1,6 @@ getCacheKey(func_get_args(), 'user_id_reg'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $userTable = $this->getTableName($databaseName, 'user'); - $sql = "SELECT user_id AS userId, user_registration AS regDate +class UserRepository extends Repository { + public function __construct( + protected ManagerRegistry $managerRegistry, + protected CacheItemPoolInterface $cache, + protected Client $guzzle, + protected LoggerInterface $logger, + protected ParameterBagInterface $parameterBag, + protected bool $isWMF, + protected int $queryTimeout, + protected ProjectRepository $projectRepo, + protected ?RequestStack $requestStack + ) { + parent::__construct( + $managerRegistry, + $cache, + $guzzle, + $logger, + $parameterBag, + $isWMF, + $queryTimeout, + $requestStack + ); + } + + /** + * Get the user's ID and registration date. + * @param string $databaseName The database to query. + * @param string $username The username to find. + * @return array|false With keys 'userId' and regDate'. false if user not found. + */ + public function getIdAndRegistration( string $databaseName, string $username ) { + $cacheKey = $this->getCacheKey( func_get_args(), 'user_id_reg' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $userTable = $this->getTableName( $databaseName, 'user' ); + $sql = "SELECT user_id AS userId, user_registration AS regDate FROM $userTable WHERE user_name = :username LIMIT 1"; - $resultQuery = $this->executeProjectsQuery($databaseName, $sql, ['username' => $username]); - - // Cache and return. - return $this->setCache($cacheKey, $resultQuery->fetchAssociative()); - } - - /** - * Get the user's actor ID. - * @param string $databaseName - * @param string $username - * @return ?int - */ - public function getActorId(string $databaseName, string $username): ?int - { - if (IPUtils::isValidRange($username)) { - return null; - } - - $cacheKey = $this->getCacheKey(func_get_args(), 'user_actor_id'); - if ($this->cache->hasItem($cacheKey)) { - return (int)$this->cache->getItem($cacheKey)->get(); - } - - $actorTable = $this->getTableName($databaseName, 'actor'); - - $sql = "SELECT actor_id + $resultQuery = $this->executeProjectsQuery( $databaseName, $sql, [ 'username' => $username ] ); + + // Cache and return. + return $this->setCache( $cacheKey, $resultQuery->fetchAssociative() ); + } + + /** + * Get the user's actor ID. + * @param string $databaseName + * @param string $username + * @return ?int + */ + public function getActorId( string $databaseName, string $username ): ?int { + if ( IPUtils::isValidRange( $username ) ) { + return null; + } + + $cacheKey = $this->getCacheKey( func_get_args(), 'user_actor_id' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return (int)$this->cache->getItem( $cacheKey )->get(); + } + + $actorTable = $this->getTableName( $databaseName, 'actor' ); + + $sql = "SELECT actor_id FROM $actorTable WHERE actor_name = :username LIMIT 1"; - $resultQuery = $this->executeProjectsQuery($databaseName, $sql, ['username' => $username]); - - // Cache and return. - return (int)$this->setCache($cacheKey, $resultQuery->fetchOne()); - } - - /** - * Get the user's (system) edit count. - * @param string $databaseName The database to query. - * @param string $username The username to find. - * @return int As returned by the database. - */ - public function getEditCount(string $databaseName, string $username): int - { - $cacheKey = $this->getCacheKey(func_get_args(), 'user_edit_count'); - if ($this->cache->hasItem($cacheKey)) { - return (int)$this->cache->getItem($cacheKey)->get(); - } - - $userTable = $this->getTableName($databaseName, 'user'); - $sql = "SELECT user_editcount FROM $userTable WHERE user_name = :username LIMIT 1"; - $resultQuery = $this->executeProjectsQuery($databaseName, $sql, ['username' => $username]); - - return (int)$this->setCache($cacheKey, $resultQuery->fetchOne()); - } - - /** - * Get the number of active blocks on the user. - * @param Project $project - * @param User $user - * @return int Number of active blocks. - */ - public function countActiveBlocks(Project $project, User $user): int - { - $cacheKey = $this->getCacheKey(func_get_args(), 'user_active_blocks'); - if ($this->cache->hasItem($cacheKey)) { - return (int)$this->cache->getItem($cacheKey)->get(); - } - if ($user->isIP()) { - $blockTargetTable = $project->getTableName('block_target_ipindex'); - $userField = 'bt_address'; - if ($user->isIpRange()) { - $userId = IPUtils::sanitizeRange($user->getUsername()); - } else { - $userId = IPUtils::sanitizeIp($user->getUsername()); - } - } else { - $blockTargetTable = $project->getTableName('block_target'); - $userField = 'bt_user'; - $userId = $user->getId($project); - } - $sql = "SELECT bt_count + $resultQuery = $this->executeProjectsQuery( $databaseName, $sql, [ 'username' => $username ] ); + + // Cache and return. + return (int)$this->setCache( $cacheKey, $resultQuery->fetchOne() ); + } + + /** + * Get the user's (system) edit count. + * @param string $databaseName The database to query. + * @param string $username The username to find. + * @return int As returned by the database. + */ + public function getEditCount( string $databaseName, string $username ): int { + $cacheKey = $this->getCacheKey( func_get_args(), 'user_edit_count' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return (int)$this->cache->getItem( $cacheKey )->get(); + } + + $userTable = $this->getTableName( $databaseName, 'user' ); + $sql = "SELECT user_editcount FROM $userTable WHERE user_name = :username LIMIT 1"; + $resultQuery = $this->executeProjectsQuery( $databaseName, $sql, [ 'username' => $username ] ); + + return (int)$this->setCache( $cacheKey, $resultQuery->fetchOne() ); + } + + /** + * Get the number of active blocks on the user. + * @param Project $project + * @param User $user + * @return int Number of active blocks. + */ + public function countActiveBlocks( Project $project, User $user ): int { + $cacheKey = $this->getCacheKey( func_get_args(), 'user_active_blocks' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return (int)$this->cache->getItem( $cacheKey )->get(); + } + if ( $user->isIP() ) { + $blockTargetTable = $project->getTableName( 'block_target_ipindex' ); + $userField = 'bt_address'; + if ( $user->isIpRange() ) { + $userId = IPUtils::sanitizeRange( $user->getUsername() ); + } else { + $userId = IPUtils::sanitizeIp( $user->getUsername() ); + } + } else { + $blockTargetTable = $project->getTableName( 'block_target' ); + $userField = 'bt_user'; + $userId = $user->getId( $project ); + } + $sql = "SELECT bt_count FROM $blockTargetTable WHERE $userField = :user"; - $resultQuery = $this->executeProjectsQuery($project->getDatabaseName(), $sql, ['user' => $userId]); - return $this->setCache($cacheKey, (int)$resultQuery->fetchOne()); - } - - /** - * Get edit count within given timeframe and namespace. - * @param Project $project - * @param User $user - * @param int|string $namespace Namespace ID or 'all' for all namespaces - * @param int|false $start Start date as Unix timestamp. - * @param int|false $end End date as Unix timestamp. - * @return int - */ - public function countEdits( - Project $project, - User $user, - int|string $namespace = 'all', - int|false $start = false, - int|false $end = false - ): int { - $cacheKey = $this->getCacheKey(func_get_args(), 'user_editcount'); - if ($this->cache->hasItem($cacheKey)) { - return (int)$this->cache->getItem($cacheKey)->get(); - } - - $revDateConditions = $this->getDateConditions($start, $end); - [$pageJoin, $condNamespace] = $this->getPageAndNamespaceSql($project, $namespace); - $revisionTable = $project->getTableName('revision'); - $params = []; - - if ($user->isIP()) { - [$params['startIp'], $params['endIp']] = IPUtils::parseRange($user->getUsername()); - $ipcTable = $project->getTableName('ip_changes'); - $sql = "SELECT COUNT(ipc_rev_id) + $resultQuery = $this->executeProjectsQuery( $project->getDatabaseName(), $sql, [ 'user' => $userId ] ); + return $this->setCache( $cacheKey, (int)$resultQuery->fetchOne() ); + } + + /** + * Get edit count within given timeframe and namespace. + * @param Project $project + * @param User $user + * @param int|string $namespace Namespace ID or 'all' for all namespaces + * @param int|false $start Start date as Unix timestamp. + * @param int|false $end End date as Unix timestamp. + * @return int + */ + public function countEdits( + Project $project, + User $user, + int|string $namespace = 'all', + int|false $start = false, + int|false $end = false + ): int { + $cacheKey = $this->getCacheKey( func_get_args(), 'user_editcount' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return (int)$this->cache->getItem( $cacheKey )->get(); + } + + $revDateConditions = $this->getDateConditions( $start, $end ); + [ $pageJoin, $condNamespace ] = $this->getPageAndNamespaceSql( $project, $namespace ); + $revisionTable = $project->getTableName( 'revision' ); + $params = []; + + if ( $user->isIP() ) { + [ $params['startIp'], $params['endIp'] ] = IPUtils::parseRange( $user->getUsername() ); + $ipcTable = $project->getTableName( 'ip_changes' ); + $sql = "SELECT COUNT(ipc_rev_id) FROM $ipcTable JOIN $revisionTable ON ipc_rev_id = rev_id $pageJoin WHERE ipc_hex BETWEEN :startIp AND :endIp $condNamespace $revDateConditions"; - } else { - $sql = "SELECT COUNT(rev_id) + } else { + $sql = "SELECT COUNT(rev_id) FROM $revisionTable $pageJoin WHERE rev_actor = :actorId $condNamespace $revDateConditions"; - } - - $resultQuery = $this->executeQuery($sql, $project, $user, $namespace, $params); - $result = (int)$resultQuery->fetchOne(); - - // Cache and return. - return $this->setCache($cacheKey, $result); - } - - /** - * Get information about the currently-logged in user. - * @return array|object|null null if not logged in. - */ - public function getXtoolsUserInfo(): object|array|null - { - return $this->requestStack->getSession()->get('logged_in_user'); - } - - /** - * Number of edits which if exceeded, will require the user to log in. - * @return int - */ - public function numEditsRequiringLogin(): int - { - return (int)$this->parameterBag->get('app.num_edits_requiring_login'); - } - - /** - * Maximum number of edits to process, based on configuration. - * @return int - */ - public function maxEdits(): int - { - return (int)$this->parameterBag->get('app.max_user_edits'); - } - - /** - * Get SQL clauses for joining on `page` and restricting to a namespace. - * @param Project $project - * @param int|string $namespace Namespace ID or 'all' for all namespaces. - * @return array [page join clause, page namespace clause] - */ - protected function getPageAndNamespaceSql(Project $project, int|string $namespace): array - { - if ('all' === $namespace) { - return [null, null]; - } - - $pageTable = $project->getTableName('page'); - $pageJoin = "LEFT JOIN $pageTable ON rev_page = page_id"; - $condNamespace = 'AND page_namespace = :namespace'; - - return [$pageJoin, $condNamespace]; - } - - /** - * Get SQL fragments for filtering by user. - * Used in self::getPagesCreatedInnerSql(). - * @param bool $dateFiltering Whether the query you're working with has date filtering. - * If false, a clause to check timestamp > 1 is added to force use of the timestamp index. - * @return string[] Keys 'whereRev' and 'whereArc'. - */ - public function getUserConditions(bool $dateFiltering = false): array - { - return [ - 'whereRev' => " rev_actor = :actorId ".($dateFiltering ? '' : "AND rev_timestamp > 1 "), - 'whereArc' => " ar_actor = :actorId ".($dateFiltering ? '' : "AND ar_timestamp > 1 "), - ]; - } - - /** - * Prepare the given SQL, bind the given parameters, and execute the Doctrine Statement. - * @param string $sql - * @param Project $project - * @param User $user - * @param int|string|null $namespace Namespace ID, or 'all'/null for all namespaces. - * @param array $extraParams Will get merged in the params array used for binding values. - * @return Result - */ - protected function executeQuery( - string $sql, - Project $project, - User $user, - int|string|null $namespace = 'all', - array $extraParams = [] - ): Result { - $params = ['actorId' => $user->getActorId($project)]; - - if ('all' !== $namespace) { - $params['namespace'] = $namespace; - } - - return $this->executeProjectsQuery($project, $sql, array_merge($params, $extraParams)); - } - - /** - * Check if a user exists globally. - * @param User $user - * @return bool - */ - public function existsGlobally(User $user): bool - { - if ($user->isIP()) { - return true; - } - - return (bool)$this->executeProjectsQuery( - 'centralauth', - 'SELECT 1 FROM centralauth_p.globaluser WHERE gu_name = :username', - ['username' => $user->getUsername()] - )->fetchFirstColumn(); - } - - /** - * Get a user's local user rights on the given Project. - * @param Project $project - * @param User $user - * @return string[] - */ - public function getUserRights(Project $project, User $user): array - { - if ($user->isIP()) { - return []; - } elseif ($user->isTemp($project)) { - return ['temp']; - } - - $cacheKey = $this->getCacheKey(func_get_args(), 'user_rights'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $userGroupsTable = $project->getTableName('user_groups'); - $userTable = $project->getTableName('user'); - - $sql = "SELECT ug_group + } + + $resultQuery = $this->executeQuery( $sql, $project, $user, $namespace, $params ); + $result = (int)$resultQuery->fetchOne(); + + // Cache and return. + return $this->setCache( $cacheKey, $result ); + } + + /** + * Get information about the currently-logged in user. + * @return array|stdClass|null null if not logged in. + */ + public function getXtoolsUserInfo(): array|stdClass|null { + return $this->requestStack->getSession()->get( 'logged_in_user' ); + } + + /** + * Number of edits which if exceeded, will require the user to log in. + * @return int + */ + public function numEditsRequiringLogin(): int { + return (int)$this->parameterBag->get( 'app.num_edits_requiring_login' ); + } + + /** + * Maximum number of edits to process, based on configuration. + * @return int + */ + public function maxEdits(): int { + return (int)$this->parameterBag->get( 'app.max_user_edits' ); + } + + /** + * Get SQL clauses for joining on `page` and restricting to a namespace. + * @param Project $project + * @param int|string $namespace Namespace ID or 'all' for all namespaces. + * @return array [page join clause, page namespace clause] + */ + protected function getPageAndNamespaceSql( Project $project, int|string $namespace ): array { + if ( $namespace === 'all' ) { + return [ null, null ]; + } + + $pageTable = $project->getTableName( 'page' ); + $pageJoin = "LEFT JOIN $pageTable ON rev_page = page_id"; + $condNamespace = 'AND page_namespace = :namespace'; + + return [ $pageJoin, $condNamespace ]; + } + + /** + * Get SQL fragments for filtering by user. + * Used in self::getPagesCreatedInnerSql(). + * @param bool $dateFiltering Whether the query you're working with has date filtering. + * If false, a clause to check timestamp > 1 is added to force use of the timestamp index. + * @return string[] Keys 'whereRev' and 'whereArc'. + */ + public function getUserConditions( bool $dateFiltering = false ): array { + return [ + 'whereRev' => " rev_actor = :actorId " . ( $dateFiltering ? '' : "AND rev_timestamp > 1 " ), + 'whereArc' => " ar_actor = :actorId " . ( $dateFiltering ? '' : "AND ar_timestamp > 1 " ), + ]; + } + + /** + * Prepare the given SQL, bind the given parameters, and execute the Doctrine Statement. + * @param string $sql + * @param Project $project + * @param User $user + * @param int|string|null $namespace Namespace ID, or 'all'/null for all namespaces. + * @param array $extraParams Will get merged in the params array used for binding values. + * @return Result + */ + protected function executeQuery( + string $sql, + Project $project, + User $user, + int|string|null $namespace = 'all', + array $extraParams = [] + ): Result { + $params = [ 'actorId' => $user->getActorId( $project ) ]; + + if ( $namespace !== 'all' ) { + $params['namespace'] = $namespace; + } + + return $this->executeProjectsQuery( $project, $sql, array_merge( $params, $extraParams ) ); + } + + /** + * Check if a user exists globally. + * @param User $user + * @return bool + */ + public function existsGlobally( User $user ): bool { + if ( $user->isIP() ) { + return true; + } + + return (bool)$this->executeProjectsQuery( + 'centralauth', + 'SELECT 1 FROM centralauth_p.globaluser WHERE gu_name = :username', + [ 'username' => $user->getUsername() ] + )->fetchFirstColumn(); + } + + /** + * Get a user's local user rights on the given Project. + * @param Project $project + * @param User $user + * @return string[] + */ + public function getUserRights( Project $project, User $user ): array { + if ( $user->isIP() ) { + return []; + } elseif ( $user->isTemp( $project ) ) { + return [ 'temp' ]; + } + + $cacheKey = $this->getCacheKey( func_get_args(), 'user_rights' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $userGroupsTable = $project->getTableName( 'user_groups' ); + $userTable = $project->getTableName( 'user' ); + + $sql = "SELECT ug_group FROM $userGroupsTable JOIN $userTable ON user_id = ug_user WHERE user_name = :username AND (ug_expiry IS NULL OR ug_expiry > CURRENT_TIMESTAMP)"; - $ret = $this->executeProjectsQuery($project, $sql, [ - 'username' => $user->getUsername(), - ])->fetchFirstColumn(); - - // Cache and return. - return $this->setCache($cacheKey, $ret); - } - - /** - * Get a user's global group membership (starting at XTools' default project if none is - * provided). This requires the CentralAuth extension to be installed. - * @link https://www.mediawiki.org/wiki/Extension:CentralAuth - * @param string $username The username. - * @param Project|null $project The project to query. - * @return string[] - */ - public function getGlobalUserRights(string $username, ?Project $project = null): array - { - $cacheKey = $this->getCacheKey(func_get_args(), 'user_global_groups'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - // Get the default project if not provided. - if (!$project instanceof Project) { - $project = $this->projectRepo->getDefaultProject(); - } - - $params = [ - 'meta' => 'globaluserinfo', - 'guiuser' => $username, - 'guiprop' => 'groups', - ]; - - $res = $this->executeApiRequest($project, $params); - $result = []; - if (isset($res['batchcomplete']) && isset($res['query']['globaluserinfo']['groups'])) { - $result = $res['query']['globaluserinfo']['groups']; - } - - // Cache and return. - return $this->setCache($cacheKey, $result); - } + $ret = $this->executeProjectsQuery( $project, $sql, [ + 'username' => $user->getUsername(), + ] )->fetchFirstColumn(); + + // Cache and return. + return $this->setCache( $cacheKey, $ret ); + } + + /** + * Get a user's global group membership (starting at XTools' default project if none is + * provided). This requires the CentralAuth extension to be installed. + * @link https://www.mediawiki.org/wiki/Extension:CentralAuth + * @param string $username The username. + * @param Project|null $project The project to query. + * @return string[] + */ + public function getGlobalUserRights( string $username, ?Project $project = null ): array { + $cacheKey = $this->getCacheKey( func_get_args(), 'user_global_groups' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + // Get the default project if not provided. + if ( !$project instanceof Project ) { + $project = $this->projectRepo->getDefaultProject(); + } + + $params = [ + 'meta' => 'globaluserinfo', + 'guiuser' => $username, + 'guiprop' => 'groups', + ]; + + $res = $this->executeApiRequest( $project, $params ); + $result = []; + if ( isset( $res['batchcomplete'] ) && isset( $res['query']['globaluserinfo']['groups'] ) ) { + $result = $res['query']['globaluserinfo']['groups']; + } + + // Cache and return. + return $this->setCache( $cacheKey, $result ); + } } diff --git a/src/Repository/UserRightsRepository.php b/src/Repository/UserRightsRepository.php index f0527764d..9574ed9f4 100644 --- a/src/Repository/UserRightsRepository.php +++ b/src/Repository/UserRightsRepository.php @@ -1,6 +1,6 @@ queryRightsChanges($project, $user); - - if ($this->isWMF) { - $changes = array_merge( - $changes, - $this->queryRightsChanges($project, $user, 'meta') - ); - } - - return $changes; - } - - /** - * Get global user rights changes of the given user. - * @param Project $project Global rights are always on Meta, so this - * Project instance is re-used if it is already Meta, otherwise - * a new Project instance is created. - * @param User $user - * @return array - */ - public function getGlobalRightsChanges(Project $project, User $user): array - { - return $this->queryRightsChanges($project, $user, 'global'); - } - - /** - * User rights changes for given project, optionally fetched from Meta. - * @param Project $project Global rights and Meta-changed rights will - * automatically use the Meta Project. This Project instance is re-used - * if it is already Meta, otherwise a new Project instance is created. - * @param User $user - * @param string $type One of 'local' - query the local rights log, - * 'meta' - query for username@dbname for local rights changes made on Meta, or - * 'global' - query for global rights changes. - * @return array - */ - private function queryRightsChanges(Project $project, User $user, string $type = 'local'): array - { - $dbName = $project->getDatabaseName(); - - // Global rights and Meta-changed rights should use a Meta Project. - if ('local' !== $type) { - $dbName = 'metawiki'; - } - - $loggingTable = $this->getTableName($dbName, 'logging', 'logindex'); - $commentTable = $this->getTableName($dbName, 'comment', 'logging'); - $actorTable = $this->getTableName($dbName, 'actor', 'logging'); - $username = str_replace(' ', '_', $user->getUsername()); - - if ('meta' === $type) { - // Reference the original Project. - $username .= '@'.$project->getDatabaseName(); - } - - // Way back when it was possible to have usernames with lowercase characters. - // Some log entries aren't caught unless we look for both variations. - $usernameLower = lcfirst($username); - - $logType = 'global' == $type ? 'gblrights' : 'rights'; - - $sql = "SELECT log_id, log_timestamp, log_params, log_action, actor_name AS `performer`, +class UserRightsRepository extends Repository { + /** + * Get user rights changes of the given user, including those made on Meta. + * @param Project $project + * @param User $user + * @return array + */ + public function getRightsChanges( Project $project, User $user ): array { + $changes = $this->queryRightsChanges( $project, $user ); + + if ( $this->isWMF ) { + $changes = array_merge( + $changes, + $this->queryRightsChanges( $project, $user, 'meta' ) + ); + } + + return $changes; + } + + /** + * Get global user rights changes of the given user. + * @param Project $project Global rights are always on Meta, so this + * Project instance is re-used if it is already Meta, otherwise + * a new Project instance is created. + * @param User $user + * @return array + */ + public function getGlobalRightsChanges( Project $project, User $user ): array { + return $this->queryRightsChanges( $project, $user, 'global' ); + } + + /** + * User rights changes for given project, optionally fetched from Meta. + * @param Project $project Global rights and Meta-changed rights will + * automatically use the Meta Project. This Project instance is re-used + * if it is already Meta, otherwise a new Project instance is created. + * @param User $user + * @param string $type One of 'local' - query the local rights log, + * 'meta' - query for username@dbname for local rights changes made on Meta, or + * 'global' - query for global rights changes. + * @return array + */ + private function queryRightsChanges( Project $project, User $user, string $type = 'local' ): array { + $dbName = $project->getDatabaseName(); + + // Global rights and Meta-changed rights should use a Meta Project. + if ( $type !== 'local' ) { + $dbName = 'metawiki'; + } + + $loggingTable = $this->getTableName( $dbName, 'logging', 'logindex' ); + $commentTable = $this->getTableName( $dbName, 'comment', 'logging' ); + $actorTable = $this->getTableName( $dbName, 'actor', 'logging' ); + $username = str_replace( ' ', '_', $user->getUsername() ); + + if ( $type === 'meta' ) { + // Reference the original Project. + $username .= '@' . $project->getDatabaseName(); + } + + // Way back when it was possible to have usernames with lowercase characters. + // Some log entries aren't caught unless we look for both variations. + $usernameLower = lcfirst( $username ); + + $logType = $type === 'global' ? 'gblrights' : 'rights'; + + $sql = "SELECT log_id, log_timestamp, log_params, log_action, actor_name AS `performer`, comment_text AS `log_comment`, log_deleted, '$type' AS type FROM $loggingTable LEFT OUTER JOIN $actorTable ON log_actor = actor_id @@ -92,199 +88,196 @@ private function queryRightsChanges(Project $project, User $user, string $type = AND log_namespace = 2 AND log_title IN (:username, :username2)"; - return $this->executeProjectsQuery($dbName, $sql, [ - 'username' => $username, - 'username2' => $usernameLower, - ])->fetchAllAssociative(); - } - - /** - * Get the localized names for all user groups on given Project (and global), - * fetched from on-wiki system messages. - * @param Project $project - * @param string $lang Language code to pass in. - * @return string[] Localized names keyed by database value. - */ - public function getRightsNames(Project $project, string $lang): array - { - $cacheKey = $this->getCacheKey(func_get_args(), 'project_rights_names'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $rightsPaths = array_map(function ($right) { - return "Group-$right-member"; - }, $this->getRawRightsNames($project)); - - $rightsNames = []; - - for ($i = 0; $i < count($rightsPaths); $i += 50) { - $rightsSlice = array_slice($rightsPaths, $i, 50); - $params = [ - 'action' => 'query', - 'meta' => 'allmessages', - 'ammessages' => implode('|', $rightsSlice), - 'amlang' => $lang, - 'amenableparser' => 1, - 'formatversion' => 2, - ]; - $result = $this->executeApiRequest($project, $params)['query']['allmessages']; - foreach ($result as $msg) { - $normalized = preg_replace('/^group-|-member$/', '', $msg['normalizedname']); - $rightsNames[$normalized] = $msg['content'] ?? $normalized; - } - } - - // Cache for one day and return. - return $this->setCache($cacheKey, $rightsNames, 'P1D'); - } - - /** - * Get the names of all the possible local and global user groups. - * @param Project $project - * @return string[] - */ - private function getRawRightsNames(Project $project): array - { - $ugTable = $project->getTableName('user_groups'); - $ufgTable = $project->getTableName('user_former_groups'); - $sql = "SELECT DISTINCT(ug_group) + return $this->executeProjectsQuery( $dbName, $sql, [ + 'username' => $username, + 'username2' => $usernameLower, + ] )->fetchAllAssociative(); + } + + /** + * Get the localized names for all user groups on given Project (and global), + * fetched from on-wiki system messages. + * @param Project $project + * @param string $lang Language code to pass in. + * @return string[] Localized names keyed by database value. + */ + public function getRightsNames( Project $project, string $lang ): array { + $cacheKey = $this->getCacheKey( func_get_args(), 'project_rights_names' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $rightsPaths = array_map( static function ( $right ) { + return "Group-$right-member"; + }, $this->getRawRightsNames( $project ) ); + + $rightsNames = []; + + for ( $i = 0; $i < count( $rightsPaths ); $i += 50 ) { + $rightsSlice = array_slice( $rightsPaths, $i, 50 ); + $params = [ + 'action' => 'query', + 'meta' => 'allmessages', + 'ammessages' => implode( '|', $rightsSlice ), + 'amlang' => $lang, + 'amenableparser' => 1, + 'formatversion' => 2, + ]; + $result = $this->executeApiRequest( $project, $params )['query']['allmessages']; + foreach ( $result as $msg ) { + $normalized = preg_replace( '/^group-|-member$/', '', $msg['normalizedname'] ); + $rightsNames[$normalized] = $msg['content'] ?? $normalized; + } + } + + // Cache for one day and return. + return $this->setCache( $cacheKey, $rightsNames, 'P1D' ); + } + + /** + * Get the names of all the possible local and global user groups. + * @param Project $project + * @return string[] + */ + private function getRawRightsNames( Project $project ): array { + $ugTable = $project->getTableName( 'user_groups' ); + $ufgTable = $project->getTableName( 'user_former_groups' ); + $sql = "SELECT DISTINCT(ug_group) FROM $ugTable UNION SELECT DISTINCT(ufg_group) FROM $ufgTable"; - $groups = $this->executeProjectsQuery($project, $sql)->fetchFirstColumn(); - - if ($this->isWMF) { - $sql = "SELECT DISTINCT(gug_group) FROM centralauth_p.global_user_groups"; - $groups = array_merge( - $groups, - $this->executeProjectsQuery('centralauth', $sql)->fetchFirstColumn() - ); - } - // Some installations have the special 'autoconfirmed' and 'temp' user groups. - $groups = array_merge($groups, ['autoconfirmed', 'temp']); - - return array_unique($groups); - } - - /** - * Get the threshold values to become autoconfirmed for the given Project. - * Yes, eval is bad, but here we're validating only mathematical expressions are ran. - * @param Project $project - * @return array|null With keys 'wgAutoConfirmAge' and 'wgAutoConfirmCount'. Null if not found/not applicable. - */ - public function getAutoconfirmedAgeAndCount(Project $project): ?array - { - if (!$this->isWMF) { - return null; - } - - // Set up cache. - $cacheKey = $this->getCacheKey(func_get_args(), 'ec_rightschanges_autoconfirmed'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $url = 'https://noc.wikimedia.org/conf/InitialiseSettings.php.txt'; - $contents = $this->guzzle->request('GET', $url) - ->getBody() - ->getContents(); - - $dbname = $project->getDatabaseName(); - if ('wikidatawiki' === $dbname) { - // Edge-case: 'wikidata' is an alias. - $dbname = 'wikidatawiki|wikidata'; - } - $dbNameRegex = "/'$dbname'\s*=>\s*([\d*\s]+)/s"; - $defaultRegex = "/'default'\s*=>\s*([\d*\s]+)/s"; - $out = []; - - foreach (['wgAutoConfirmAge', 'wgAutoConfirmCount'] as $type) { - // Extract the text of the file that contains the rules we're looking for. - $typeRegex = "/\'$type.*?\]/s"; - $matches = []; - if (1 === preg_match($typeRegex, $contents, $matches)) { - $group = $matches[0]; - - // Find the autoconfirmed expression for the $type and $dbname. - $matches = []; - if (1 === preg_match($dbNameRegex, $group, $matches)) { - $out[$type] = (int)eval('return('.$matches[1].');'); - continue; - } - - // Find the autoconfirmed expression for the 'default' and $dbname. - $matches = []; - if (1 === preg_match($defaultRegex, $group, $matches)) { - $out[$type] = (int)eval('return('.$matches[1].');'); - continue; - } - } else { - return null; - } - } - - // Cache for one day and return. - return $this->setCache($cacheKey, $out, 'P1D'); - } - - /** - * Get the timestamp of the nth edit made by the given user. - * @param Project $project - * @param User $user - * @param string $offset Date to start at, in YYYYMMDDHHSS format. - * @param int $edits Offset of rows to look for (edit threshold for autoconfirmed). - * @return string|false Timestamp in YYYYMMDDHHSS format. False if not found. - */ - public function getNthEditTimestamp(Project $project, User $user, string $offset, int $edits) - { - $cacheKey = $this->getCacheKey(func_get_args(), 'ec_rightschanges_nthtimestamp'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $revisionTable = $project->getTableName('revision'); - $sql = "SELECT rev_timestamp + $groups = $this->executeProjectsQuery( $project, $sql )->fetchFirstColumn(); + + if ( $this->isWMF ) { + $sql = "SELECT DISTINCT(gug_group) FROM centralauth_p.global_user_groups"; + $groups = array_merge( + $groups, + $this->executeProjectsQuery( 'centralauth', $sql )->fetchFirstColumn() + ); + } + // Some installations have the special 'autoconfirmed' and 'temp' user groups. + $groups = array_merge( $groups, [ 'autoconfirmed', 'temp' ] ); + + return array_unique( $groups ); + } + + /** + * Get the threshold values to become autoconfirmed for the given Project. + * Yes, eval is bad, but here we're validating only mathematical expressions are ran. + * @param Project $project + * @return array|null With keys 'wgAutoConfirmAge' and 'wgAutoConfirmCount'. Null if not found/not applicable. + */ + public function getAutoconfirmedAgeAndCount( Project $project ): ?array { + if ( !$this->isWMF ) { + return null; + } + + // Set up cache. + $cacheKey = $this->getCacheKey( func_get_args(), 'ec_rightschanges_autoconfirmed' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $url = 'https://noc.wikimedia.org/conf/InitialiseSettings.php.txt'; + $contents = $this->guzzle->request( 'GET', $url ) + ->getBody() + ->getContents(); + + $dbname = $project->getDatabaseName(); + if ( $dbname === 'wikidatawiki' ) { + // Edge-case: 'wikidata' is an alias. + $dbname = 'wikidatawiki|wikidata'; + } + $dbNameRegex = "/'$dbname'\s*=>\s*([\d*\s]+)/s"; + $defaultRegex = "/'default'\s*=>\s*([\d*\s]+)/s"; + $out = []; + + foreach ( [ 'wgAutoConfirmAge', 'wgAutoConfirmCount' ] as $type ) { + // Extract the text of the file that contains the rules we're looking for. + $typeRegex = "/\'$type.*?\]/s"; + $matches = []; + if ( preg_match( $typeRegex, $contents, $matches ) === 1 ) { + $group = $matches[0]; + + // Find the autoconfirmed expression for the $type and $dbname. + $matches = []; + if ( preg_match( $dbNameRegex, $group, $matches ) === 1 ) { + // phpcs:ignore MediaWiki.Usage.ForbiddenFunctions.eval + $out[$type] = (int)eval( 'return(' . $matches[1] . ');' ); + continue; + } + + // Find the autoconfirmed expression for the 'default' and $dbname. + $matches = []; + if ( preg_match( $defaultRegex, $group, $matches ) === 1 ) { + // phpcs:ignore MediaWiki.Usage.ForbiddenFunctions.eval + $out[$type] = (int)eval( 'return(' . $matches[1] . ');' ); + continue; + } + } else { + return null; + } + } + + // Cache for one day and return. + return $this->setCache( $cacheKey, $out, 'P1D' ); + } + + /** + * Get the timestamp of the nth edit made by the given user. + * @param Project $project + * @param User $user + * @param string $offset Date to start at, in YYYYMMDDHHSS format. + * @param int $edits Offset of rows to look for (edit threshold for autoconfirmed). + * @return string|false Timestamp in YYYYMMDDHHSS format. False if not found. + */ + public function getNthEditTimestamp( Project $project, User $user, string $offset, int $edits ) { + $cacheKey = $this->getCacheKey( func_get_args(), 'ec_rightschanges_nthtimestamp' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $revisionTable = $project->getTableName( 'revision' ); + $sql = "SELECT rev_timestamp FROM $revisionTable WHERE rev_actor = :actorId AND rev_timestamp >= $offset - LIMIT 1 OFFSET ".($edits - 1); - - $ret = $this->executeProjectsQuery($project, $sql, [ - 'actorId' => $user->getActorId($project), - ])->fetchOne(); - - // Cache and return. - return $this->setCache($cacheKey, $ret); - } - - /** - * Get the number of edits the user has made as of the given timestamp. - * @param Project $project - * @param User $user - * @param string $timestamp In YYYYMMDDHHSS format. - * @return int - */ - public function getNumEditsByTimestamp(Project $project, User $user, string $timestamp): int - { - $cacheKey = $this->getCacheKey(func_get_args(), 'ec_rightschanges_editstimestamp'); - if ($this->cache->hasItem($cacheKey)) { - return $this->cache->getItem($cacheKey)->get(); - } - - $revisionTable = $project->getTableName('revision'); - $sql = "SELECT COUNT(rev_id) + LIMIT 1 OFFSET " . ( $edits - 1 ); + + $ret = $this->executeProjectsQuery( $project, $sql, [ + 'actorId' => $user->getActorId( $project ), + ] )->fetchOne(); + + // Cache and return. + return $this->setCache( $cacheKey, $ret ); + } + + /** + * Get the number of edits the user has made as of the given timestamp. + * @param Project $project + * @param User $user + * @param string $timestamp In YYYYMMDDHHSS format. + * @return int + */ + public function getNumEditsByTimestamp( Project $project, User $user, string $timestamp ): int { + $cacheKey = $this->getCacheKey( func_get_args(), 'ec_rightschanges_editstimestamp' ); + if ( $this->cache->hasItem( $cacheKey ) ) { + return $this->cache->getItem( $cacheKey )->get(); + } + + $revisionTable = $project->getTableName( 'revision' ); + $sql = "SELECT COUNT(rev_id) FROM $revisionTable WHERE rev_actor = :actorId AND rev_timestamp <= $timestamp"; - $ret = (int)$this->executeProjectsQuery($project, $sql, [ - 'actorId' => $user->getActorId($project), - ])->fetchOne(); + $ret = (int)$this->executeProjectsQuery( $project, $sql, [ + 'actorId' => $user->getActorId( $project ), + ] )->fetchOne(); - // Cache and return. - return $this->setCache($cacheKey, $ret); - } + // Cache and return. + return $this->setCache( $cacheKey, $ret ); + } } diff --git a/src/Twig/AppExtension.php b/src/Twig/AppExtension.php index 93a5961dc..58c676308 100644 --- a/src/Twig/AppExtension.php +++ b/src/Twig/AppExtension.php @@ -1,6 +1,6 @@ ['html']]), - new TwigFunction('msgExists', [$this, 'msgExists'], ['is_safe' => ['html']]), - new TwigFunction('msg', [$this, 'msg'], ['is_safe' => ['html']]), - new TwigFunction('lang', [$this, 'getLang']), - new TwigFunction('langName', [$this, 'getLangName']), - new TwigFunction('fallbackLangs', [$this, 'getFallbackLangs']), - new TwigFunction('allLangs', [$this, 'getAllLangs']), - new TwigFunction('isRTL', [$this, 'isRTL']), - new TwigFunction('shortHash', [$this, 'gitShortHash']), - new TwigFunction('hash', [$this, 'gitHash']), - new TwigFunction('releaseDate', [$this, 'gitDate']), - new TwigFunction('enabled', [$this, 'toolEnabled']), - new TwigFunction('tools', [$this, 'tools']), - new TwigFunction('color', [$this, 'getColorList']), - new TwigFunction('chartColor', [$this, 'chartColor']), - new TwigFunction('isSingleWiki', [$this, 'isSingleWiki']), - new TwigFunction('getReplagThreshold', [$this, 'getReplagThreshold']), - new TwigFunction('isWMF', [$this, 'isWMF']), - new TwigFunction('replag', [$this, 'replag']), - new TwigFunction('quote', [$this, 'quote']), - new TwigFunction('bugReportURL', [$this, 'bugReportURL']), - new TwigFunction('logged_in_user', [$this, 'loggedInUser']), - new TwigFunction('isUserAnon', [$this, 'isUserAnon']), - new TwigFunction('nsName', [$this, 'nsName']), - new TwigFunction('titleWithNs', [$this, 'titleWithNs']), - new TwigFunction('formatDuration', [$this, 'formatDuration']), - new TwigFunction('numberFormat', [$this, 'numberFormat']), - new TwigFunction('buildQuery', [$this, 'buildQuery']), - new TwigFunction('login_url', [$this, 'loginUrl']), - ]; - } - - /** - * Get the duration of the current HTTP request in seconds. - * @return float - * Untestable since there is no request stack in the tests. - * @codeCoverageIgnore - */ - public function requestTime(): float - { - if (!isset($this->requestTime)) { - $this->requestTime = microtime(true) - $this->getRequest()->server->get('REQUEST_TIME_FLOAT'); - } - - return $this->requestTime; - } - - /** - * Get the formatted real memory usage. - * @return float - */ - public function requestMemory(): float - { - $mem = memory_get_usage(false); - $div = pow(1024, 2); - return $mem / $div; - } - - /** - * Get an i18n message. - * @param string $message - * @param string[] $vars - * @return string|null - */ - public function msg(string $message = '', array $vars = []): ?string - { - return $this->i18n->msg($message, $vars); - } - - /** - * See if a given i18n message exists. - * @param string|null $message The message. - * @param string[] $vars - * @return bool - */ - public function msgExists(?string $message, array $vars = []): bool - { - return $this->i18n->msgExists($message, $vars); - } - - /** - * Get an i18n message if it exists, otherwise just get the message key. - * @param string|null $message - * @param string[] $vars - * @return string - */ - public function msgIfExists(?string $message, array $vars = []): string - { - return $this->i18n->msgIfExists($message, $vars); - } - - /** - * Get the current language code. - * @return string - */ - public function getLang(): string - { - return $this->i18n->getLang(); - } - - /** - * Get the current language name (defaults to 'English'). - * @return string - */ - public function getLangName(): string - { - return $this->i18n->getLangName(); - } - - /** - * Get the fallback languages for the current language, so we know what to load with jQuery.i18n. - * @return string[] - */ - public function getFallbackLangs(): array - { - return $this->i18n->getFallbacks(); - } - - /** - * Get all available languages in the i18n directory - * @return string[] Associative array of langKey => langName - */ - public function getAllLangs(): array - { - return $this->i18n->getAllLangs(); - } - - /** - * Whether the current language is right-to-left. - * @param string|null $lang Optionally provide a specific lanuage code. - * @return bool - */ - public function isRTL(?string $lang = null): bool - { - return $this->i18n->isRTL($lang); - } - - /** - * Get the short hash of the currently checked-out Git commit. - * @return string - */ - public function gitShortHash(): string - { - return exec('git rev-parse --short HEAD'); - } - - /** - * Get the full hash of the currently checkout-out Git commit. - * @return string - */ - public function gitHash(): string - { - return exec('git rev-parse HEAD'); - } - - /** - * Get the date of the HEAD commit. - * @return string - */ - public function gitDate(): string - { - $date = new DateTime(exec('git show -s --format=%ci')); - return $this->dateFormat($date, 'yyyy-MM-dd'); - } - - /** - * Check whether a given tool is enabled. - * @param string $tool The short name of the tool. - * @return bool - */ - public function toolEnabled(string $tool = 'index'): bool - { - $param = false; - if ($this->parameterBag->has("enable.$tool")) { - $param = (bool)$this->parameterBag->get("enable.$tool"); - } - return $param; - } - - /** - * Get a list of the short names of all tools. - * @return string[] - */ - public function tools(): array - { - return $this->parameterBag->get('tools'); - } - - /** - * Get the color for a given namespace. - * @param int|null $nsId Namespace ID. - * @return string Hex value of the color. - * @codeCoverageIgnore - */ - public function getColorList(?int $nsId = null): string - { - $colors = [ - 0 => '#FF5555', - 1 => '#55FF55', - 2 => '#FFEE22', - 3 => '#FF55FF', - 4 => '#5555FF', - 5 => '#55FFFF', - 6 => '#C00000', - 7 => '#0000C0', - 8 => '#008800', - 9 => '#00C0C0', - 10 => '#FFAFAF', - 11 => '#808080', - 12 => '#00C000', - 13 => '#404040', - 14 => '#C0C000', - 15 => '#C000C0', - 90 => '#991100', - 91 => '#99FF00', - 92 => '#000000', - 93 => '#777777', - 100 => '#75A3D1', - 101 => '#A679D2', - 102 => '#660000', - 103 => '#000066', - 104 => '#FAFFAF', - 105 => '#408345', - 106 => '#5c8d20', - 107 => '#e1711d', - 108 => '#94ef2b', - 109 => '#756a4a', - 110 => '#6f1dab', - 111 => '#301e30', - 112 => '#5c9d96', - 113 => '#a8cd8c', - 114 => '#f2b3f1', - 115 => '#9b5828', - 116 => '#002288', - 117 => '#0000CC', - 118 => '#99FFFF', - 119 => '#99BBFF', - 120 => '#FF99FF', - 121 => '#CCFFFF', - 122 => '#CCFF00', - 123 => '#CCFFCC', - 200 => '#33FF00', - 201 => '#669900', - 202 => '#666666', - 203 => '#999999', - 204 => '#FFFFCC', - 205 => '#FF00CC', - 206 => '#FFFF00', - 207 => '#FFCC00', - 208 => '#FF0000', - 209 => '#FF6600', - 250 => '#6633CC', - 251 => '#6611AA', - 252 => '#66FF99', - 253 => '#66FF66', - 446 => '#06DCFB', - 447 => '#892EE4', - 460 => '#99FF66', - 461 => '#99CC66', - 470 => '#CCCC33', - 471 => '#CCFF33', - 480 => '#6699FF', - 481 => '#66FFFF', - 484 => '#07C8D6', - 485 => '#2AF1FF', - 486 => '#79CB21', - 487 => '#80D822', - 490 => '#995500', - 491 => '#998800', - 710 => '#FFCECE', - 711 => '#FFC8F2', - 828 => '#F7DE00', - 829 => '#BABA21', - 866 => '#FFFFFF', - 867 => '#FFCCFF', - 1198 => '#FF34B3', - 1199 => '#8B1C62', - 2300 => '#A900B8', - 2301 => '#C93ED6', - 2302 => '#8A09C1', - 2303 => '#974AB8', - 2600 => '#000000', - ]; - - // Default to grey. - return $colors[$nsId] ?? '#CCC'; - } - - /** - * Get color-blind friendly colors for use in charts - * @param int $num Index of color - * @return string RGBA color (so you can more easily adjust the opacity) - */ - public function chartColor(int $num): string - { - $colors = [ - 'rgba(171, 212, 235, 1)', - 'rgba(178, 223, 138, 1)', - 'rgba(251, 154, 153, 1)', - 'rgba(253, 191, 111, 1)', - 'rgba(202, 178, 214, 1)', - 'rgba(207, 182, 128, 1)', - 'rgba(141, 211, 199, 1)', - 'rgba(252, 205, 229, 1)', - 'rgba(255, 247, 161, 1)', - 'rgba(252, 146, 114, 1)', - 'rgba(217, 217, 217, 1)', - ]; - - return $colors[$num % count($colors)]; - } - - /** - * Whether XTools is running in single-project mode. - * @return bool - */ - public function isSingleWiki(): bool - { - return $this->singleWiki; - } - - /** - * Get the database replication-lag threshold. - * @return int - */ - public function getReplagThreshold(): int - { - return $this->replagThreshold; - } - - /** - * Whether XTools is running in WMF mode. - * @return bool - */ - public function isWMF(): bool - { - return $this->isWMF; - } - - /** - * The current replication lag. - * @return int - * @codeCoverageIgnore - */ - public function replag(): int - { - $projectIdent = $this->getRequest()->get('project', 'enwiki'); - $project = $this->projectRepo->getProject($projectIdent); - $dbName = $project->getDatabaseName(); - $sql = "SELECT lag FROM `heartbeat_p`.`heartbeat`"; - return (int)$project->getRepository()->executeProjectsQuery($project, $sql, [ - 'project' => $dbName, - ])->fetchOne(); - } - - /** - * Get a random quote for the footer - * @return string - */ - public function quote(): string - { - // Don't show if Quote is turned off, but always show for WMF - // (so quote is in footer but not in nav). - if (!$this->isWMF && !$this->parameterBag->get('enable.Quote')) { - return ''; - } - $quotes = $this->parameterBag->get('quotes'); - $id = array_rand($quotes); - return $quotes[$id]; - } - - /** - * Get the currently logged in user's details. - * @return string[]|object|null - */ - public function loggedInUser(): array|object|null - { - return $this->requestStack->getSession()->get('logged_in_user'); - } - - /** - * Get a URL to the login route with parameters to redirect back to the current page after logging in. - * @param Request $request - * @return string - */ - public function loginUrl(Request $request): string - { - return $this->urlGenerator->generate('login', [ - 'callback' => $this->urlGenerator->generate( - 'oauth_callback', - ['redirect' => $request->getUri()], - UrlGeneratorInterface::ABSOLUTE_URL - ), - ], UrlGeneratorInterface::ABSOLUTE_URL); - } - - /*********************************** FILTERS ***********************************/ - - /** - * Get all filters for this extension. - * @return TwigFilter[] - * @codeCoverageIgnore - */ - public function getFilters(): array - { - return [ - new TwigFilter('ucfirst', [$this, 'capitalizeFirst']), - new TwigFilter('percent_format', [$this, 'percentFormat']), - new TwigFilter('diff_format', [$this, 'diffFormat'], ['is_safe' => ['html']]), - new TwigFilter('num_format', [$this, 'numberFormat']), - new TwigFilter('size_format', [$this, 'sizeFormat']), - new TwigFilter('date_format', [$this, 'dateFormat']), - new TwigFilter('wikify', [$this, 'wikify']), - ]; - } - - /** - * Format a number based on language settings. - * @param int|float $number - * @param int $decimals Number of decimals to format to. - * @return string - */ - public function numberFormat(int|float $number, int $decimals = 0): string - { - return $this->i18n->numberFormat($number, $decimals); - } - - /** - * Format the given size (in bytes) as KB, MB, GB, or TB. - * Some code courtesy of Leo, CC BY-SA 4.0 - * @see https://stackoverflow.com/a/2510459/604142 - * @param int $bytes - * @param int $precision - * @return string - */ - public function sizeFormat(int $bytes, int $precision = 2): string - { - $base = log($bytes, 1024); - $suffixes = ['', 'kilobytes', 'megabytes', 'gigabytes', 'terabytes']; - - $index = floor($base); - - if (0 === (int)$index) { - return $this->numberFormat($bytes); - } - - $sizeMessage = $this->numberFormat( - pow(1024, $base - floor($base)), - $precision - ); - - return $this->i18n->msg('size-'.$suffixes[floor($base)], [$sizeMessage]); - } - - /** - * Localize the given date based on language settings. - * @param string|int|DateTime $datetime - * @param string $pattern Format according to this ICU date format. - * @see http://userguide.icu-project.org/formatparse/datetime - * @return string - */ - public function dateFormat(string|int|DateTime $datetime, string $pattern = 'yyyy-MM-dd HH:mm'): string - { - return $this->i18n->dateFormat($datetime, $pattern); - } - - /** - * Convert raw wikitext to HTML-formatted string. - * @param string $str - * @param Project $project - * @return string - */ - public function wikify(string $str, Project $project): string - { - return Edit::wikifyString($str, $project); - } - - /** - * Mysteriously missing Twig helper to capitalize only the first character. - * E.g. used for table headings for translated messages - * @param string $str The string - * @return string The string, capitalized - */ - public function capitalizeFirst(string $str): string - { - return ucfirst($str); - } - - /** - * Format a given number or fraction as a percentage. - * @param int|float $numerator Numerator or single fraction if denominator is ommitted. - * @param int|null $denominator Denominator. - * @param integer $precision Number of decimal places to show. - * @return string Formatted percentage. - */ - public function percentFormat(int|float $numerator, ?int $denominator = null, int $precision = 1): string - { - return $this->i18n->percentFormat($numerator, $denominator, $precision); - } - - /** - * Helper to return whether the given user is an anonymous (logged out) user. - * @param Project $project - * @param User|string $user User object or username as a string. - * @return bool - */ - public function isUserAnon(Project $project, User|string $user): bool - { - if ($user instanceof User) { - $username = $user->getUsername(); - } else { - $username = (string)$user; - } - return IPUtils::isIPAddress($username) || User::isTempUsername($project, $username); - } - - /** - * Helper to properly translate a namespace name. - * @param int|string $namespace Namespace key as a string or ID. - * @param string[] $namespaces List of available namespaces as retrieved from Project::getNamespaces(). - * @return string Namespace name - */ - public function nsName(int|string $namespace, array $namespaces): string - { - if ('all' === $namespace) { - return $this->i18n->msg('all'); - } elseif ('0' === $namespace || 0 === $namespace || 'Main' === $namespace) { - return $this->i18n->msg('mainspace'); - } else { - return $namespaces[$namespace] ?? $this->i18n->msg('unknown'); - } - } - - /** - * Given a page title and namespace, generate the full page title. - * @param string $title - * @param int $namespace - * @param array $namespaces - * @return string - */ - public function titleWithNs(string $title, int $namespace, array $namespaces): string - { - $title = str_replace('_', ' ', $title); - if (0 === $namespace) { - return $title; - } - return $this->nsName($namespace, $namespaces).':'.$title; - } - - /** - * Format a given number as a diff, colouring it green if it's positive, red if negative, gary if zero - * @param int|null $size Diff size - * @return string Markup with formatted number - */ - public function diffFormat(?int $size): string - { - if (null === $size) { - // Deleted/suppressed revisions. - return ''; - } - if ($size < 0) { - $class = 'diff-neg'; - } elseif ($size > 0) { - $class = 'diff-pos'; - } else { - $class = 'diff-zero'; - } - - $size = $this->numberFormat($size); - - return "i18n->isRTL() ? " dir='rtl'" : ''). - ">$size"; - } - - /** - * Format a time duration as humanized string. - * @param int $seconds Number of seconds. - * @param bool $translate Used for unit testing. Set to false to return - * the value and i18n key, instead of the actual translation. - * @return string|array Examples: '30 seconds', '2 minutes', '15 hours', '500 days', - * or [30, 'num-seconds'] (etc.) if $translate is false. - */ - public function formatDuration(int $seconds, bool $translate = true): string|array - { - [$val, $key] = $this->getDurationMessageKey($seconds); - - // The following messages are used here: - // * num-days - // * num-hours - // * num-minutes - if ($translate) { - return $this->numberFormat($val).' '.$this->i18n->msg("num-$key", [$val]); - } else { - return [$this->numberFormat($val), "num-$key"]; - } - } - - /** - * Given a time duration in seconds, generate a i18n message key and value. - * @param int $seconds Number of seconds. - * @return array [int - message value, string - message key] - */ - private function getDurationMessageKey(int $seconds): array - { - // Value to show in message - $val = $seconds; - - // Unit of time, used in the key for the i18n message - $key = 'seconds'; - - if ($seconds >= 86400) { - // Over a day - $val = (int) floor($seconds / 86400); - $key = 'days'; - } elseif ($seconds >= 3600) { - // Over an hour, less than a day - $val = (int) floor($seconds / 3600); - $key = 'hours'; - } elseif ($seconds >= 60) { - // Over a minute, less than an hour - $val = (int) floor($seconds / 60); - $key = 'minutes'; - } - - return [$val, $key]; - } - - /** - * Build URL query string from given params. - * @param string[]|null $params - * @return string - */ - public function buildQuery(?array $params): string - { - return $params ? http_build_query($params) : ''; - } - - /** - * Shorthand to get the current request from the request stack. - * @return Request - * There is no request stack in the unit tests. - * @codeCoverageIgnore - */ - private function getRequest(): Request - { - return $this->requestStack->getCurrentRequest(); - } +class AppExtension extends AbstractExtension { + /** @var float Duration of the current HTTP request in seconds. */ + protected float $requestTime; + + public function __construct( + protected RequestStack $requestStack, + protected I18nHelper $i18n, + protected UrlGeneratorInterface $urlGenerator, + protected ProjectRepository $projectRepo, + protected ParameterBagInterface $parameterBag, + protected bool $isWMF, + protected bool $singleWiki, + protected int $replagThreshold + ) { + } + + /*********************************** FUNCTIONS */ + + /** + * Get all functions that this class provides. + * @return TwigFunction[] + * @codeCoverageIgnore + */ + public function getFunctions(): array { + return [ + new TwigFunction( 'request_time', [ $this, 'requestTime' ] ), + new TwigFunction( 'memory_usage', [ $this, 'requestMemory' ] ), + new TwigFunction( 'msgIfExists', [ $this, 'msgIfExists' ], [ 'is_safe' => [ 'html' ] ] ), + new TwigFunction( 'msgExists', [ $this, 'msgExists' ], [ 'is_safe' => [ 'html' ] ] ), + new TwigFunction( 'msg', [ $this, 'msg' ], [ 'is_safe' => [ 'html' ] ] ), + new TwigFunction( 'lang', [ $this, 'getLang' ] ), + new TwigFunction( 'langName', [ $this, 'getLangName' ] ), + new TwigFunction( 'fallbackLangs', [ $this, 'getFallbackLangs' ] ), + new TwigFunction( 'allLangs', [ $this, 'getAllLangs' ] ), + new TwigFunction( 'isRTL', [ $this, 'isRTL' ] ), + new TwigFunction( 'shortHash', [ $this, 'gitShortHash' ] ), + new TwigFunction( 'hash', [ $this, 'gitHash' ] ), + new TwigFunction( 'releaseDate', [ $this, 'gitDate' ] ), + new TwigFunction( 'enabled', [ $this, 'toolEnabled' ] ), + new TwigFunction( 'tools', [ $this, 'tools' ] ), + new TwigFunction( 'color', [ $this, 'getColorList' ] ), + new TwigFunction( 'chartColor', [ $this, 'chartColor' ] ), + new TwigFunction( 'isSingleWiki', [ $this, 'isSingleWiki' ] ), + new TwigFunction( 'getReplagThreshold', [ $this, 'getReplagThreshold' ] ), + new TwigFunction( 'isWMF', [ $this, 'isWMF' ] ), + new TwigFunction( 'replag', [ $this, 'replag' ] ), + new TwigFunction( 'quote', [ $this, 'quote' ] ), + new TwigFunction( 'bugReportURL', [ $this, 'bugReportURL' ] ), + new TwigFunction( 'logged_in_user', [ $this, 'loggedInUser' ] ), + new TwigFunction( 'isUserAnon', [ $this, 'isUserAnon' ] ), + new TwigFunction( 'nsName', [ $this, 'nsName' ] ), + new TwigFunction( 'titleWithNs', [ $this, 'titleWithNs' ] ), + new TwigFunction( 'formatDuration', [ $this, 'formatDuration' ] ), + new TwigFunction( 'numberFormat', [ $this, 'numberFormat' ] ), + new TwigFunction( 'buildQuery', [ $this, 'buildQuery' ] ), + new TwigFunction( 'login_url', [ $this, 'loginUrl' ] ), + ]; + } + + /** + * Get the duration of the current HTTP request in seconds. + * @return float + * Untestable since there is no request stack in the tests. + * @codeCoverageIgnore + */ + public function requestTime(): float { + if ( !isset( $this->requestTime ) ) { + $this->requestTime = microtime( true ) - $this->getRequest()->server->get( 'REQUEST_TIME_FLOAT' ); + } + + return $this->requestTime; + } + + /** + * Get the formatted real memory usage. + * @return float + */ + public function requestMemory(): float { + $mem = memory_get_usage( false ); + $div = pow( 1024, 2 ); + return $mem / $div; + } + + /** + * Get an i18n message. + * @param string $message + * @param string[] $vars + * @return string|null + */ + public function msg( string $message = '', array $vars = [] ): ?string { + return $this->i18n->msg( $message, $vars ); + } + + /** + * See if a given i18n message exists. + * @param string|null $message The message. + * @param string[] $vars + * @return bool + */ + public function msgExists( ?string $message, array $vars = [] ): bool { + return $this->i18n->msgExists( $message, $vars ); + } + + /** + * Get an i18n message if it exists, otherwise just get the message key. + * @param string|null $message + * @param string[] $vars + * @return string + */ + public function msgIfExists( ?string $message, array $vars = [] ): string { + return $this->i18n->msgIfExists( $message, $vars ); + } + + /** + * Get the current language code. + * @return string + */ + public function getLang(): string { + return $this->i18n->getLang(); + } + + /** + * Get the current language name (defaults to 'English'). + * @return string + */ + public function getLangName(): string { + return $this->i18n->getLangName(); + } + + /** + * Get the fallback languages for the current language, so we know what to load with jQuery.i18n. + * @return string[] + */ + public function getFallbackLangs(): array { + return $this->i18n->getFallbacks(); + } + + /** + * Get all available languages in the i18n directory + * @return string[] Associative array of langKey => langName + */ + public function getAllLangs(): array { + return $this->i18n->getAllLangs(); + } + + /** + * Whether the current language is right-to-left. + * @param string|null $lang Optionally provide a specific lanuage code. + * @return bool + */ + public function isRTL( ?string $lang = null ): bool { + return $this->i18n->isRTL( $lang ); + } + + /** + * Get the short hash of the currently checked-out Git commit. + * @return string + */ + public function gitShortHash(): string { + // phpcs:ignore MediaWiki.Usage.ForbiddenFunctions.exec + return exec( 'git rev-parse --short HEAD' ); + } + + /** + * Get the full hash of the currently checkout-out Git commit. + * @return string + */ + public function gitHash(): string { + // phpcs:ignore MediaWiki.Usage.ForbiddenFunctions.exec + return exec( 'git rev-parse HEAD' ); + } + + /** + * Get the date of the HEAD commit. + * @return string + */ + public function gitDate(): string { + // phpcs:ignore MediaWiki.Usage.ForbiddenFunctions.exec + $date = new DateTime( exec( 'git show -s --format=%ci' ) ); + return $this->dateFormat( $date, 'yyyy-MM-dd' ); + } + + /** + * Check whether a given tool is enabled. + * @param string $tool The short name of the tool. + * @return bool + */ + public function toolEnabled( string $tool = 'index' ): bool { + $param = false; + if ( $this->parameterBag->has( "enable.$tool" ) ) { + $param = (bool)$this->parameterBag->get( "enable.$tool" ); + } + return $param; + } + + /** + * Get a list of the short names of all tools. + * @return string[] + */ + public function tools(): array { + return $this->parameterBag->get( 'tools' ); + } + + /** + * Get the color for a given namespace. + * @param int|null $nsId Namespace ID. + * @return string Hex value of the color. + * @codeCoverageIgnore + */ + public function getColorList( ?int $nsId = null ): string { + $colors = [ + 0 => '#FF5555', + 1 => '#55FF55', + 2 => '#FFEE22', + 3 => '#FF55FF', + 4 => '#5555FF', + 5 => '#55FFFF', + 6 => '#C00000', + 7 => '#0000C0', + 8 => '#008800', + 9 => '#00C0C0', + 10 => '#FFAFAF', + 11 => '#808080', + 12 => '#00C000', + 13 => '#404040', + 14 => '#C0C000', + 15 => '#C000C0', + 90 => '#991100', + 91 => '#99FF00', + 92 => '#000000', + 93 => '#777777', + 100 => '#75A3D1', + 101 => '#A679D2', + 102 => '#660000', + 103 => '#000066', + 104 => '#FAFFAF', + 105 => '#408345', + 106 => '#5c8d20', + 107 => '#e1711d', + 108 => '#94ef2b', + 109 => '#756a4a', + 110 => '#6f1dab', + 111 => '#301e30', + 112 => '#5c9d96', + 113 => '#a8cd8c', + 114 => '#f2b3f1', + 115 => '#9b5828', + 116 => '#002288', + 117 => '#0000CC', + 118 => '#99FFFF', + 119 => '#99BBFF', + 120 => '#FF99FF', + 121 => '#CCFFFF', + 122 => '#CCFF00', + 123 => '#CCFFCC', + 200 => '#33FF00', + 201 => '#669900', + 202 => '#666666', + 203 => '#999999', + 204 => '#FFFFCC', + 205 => '#FF00CC', + 206 => '#FFFF00', + 207 => '#FFCC00', + 208 => '#FF0000', + 209 => '#FF6600', + 250 => '#6633CC', + 251 => '#6611AA', + 252 => '#66FF99', + 253 => '#66FF66', + 446 => '#06DCFB', + 447 => '#892EE4', + 460 => '#99FF66', + 461 => '#99CC66', + 470 => '#CCCC33', + 471 => '#CCFF33', + 480 => '#6699FF', + 481 => '#66FFFF', + 484 => '#07C8D6', + 485 => '#2AF1FF', + 486 => '#79CB21', + 487 => '#80D822', + 490 => '#995500', + 491 => '#998800', + 710 => '#FFCECE', + 711 => '#FFC8F2', + 828 => '#F7DE00', + 829 => '#BABA21', + 866 => '#FFFFFF', + 867 => '#FFCCFF', + 1198 => '#FF34B3', + 1199 => '#8B1C62', + 2300 => '#A900B8', + 2301 => '#C93ED6', + 2302 => '#8A09C1', + 2303 => '#974AB8', + 2600 => '#000000', + ]; + + // Default to grey. + return $colors[$nsId] ?? '#CCC'; + } + + /** + * Get color-blind friendly colors for use in charts + * @param int $num Index of color + * @return string RGBA color (so you can more easily adjust the opacity) + */ + public function chartColor( int $num ): string { + $colors = [ + 'rgba(171, 212, 235, 1)', + 'rgba(178, 223, 138, 1)', + 'rgba(251, 154, 153, 1)', + 'rgba(253, 191, 111, 1)', + 'rgba(202, 178, 214, 1)', + 'rgba(207, 182, 128, 1)', + 'rgba(141, 211, 199, 1)', + 'rgba(252, 205, 229, 1)', + 'rgba(255, 247, 161, 1)', + 'rgba(252, 146, 114, 1)', + 'rgba(217, 217, 217, 1)', + ]; + + return $colors[$num % count( $colors )]; + } + + /** + * Whether XTools is running in single-project mode. + * @return bool + */ + public function isSingleWiki(): bool { + return $this->singleWiki; + } + + /** + * Get the database replication-lag threshold. + * @return int + */ + public function getReplagThreshold(): int { + return $this->replagThreshold; + } + + /** + * Whether XTools is running in WMF mode. + * @return bool + */ + public function isWMF(): bool { + return $this->isWMF; + } + + /** + * The current replication lag. + * @return int + * @codeCoverageIgnore + */ + public function replag(): int { + $projectIdent = $this->getRequest()->get( 'project', 'enwiki' ); + $project = $this->projectRepo->getProject( $projectIdent ); + $dbName = $project->getDatabaseName(); + $sql = "SELECT lag FROM `heartbeat_p`.`heartbeat`"; + return (int)$project->getRepository()->executeProjectsQuery( $project, $sql, [ + 'project' => $dbName, + ] )->fetchOne(); + } + + /** + * Get a random quote for the footer + * @return string + */ + public function quote(): string { + // Don't show if Quote is turned off, but always show for WMF + // (so quote is in footer but not in nav). + if ( !$this->isWMF && !$this->parameterBag->get( 'enable.Quote' ) ) { + return ''; + } + $quotes = $this->parameterBag->get( 'quotes' ); + $id = array_rand( $quotes ); + return $quotes[$id]; + } + + /** + * Get the currently logged in user's details. + * @return string[]|object|null + */ + // phpcs:ignore MediaWiki.Commenting.FunctionComment.ObjectTypeHintReturn + public function loggedInUser(): array|object|null { + return $this->requestStack->getSession()->get( 'logged_in_user' ); + } + + /** + * Get a URL to the login route with parameters to redirect back to the current page after logging in. + * @param Request $request + * @return string + */ + public function loginUrl( Request $request ): string { + return $this->urlGenerator->generate( 'login', [ + 'callback' => $this->urlGenerator->generate( + 'oauth_callback', + [ 'redirect' => $request->getUri() ], + UrlGeneratorInterface::ABSOLUTE_URL + ), + ], UrlGeneratorInterface::ABSOLUTE_URL ); + } + + /*********************************** FILTERS */ + + /** + * Get all filters for this extension. + * @return TwigFilter[] + * @codeCoverageIgnore + */ + public function getFilters(): array { + return [ + new TwigFilter( 'ucfirst', [ $this, 'capitalizeFirst' ] ), + new TwigFilter( 'percent_format', [ $this, 'percentFormat' ] ), + new TwigFilter( 'diff_format', [ $this, 'diffFormat' ], [ 'is_safe' => [ 'html' ] ] ), + new TwigFilter( 'num_format', [ $this, 'numberFormat' ] ), + new TwigFilter( 'size_format', [ $this, 'sizeFormat' ] ), + new TwigFilter( 'date_format', [ $this, 'dateFormat' ] ), + new TwigFilter( 'wikify', [ $this, 'wikify' ] ), + ]; + } + + /** + * Format a number based on language settings. + * @param int|float $number + * @param int $decimals Number of decimals to format to. + * @return string + */ + public function numberFormat( int|float $number, int $decimals = 0 ): string { + return $this->i18n->numberFormat( $number, $decimals ); + } + + /** + * Format the given size (in bytes) as KB, MB, GB, or TB. + * Some code courtesy of Leo, CC BY-SA 4.0 + * @see https://stackoverflow.com/a/2510459/604142 + * @param int $bytes + * @param int $precision + * @return string + */ + public function sizeFormat( int $bytes, int $precision = 2 ): string { + $base = log( $bytes, 1024 ); + $suffixes = [ '', 'kilobytes', 'megabytes', 'gigabytes', 'terabytes' ]; + + $index = floor( $base ); + + if ( (int)$index === 0 ) { + return $this->numberFormat( $bytes ); + } + + $sizeMessage = $this->numberFormat( + pow( 1024, $base - floor( $base ) ), + $precision + ); + + return $this->i18n->msg( 'size-' . $suffixes[floor( $base )], [ $sizeMessage ] ); + } + + /** + * Localize the given date based on language settings. + * @param string|int|DateTime $datetime + * @param string $pattern Format according to this ICU date format. + * @see http://userguide.icu-project.org/formatparse/datetime + * @return string + */ + public function dateFormat( string|int|DateTime $datetime, string $pattern = 'yyyy-MM-dd HH:mm' ): string { + return $this->i18n->dateFormat( $datetime, $pattern ); + } + + /** + * Convert raw wikitext to HTML-formatted string. + * @param string $str + * @param Project $project + * @return string + */ + public function wikify( string $str, Project $project ): string { + return Edit::wikifyString( $str, $project ); + } + + /** + * Mysteriously missing Twig helper to capitalize only the first character. + * E.g. used for table headings for translated messages + * @param string $str The string + * @return string The string, capitalized + */ + public function capitalizeFirst( string $str ): string { + return ucfirst( $str ); + } + + /** + * Format a given number or fraction as a percentage. + * @param int|float $numerator Numerator or single fraction if denominator is ommitted. + * @param int|null $denominator Denominator. + * @param int $precision Number of decimal places to show. + * @return string Formatted percentage. + */ + public function percentFormat( int|float $numerator, ?int $denominator = null, int $precision = 1 ): string { + return $this->i18n->percentFormat( $numerator, $denominator, $precision ); + } + + /** + * Helper to return whether the given user is an anonymous (logged out) user. + * @param Project $project + * @param User|string $user User object or username as a string. + * @return bool + */ + public function isUserAnon( Project $project, User|string $user ): bool { + if ( $user instanceof User ) { + $username = $user->getUsername(); + } else { + $username = (string)$user; + } + return IPUtils::isIPAddress( $username ) || User::isTempUsername( $project, $username ); + } + + /** + * Helper to properly translate a namespace name. + * @param int|string $namespace Namespace key as a string or ID. + * @param string[] $namespaces List of available namespaces as retrieved from Project::getNamespaces(). + * @return string Namespace name + */ + public function nsName( int|string $namespace, array $namespaces ): string { + if ( $namespace === 'all' ) { + return $this->i18n->msg( 'all' ); + } elseif ( in_array( $namespace, [ '0', 0, 'Main' ], true ) ) { + return $this->i18n->msg( 'mainspace' ); + } else { + return $namespaces[$namespace] ?? $this->i18n->msg( 'unknown' ); + } + } + + /** + * Given a page title and namespace, generate the full page title. + * @param string $title + * @param int $namespace + * @param array $namespaces + * @return string + */ + public function titleWithNs( string $title, int $namespace, array $namespaces ): string { + $title = str_replace( '_', ' ', $title ); + if ( $namespace === 0 ) { + return $title; + } + return $this->nsName( $namespace, $namespaces ) . ':' . $title; + } + + /** + * Format a given number as a diff, colouring it green if it's positive, red if negative, gary if zero + * @param int|null $size Diff size + * @return string Markup with formatted number + */ + public function diffFormat( ?int $size ): string { + if ( $size === null ) { + // Deleted/suppressed revisions. + return ''; + } + if ( $size < 0 ) { + $class = 'diff-neg'; + } elseif ( $size > 0 ) { + $class = 'diff-pos'; + } else { + $class = 'diff-zero'; + } + + $size = $this->numberFormat( $size ); + + return "i18n->isRTL() ? " dir='rtl'" : '' ) . + ">$size"; + } + + /** + * Format a time duration as humanized string. + * @param int $seconds Number of seconds. + * @param bool $translate Used for unit testing. Set to false to return + * the value and i18n key, instead of the actual translation. + * @return string|array Examples: '30 seconds', '2 minutes', '15 hours', '500 days', + * or [30, 'num-seconds'] (etc.) if $translate is false. + */ + public function formatDuration( int $seconds, bool $translate = true ): string|array { + [ $val, $key ] = $this->getDurationMessageKey( $seconds ); + + // The following messages are used here: + // * num-days + // * num-hours + // * num-minutes + if ( $translate ) { + return $this->numberFormat( $val ) . ' ' . $this->i18n->msg( "num-$key", [ $val ] ); + } else { + return [ $this->numberFormat( $val ), "num-$key" ]; + } + } + + /** + * Given a time duration in seconds, generate a i18n message key and value. + * @param int $seconds Number of seconds. + * @return array [int - message value, string - message key] + */ + private function getDurationMessageKey( int $seconds ): array { + // Value to show in message + $val = $seconds; + + // Unit of time, used in the key for the i18n message + $key = 'seconds'; + + if ( $seconds >= 86400 ) { + // Over a day + $val = (int)floor( $seconds / 86400 ); + $key = 'days'; + } elseif ( $seconds >= 3600 ) { + // Over an hour, less than a day + $val = (int)floor( $seconds / 3600 ); + $key = 'hours'; + } elseif ( $seconds >= 60 ) { + // Over a minute, less than an hour + $val = (int)floor( $seconds / 60 ); + $key = 'minutes'; + } + + return [ $val, $key ]; + } + + /** + * Build URL query string from given params. + * @param string[]|null $params + * @return string + */ + public function buildQuery( ?array $params ): string { + return $params ? http_build_query( $params ) : ''; + } + + /** + * Shorthand to get the current request from the request stack. + * @return Request + * There is no request stack in the unit tests. + * @codeCoverageIgnore + */ + private function getRequest(): Request { + return $this->requestStack->getCurrentRequest(); + } } diff --git a/src/Twig/TopNavExtension.php b/src/Twig/TopNavExtension.php index b050be327..a9d200cb3 100644 --- a/src/Twig/TopNavExtension.php +++ b/src/Twig/TopNavExtension.php @@ -1,6 +1,6 @@ topNavEditCounter)) { - return $this->topNavEditCounter; - } - - $toolsMessages = [ - 'EditCounterGeneralStatsIndex' => 'general-stats', - 'EditCounterMonthCountsIndex' => 'month-counts', - 'EditCounterNamespaceTotalsIndex' => 'namespace-totals', - 'EditCounterRightsChangesIndex' => 'rights-changes', - 'EditCounterTimecardIndex' => 'timecard', - 'TopEdits' => 'top-edited-pages', - 'EditCounterYearCountsIndex' => 'year-counts', - ]; - - $this->topNavEditCounter = $this->sortEntries($toolsMessages, 'EditCounter'); - return $this->topNavEditCounter; - } - - /** - * Sorted list of links for the User dropdown. - * @return string[] Keys are tool IDs, values are the localized labels. - */ - public function topNavUser(): array - { - if (isset($this->topNavUser)) { - return $this->topNavUser; - } - - $toolsMessages = [ - 'AdminScore' => 'tool-adminscore', - 'AutoEdits' => 'tool-autoedits', - 'CategoryEdits' => 'tool-categoryedits', - 'EditCounter' => 'tool-editcounter', - 'EditSummary' => 'tool-editsummary', - 'GlobalContribs' => 'tool-globalcontribs', - 'Pages' => 'tool-pages', - 'EditCounterRightsChangesIndex' => 'rights-changes', - 'SimpleEditCounter' => 'tool-simpleeditcounter', - 'TopEdits' => 'tool-topedits', - ]; - - $this->topNavUser = $this->sortEntries($toolsMessages); - return $this->topNavUser; - } - - /** - * Sorted list of links for the Page dropdown. - * @return string[] Keys are tool IDs, values are the localized labels. - */ - public function topNavPage(): array - { - if (isset($this->topNavPage)) { - return $this->topNavPage; - } - - $toolsMessages = [ - 'Authorship' => 'tool-authorship', - 'PageInfo' => 'tool-pageinfo', - 'Blame' => 'tool-blame', - ]; - - $this->topNavPage = $this->sortEntries($toolsMessages); - return $this->topNavPage; - } - - /** - * Sorted list of links for the Project dropdown. - * @return string[] Keys are tool IDs, values are the localized labels. - */ - public function topNavProject(): array - { - if (isset($this->topNavProject)) { - return $this->topNavProject; - } - - $toolsMessages = [ - 'AdminStats' => 'tool-adminstats', - 'PatrollerStats' => 'tool-patrollerstats', - 'StewardStats' => 'tool-stewardstats', - ]; - - $this->topNavProject = $this->sortEntries($toolsMessages, 'AdminStats'); - - // This one should go last. - if ($this->toolEnabled('LargestPages')) { - $this->topNavProject['LargestPages'] = $this->i18n->msg('tool-largestpages'); - } - - return $this->topNavProject; - } - - /** - * Sort the given entries, localizing the labels. - * @param array $entries - * @param string|null $toolCheck Only make sure this tool is enabled (not individual tools passed in). - * @return array - */ - private function sortEntries(array $entries, ?string $toolCheck = null): array - { - $toolMessages = []; - - foreach ($entries as $tool => $key) { - if ($this->toolEnabled($toolCheck ?? $tool)) { - $toolMessages[$tool] = $this->i18n->msg($key); - } - } - - asort($toolMessages); - return $toolMessages; - } +class TopNavExtension extends AppExtension { + /** @var string[] Entries for Edit Counter dropdown. */ + protected array $topNavEditCounter; + + /** @var string[] Entries for User dropdown. */ + protected array $topNavUser; + + /** @var string[] Entries for Page dropdown. */ + protected array $topNavPage; + + /** @var string[] Entries for Project dropdown. */ + protected array $topNavProject; + + /** + * Twig functions this class provides. + * @return TwigFunction[] + * @codeCoverageIgnore + */ + public function getFunctions(): array { + return [ + new TwigFunction( 'top_nav_ec', [ $this, 'topNavEditCounter' ] ), + new TwigFunction( 'top_nav_user', [ $this, 'topNavUser' ] ), + new TwigFunction( 'top_nav_page', [ $this, 'topNavPage' ] ), + new TwigFunction( 'top_nav_project', [ $this, 'topNavProject' ] ), + ]; + } + + /** + * Sorted list of links for the Edit Counter dropdown. + * @return string[] Keys are tool IDs, values are the localized labels. + */ + public function topNavEditCounter(): array { + if ( isset( $this->topNavEditCounter ) ) { + return $this->topNavEditCounter; + } + + $toolsMessages = [ + 'EditCounterGeneralStatsIndex' => 'general-stats', + 'EditCounterMonthCountsIndex' => 'month-counts', + 'EditCounterNamespaceTotalsIndex' => 'namespace-totals', + 'EditCounterRightsChangesIndex' => 'rights-changes', + 'EditCounterTimecardIndex' => 'timecard', + 'TopEdits' => 'top-edited-pages', + 'EditCounterYearCountsIndex' => 'year-counts', + ]; + + $this->topNavEditCounter = $this->sortEntries( $toolsMessages, 'EditCounter' ); + return $this->topNavEditCounter; + } + + /** + * Sorted list of links for the User dropdown. + * @return string[] Keys are tool IDs, values are the localized labels. + */ + public function topNavUser(): array { + if ( isset( $this->topNavUser ) ) { + return $this->topNavUser; + } + + $toolsMessages = [ + 'AdminScore' => 'tool-adminscore', + 'AutoEdits' => 'tool-autoedits', + 'CategoryEdits' => 'tool-categoryedits', + 'EditCounter' => 'tool-editcounter', + 'EditSummary' => 'tool-editsummary', + 'GlobalContribs' => 'tool-globalcontribs', + 'Pages' => 'tool-pages', + 'EditCounterRightsChangesIndex' => 'rights-changes', + 'SimpleEditCounter' => 'tool-simpleeditcounter', + 'TopEdits' => 'tool-topedits', + ]; + + $this->topNavUser = $this->sortEntries( $toolsMessages ); + return $this->topNavUser; + } + + /** + * Sorted list of links for the Page dropdown. + * @return string[] Keys are tool IDs, values are the localized labels. + */ + public function topNavPage(): array { + if ( isset( $this->topNavPage ) ) { + return $this->topNavPage; + } + + $toolsMessages = [ + 'Authorship' => 'tool-authorship', + 'PageInfo' => 'tool-pageinfo', + 'Blame' => 'tool-blame', + ]; + + $this->topNavPage = $this->sortEntries( $toolsMessages ); + return $this->topNavPage; + } + + /** + * Sorted list of links for the Project dropdown. + * @return string[] Keys are tool IDs, values are the localized labels. + */ + public function topNavProject(): array { + if ( isset( $this->topNavProject ) ) { + return $this->topNavProject; + } + + $toolsMessages = [ + 'AdminStats' => 'tool-adminstats', + 'PatrollerStats' => 'tool-patrollerstats', + 'StewardStats' => 'tool-stewardstats', + ]; + + $this->topNavProject = $this->sortEntries( $toolsMessages, 'AdminStats' ); + + // This one should go last. + if ( $this->toolEnabled( 'LargestPages' ) ) { + $this->topNavProject['LargestPages'] = $this->i18n->msg( 'tool-largestpages' ); + } + + return $this->topNavProject; + } + + /** + * Sort the given entries, localizing the labels. + * @param array $entries + * @param string|null $toolCheck Only make sure this tool is enabled (not individual tools passed in). + * @return array + */ + private function sortEntries( array $entries, ?string $toolCheck = null ): array { + $toolMessages = []; + + foreach ( $entries as $tool => $key ) { + if ( $this->toolEnabled( $toolCheck ?? $tool ) ) { + $toolMessages[$tool] = $this->i18n->msg( $key ); + } + } + + asort( $toolMessages ); + return $toolMessages; + } } diff --git a/tests/Controller/AdminStatsControllerTest.php b/tests/Controller/AdminStatsControllerTest.php index 5785b945e..a956abe94 100644 --- a/tests/Controller/AdminStatsControllerTest.php +++ b/tests/Controller/AdminStatsControllerTest.php @@ -1,6 +1,6 @@ getParameter('app.is_wmf')) { - return; - } +class AdminStatsControllerTest extends ControllerTestAdapter { + /** + * Check response codes of index and result pages. + */ + public function testHtmlRoutes(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } - $this->assertSuccessfulRoutes([ - '/adminstats', - '/adminstats/fr.wikipedia.org', - '/adminstats/fr.wikipedia.org//2018-01-10', - '/stewardstats/meta.wikimedia.org/2018-01-01/2018-01-10?actions=global-rights', - ]); - } + $this->assertSuccessfulRoutes( [ + '/adminstats', + '/adminstats/fr.wikipedia.org', + '/adminstats/fr.wikipedia.org//2018-01-10', + '/stewardstats/meta.wikimedia.org/2018-01-01/2018-01-10?actions=global-rights', + ] ); + } - /** - * Check response codes of API endpoints. - */ - public function testApis(): void - { - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } + /** + * Check response codes of API endpoints. + */ + public function testApis(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } - $this->assertSuccessfulRoutes([ - '/api/project/admin_groups/fr.wikipedia', - '/api/project/admin_stats/frwiki/2019-01-01', - ]); - } + $this->assertSuccessfulRoutes( [ + '/api/project/admin_groups/fr.wikipedia', + '/api/project/admin_stats/frwiki/2019-01-01', + ] ); + } } diff --git a/tests/Controller/AuthorshipControllerTest.php b/tests/Controller/AuthorshipControllerTest.php index 78b6ddbf3..6e8f2e699 100644 --- a/tests/Controller/AuthorshipControllerTest.php +++ b/tests/Controller/AuthorshipControllerTest.php @@ -1,6 +1,6 @@ getParameter('app.is_wmf')) { - return; - } +class AuthorshipControllerTest extends ControllerTestAdapter { + /** + * Check response codes of index and result pages. + */ + public function testHtmlRoutes(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } - $this->assertSuccessfulRoutes([ - '/authorship', - '/authorship/de.wikipedia.org', - '/authorship/en.wikipedia.org/Hanksy/2016-01-01', - ]); - } + $this->assertSuccessfulRoutes( [ + '/authorship', + '/authorship/de.wikipedia.org', + '/authorship/en.wikipedia.org/Hanksy/2016-01-01', + ] ); + } } diff --git a/tests/Controller/AutomatedEditsControllerTest.php b/tests/Controller/AutomatedEditsControllerTest.php index 687c10cff..59e795e0b 100644 --- a/tests/Controller/AutomatedEditsControllerTest.php +++ b/tests/Controller/AutomatedEditsControllerTest.php @@ -1,6 +1,6 @@ client->request('GET', '/autoedits'); - static::assertEquals(200, $this->client->getResponse()->getStatusCode()); - - // For now... - if (!static::getContainer()->getParameter('app.is_wmf') || - static::getContainer()->getParameter('app.single_wiki') - ) { - return; - } - - // Should populate the appropriate fields. - $crawler = $this->client->request('GET', '/autoedits/de.wikipedia.org?namespace=3&end=2017-01-01'); - static::assertEquals(200, $this->client->getResponse()->getStatusCode()); - static::assertEquals('de.wikipedia.org', $crawler->filter('#project_input')->attr('value')); - static::assertEquals(3, $crawler->filter('#namespace_select option:selected')->attr('value')); - static::assertEquals('2017-01-01', $crawler->filter('[name=end]')->attr('value')); - - // Legacy URL params. - $crawler = $this->client->request('GET', '/autoedits?project=fr.wikipedia.org&namespace=5&begin=2017-02-01'); - static::assertEquals(200, $this->client->getResponse()->getStatusCode()); - static::assertEquals('fr.wikipedia.org', $crawler->filter('#project_input')->attr('value')); - static::assertEquals(5, $crawler->filter('#namespace_select option:selected')->attr('value')); - static::assertEquals('2017-02-01', $crawler->filter('[name=start]')->attr('value')); - } - - /** - * Check that the result pages return successful responses. - */ - public function testResultPages(): void - { - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } - - $this->assertSuccessfulRoutes([ - '/autoedits/en.wikipedia/Example', - '/autoedits/en.wikipedia/Example/1/2018-01-01/2018-02-01', - '/nonautoedits-contributions/en.wikipedia/Example/1/2018-01-01/2018-02-01/2018-01-15T12:00:00', - '/autoedits-contributions/en.wikipedia/Example/1/2018-01-01/2018-02-01/2018-01-15T12:00:00', - ]); - } - - /** - * Check that the APIs return successful responses. - */ - public function testApis(): void - { - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } - - // Non-automated edits endpoint is tested in self::testNonautomatedEdits(). - $this->assertSuccessfulRoutes([ - '/api/project/automated_tools/en.wikipedia', - '/api/user/automated_editcount/en.wikipedia/Example/1/2018-01-01/2018-02-01/2018-01-15T12:00:00', - '/api/user/automated_edits/en.wikipedia/Example/1/2018-01-01/2018-02-01/2018-01-15T12:00:00', - ]); - } - - /** - * Test automated edit counter endpoint. - */ - public function testAutomatedEditCount(): void - { - if (!static::getContainer()->getParameter('app.is_wmf')) { - // Untestable :( - return; - } - - $url = '/api/user/automated_editcount/en.wikipedia/musikPuppet/all///1'; - $this->client->request('GET', $url); - $response = $this->client->getResponse(); - static::assertEquals(200, $response->getStatusCode()); - static::assertEquals('application/json', $response->headers->get('content-type')); - - $data = json_decode($response->getContent(), true); - $toolNames = array_keys($data['automated_tools']); - - static::assertEquals($data['project'], 'en.wikipedia.org'); - static::assertEquals($data['username'], 'musikPuppet'); - static::assertGreaterThan(15, $data['automated_editcount']); - static::assertGreaterThan(35, $data['nonautomated_editcount']); - static::assertEquals( - $data['automated_editcount'] + $data['nonautomated_editcount'], - $data['total_editcount'] - ); - static::assertContains('Twinkle', $toolNames); - static::assertContains('Huggle', $toolNames); - } - - /** - * Test nonautomated edits endpoint. - */ - public function testNonautomatedEdits(): void - { - if (!static::getContainer()->getParameter('app.is_wmf')) { - // untestable :( - return; - } - - $url = '/api/user/nonautomated_edits/en.wikipedia/ThisIsaTest/all'; - $this->client->request('GET', $url); - $response = $this->client->getResponse(); - static::assertEquals(200, $response->getStatusCode()); - static::assertEquals('application/json', $response->headers->get('content-type')); - - // This test account *should* never edit again and be safe for testing... - static::assertCount(1, json_decode($response->getContent(), true)['nonautomated_edits']); - } +class AutomatedEditsControllerTest extends ControllerTestAdapter { + /** + * Test that the form can be retrieved. + */ + public function testIndex(): void { + // Check basics. + $this->client->request( 'GET', '/autoedits' ); + static::assertEquals( 200, $this->client->getResponse()->getStatusCode() ); + + // For now... + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) || + static::getContainer()->getParameter( 'app.single_wiki' ) + ) { + return; + } + + // Should populate the appropriate fields. + $crawler = $this->client->request( 'GET', '/autoedits/de.wikipedia.org?namespace=3&end=2017-01-01' ); + static::assertEquals( 200, $this->client->getResponse()->getStatusCode() ); + static::assertEquals( 'de.wikipedia.org', $crawler->filter( '#project_input' )->attr( 'value' ) ); + static::assertEquals( 3, $crawler->filter( '#namespace_select option:selected' )->attr( 'value' ) ); + static::assertEquals( '2017-01-01', $crawler->filter( '[name=end]' )->attr( 'value' ) ); + + // Legacy URL params. + $crawler = $this->client->request( 'GET', '/autoedits?project=fr.wikipedia.org&namespace=5&begin=2017-02-01' ); + static::assertEquals( 200, $this->client->getResponse()->getStatusCode() ); + static::assertEquals( 'fr.wikipedia.org', $crawler->filter( '#project_input' )->attr( 'value' ) ); + static::assertEquals( 5, $crawler->filter( '#namespace_select option:selected' )->attr( 'value' ) ); + static::assertEquals( '2017-02-01', $crawler->filter( '[name=start]' )->attr( 'value' ) ); + } + + /** + * Check that the result pages return successful responses. + */ + public function testResultPages(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } + + $this->assertSuccessfulRoutes( [ + '/autoedits/en.wikipedia/Example', + '/autoedits/en.wikipedia/Example/1/2018-01-01/2018-02-01', + '/nonautoedits-contributions/en.wikipedia/Example/1/2018-01-01/2018-02-01/2018-01-15T12:00:00', + '/autoedits-contributions/en.wikipedia/Example/1/2018-01-01/2018-02-01/2018-01-15T12:00:00', + ] ); + } + + /** + * Check that the APIs return successful responses. + */ + public function testApis(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } + + // Non-automated edits endpoint is tested in self::testNonautomatedEdits(). + $this->assertSuccessfulRoutes( [ + '/api/project/automated_tools/en.wikipedia', + '/api/user/automated_editcount/en.wikipedia/Example/1/2018-01-01/2018-02-01/2018-01-15T12:00:00', + '/api/user/automated_edits/en.wikipedia/Example/1/2018-01-01/2018-02-01/2018-01-15T12:00:00', + ] ); + } + + /** + * Test automated edit counter endpoint. + */ + public function testAutomatedEditCount(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + // Untestable :( + return; + } + + $url = '/api/user/automated_editcount/en.wikipedia/musikPuppet/all///1'; + $this->client->request( 'GET', $url ); + $response = $this->client->getResponse(); + static::assertEquals( 200, $response->getStatusCode() ); + static::assertEquals( 'application/json', $response->headers->get( 'content-type' ) ); + + $data = json_decode( $response->getContent(), true ); + $toolNames = array_keys( $data['automated_tools'] ); + + static::assertEquals( 'en.wikipedia.org', $data['project'] ); + static::assertEquals( 'musikPuppet', $data['username'] ); + static::assertGreaterThan( 15, $data['automated_editcount'] ); + static::assertGreaterThan( 35, $data['nonautomated_editcount'] ); + static::assertEquals( + $data['automated_editcount'] + $data['nonautomated_editcount'], + $data['total_editcount'] + ); + static::assertContains( 'Twinkle', $toolNames ); + static::assertContains( 'Huggle', $toolNames ); + } + + /** + * Test nonautomated edits endpoint. + */ + public function testNonautomatedEdits(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + // untestable :( + return; + } + + $url = '/api/user/nonautomated_edits/en.wikipedia/ThisIsaTest/all'; + $this->client->request( 'GET', $url ); + $response = $this->client->getResponse(); + static::assertEquals( 200, $response->getStatusCode() ); + static::assertEquals( 'application/json', $response->headers->get( 'content-type' ) ); + + // This test account *should* never edit again and be safe for testing... + static::assertCount( 1, json_decode( $response->getContent(), true )['nonautomated_edits'] ); + } } diff --git a/tests/Controller/CategoryEditsControllerTest.php b/tests/Controller/CategoryEditsControllerTest.php index 90684186e..221bb8182 100644 --- a/tests/Controller/CategoryEditsControllerTest.php +++ b/tests/Controller/CategoryEditsControllerTest.php @@ -1,6 +1,6 @@ getParameter('app.is_wmf')) { - return; - } +class CategoryEditsControllerTest extends ControllerTestAdapter { + /** + * Test that each route returns a successful response. + */ + public function testRoutes(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } - $this->assertSuccessfulRoutes([ - '/categoryedits', - '/categoryedits/en.wikipedia', - '/categoryedits/en.wikipedia/Example/Insects/2018-01-01/2018-02-01', - '/categoryedits-contributions/en.wikipedia/Example/Insects/2018-01-01/2018-02-01/5', - '/api/user/category_editcount/en.wikipedia/Example/Insects/2018-01-01/2018-02-01', - ]); - } + $this->assertSuccessfulRoutes( [ + '/categoryedits', + '/categoryedits/en.wikipedia', + '/categoryedits/en.wikipedia/Example/Insects/2018-01-01/2018-02-01', + '/categoryedits-contributions/en.wikipedia/Example/Insects/2018-01-01/2018-02-01/5', + '/api/user/category_editcount/en.wikipedia/Example/Insects/2018-01-01/2018-02-01', + ] ); + } } diff --git a/tests/Controller/ControllerTestAdapter.php b/tests/Controller/ControllerTestAdapter.php index 6ae521f00..7665ffb09 100644 --- a/tests/Controller/ControllerTestAdapter.php +++ b/tests/Controller/ControllerTestAdapter.php @@ -1,6 +1,6 @@ client = static::createClient(); - } - - /** - * Check that each given route returns a successful response. - * @param string[] $routes - */ - public function assertSuccessfulRoutes(array $routes): void - { - foreach ($routes as $route) { - $this->client->request('GET', $route); - static::assertTrue($this->client->getResponse()->isSuccessful(), "Failed: $route"); - } - } - - /** - * Check that each given route returns a successful response. - * @param string[] $routes - * @param int|null $statusCode - */ - public function assertUnsuccessfulRoutes(array $routes, ?int $statusCode = null): void - { - foreach ($routes as $route) { - $this->client->request('GET', $route); - static::assertEquals($statusCode, $this->client->getResponse()->getStatusCode(), "Failed: $route"); - } - } - - /** - * PHPUnit 6+ warns when there are no assertions in a test. - * Tests that connect to the replicas don't run in CI, so here we fake that assertions were made. - */ - public function tearDown(): void - { - if (!static::getContainer()->getParameter('app.is_wmf')) { - $this->addToAssertionCount(1); - } - parent::tearDown(); - } +class ControllerTestAdapter extends WebTestCase { + protected KernelBrowser $client; + protected SessionInterface $session; + + /** + * Set up the container and client. + */ + public function setUp(): void { + date_default_timezone_set( 'UTC' ); + $this->client = static::createClient(); + } + + /** + * Check that each given route returns a successful response. + * @param string[] $routes + */ + public function assertSuccessfulRoutes( array $routes ): void { + foreach ( $routes as $route ) { + $this->client->request( 'GET', $route ); + static::assertTrue( $this->client->getResponse()->isSuccessful(), "Failed: $route" ); + } + } + + /** + * Check that each given route returns a successful response. + * @param string[] $routes + * @param int|null $statusCode + */ + public function assertUnsuccessfulRoutes( array $routes, ?int $statusCode = null ): void { + foreach ( $routes as $route ) { + $this->client->request( 'GET', $route ); + static::assertEquals( $statusCode, $this->client->getResponse()->getStatusCode(), "Failed: $route" ); + } + } + + /** + * PHPUnit 6+ warns when there are no assertions in a test. + * Tests that connect to the replicas don't run in CI, so here we fake that assertions were made. + */ + public function tearDown(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + $this->addToAssertionCount( 1 ); + } + parent::tearDown(); + } } diff --git a/tests/Controller/DefaultControllerTest.php b/tests/Controller/DefaultControllerTest.php index 1d26e04b6..74d71ff12 100644 --- a/tests/Controller/DefaultControllerTest.php +++ b/tests/Controller/DefaultControllerTest.php @@ -1,6 +1,6 @@ isSingle = static::getContainer()->getParameter('app.single_wiki'); - } - - /** - * Test that the homepage is served, including in multiple languages. - */ - public function testIndex(): void - { - // Check basics. - $crawler = $this->client->request('GET', '/'); - static::assertEquals(200, $this->client->getResponse()->getStatusCode()); - static::assertStringContainsString('XTools', $crawler->filter('.splash-logo')->attr('alt')); - - // Change language. - $crawler = $this->client->request('GET', '/?uselang=es'); - static::assertStringContainsString( - 'Saciando tu hambre de datos', - $crawler->filter('#content h4')->text() - ); - - // Make sure all active tools are listed. - static::assertCount(14, $crawler->filter('.tool-list a.btn')); - } - - /** - * OAuth callback action. - */ - public function testOAuthCallback(): void - { - $this->client->request('GET', '/oauth_callback'); - - // Callback should 404 since we didn't give it anything. - static::assertEquals(404, $this->client->getResponse()->getStatusCode()); - } - - /** - * Logout action. - */ - public function testLogout(): void - { - $this->client->request('GET', '/logout'); - static::assertEquals(302, $this->client->getResponse()->getStatusCode()); - } - - /** - * Normalize a project name - */ - public function testNormalizeProject(): void - { - if (!$this->isSingle && static::getContainer()->getParameter('app.is_wmf')) { - $expectedOutput = [ - 'project' => 'en.wikipedia.org', - 'domain' => 'en.wikipedia.org', - 'url' => 'https://en.wikipedia.org/', - 'api' => 'https://en.wikipedia.org/w/api.php', - 'database' => 'enwiki', - ]; - - // from database name - $this->client->request('GET', '/api/project/normalize/enwiki'); - $output = json_decode($this->client->getResponse()->getContent(), true); - // Removed elapsed_time from the output, since we don't know what the value will be. - unset($output['elapsed_time']); - static::assertEquals($expectedOutput, $output); - - // from domain name (without .org) - $this->client->request('GET', '/api/project/normalize/en.wikipedia'); - $output = json_decode($this->client->getResponse()->getContent(), true); - unset($output['elapsed_time']); - static::assertEquals($expectedOutput, $output); - } - } - - /** - * Test that we can retrieve the namespace information. - */ - public function testNamespaces(): void - { - // Test 404 (for single-wiki setups, that wiki's namespaces are always returned). - $this->client->request('GET', '/api/project/namespaces/wiki.that.doesnt.exist.org'); - if ($this->isSingle) { - static::assertEquals(200, $this->client->getResponse()->getStatusCode()); - } else { - static::assertEquals(404, $this->client->getResponse()->getStatusCode()); - } - - if (!$this->isSingle && static::getContainer()->getParameter('app.is_wmf')) { - $this->client->request('GET', '/api/project/namespaces/fr.wikipedia.org'); - static::assertEquals(200, $this->client->getResponse()->getStatusCode()); - - // Check that a correct namespace value was returned - $response = (array) json_decode($this->client->getResponse()->getContent()); - $namespaces = (array) $response['namespaces']; - static::assertEquals('Utilisateur', array_values($namespaces)[2]); // User in French - } - } - - /** - * Test page assessments. - */ - public function testAssessments(): void - { - // Test 404 (for single-wiki setups, that wiki's namespaces are always returned). - $this->client->request('GET', '/api/project/assessments/wiki.that.doesnt.exist.org'); - if ($this->isSingle) { - static::assertEquals(200, $this->client->getResponse()->getStatusCode()); - } else { - static::assertEquals(404, $this->client->getResponse()->getStatusCode()); - } - - if (static::getContainer()->getParameter('app.is_wmf')) { - $this->client->request('GET', '/api/project/assessments/en.wikipedia.org'); - static::assertEquals(200, $this->client->getResponse()->getStatusCode()); - - $response = (array)json_decode($this->client->getResponse()->getContent(), true); - static::assertEquals('en.wikipedia.org', $response['project']); - static::assertArraySubset( - ['FA', 'A', 'GA', 'bplus', 'B', 'C', 'Start'], - array_keys($response['assessments']['class']) - ); - - $this->client->request('GET', '/api/project/assessments'); - static::assertTrue($this->client->getResponse()->isSuccessful(), "Failed: /api/project/assessments"); - } - } - - /** - * Test the wikify endpoint. - */ - public function testWikify(): void - { - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } - - $this->client->request('GET', '/api/project/parser/en.wikipedia.org?wikitext=[[Foo]]'); - static::assertTrue($this->client->getResponse()->isSuccessful()); - static::assertEquals( - "Foo", - json_decode($this->client->getResponse()->getContent(), true) - ); - } +class DefaultControllerTest extends ControllerTestAdapter { + use ArraySubsetAsserts; + + /** @var bool Whether we're testing a single-wiki setup */ + protected bool $isSingle; + + /** + * Set whether we're testing a single wiki. + */ + public function setUp(): void { + parent::setUp(); + $this->isSingle = static::getContainer()->getParameter( 'app.single_wiki' ); + } + + /** + * Test that the homepage is served, including in multiple languages. + */ + public function testIndex(): void { + // Check basics. + $crawler = $this->client->request( 'GET', '/' ); + static::assertEquals( 200, $this->client->getResponse()->getStatusCode() ); + static::assertStringContainsString( 'XTools', $crawler->filter( '.splash-logo' )->attr( 'alt' ) ); + + // Change language. + $crawler = $this->client->request( 'GET', '/?uselang=es' ); + static::assertStringContainsString( + 'Saciando tu hambre de datos', + $crawler->filter( '#content h4' )->text() + ); + + // Make sure all active tools are listed. + static::assertCount( 14, $crawler->filter( '.tool-list a.btn' ) ); + } + + /** + * OAuth callback action. + */ + public function testOAuthCallback(): void { + $this->client->request( 'GET', '/oauth_callback' ); + + // Callback should 404 since we didn't give it anything. + static::assertEquals( 404, $this->client->getResponse()->getStatusCode() ); + } + + /** + * Logout action. + */ + public function testLogout(): void { + $this->client->request( 'GET', '/logout' ); + static::assertEquals( 302, $this->client->getResponse()->getStatusCode() ); + } + + /** + * Normalize a project name + */ + public function testNormalizeProject(): void { + if ( !$this->isSingle && static::getContainer()->getParameter( 'app.is_wmf' ) ) { + $expectedOutput = [ + 'project' => 'en.wikipedia.org', + 'domain' => 'en.wikipedia.org', + 'url' => 'https://en.wikipedia.org/', + 'api' => 'https://en.wikipedia.org/w/api.php', + 'database' => 'enwiki', + ]; + + // from database name + $this->client->request( 'GET', '/api/project/normalize/enwiki' ); + $output = json_decode( $this->client->getResponse()->getContent(), true ); + // Removed elapsed_time from the output, since we don't know what the value will be. + unset( $output['elapsed_time'] ); + static::assertEquals( $expectedOutput, $output ); + + // from domain name (without .org) + $this->client->request( 'GET', '/api/project/normalize/en.wikipedia' ); + $output = json_decode( $this->client->getResponse()->getContent(), true ); + unset( $output['elapsed_time'] ); + static::assertEquals( $expectedOutput, $output ); + } + } + + /** + * Test that we can retrieve the namespace information. + */ + public function testNamespaces(): void { + // Test 404 (for single-wiki setups, that wiki's namespaces are always returned). + $this->client->request( 'GET', '/api/project/namespaces/wiki.that.doesnt.exist.org' ); + if ( $this->isSingle ) { + static::assertEquals( 200, $this->client->getResponse()->getStatusCode() ); + } else { + static::assertEquals( 404, $this->client->getResponse()->getStatusCode() ); + } + + if ( !$this->isSingle && static::getContainer()->getParameter( 'app.is_wmf' ) ) { + $this->client->request( 'GET', '/api/project/namespaces/fr.wikipedia.org' ); + static::assertEquals( 200, $this->client->getResponse()->getStatusCode() ); + + // Check that a correct namespace value was returned + $response = (array)json_decode( $this->client->getResponse()->getContent() ); + $namespaces = (array)$response['namespaces']; + // User in French + static::assertEquals( 'Utilisateur', array_values( $namespaces )[2] ); + } + } + + /** + * Test page assessments. + */ + public function testAssessments(): void { + // Test 404 (for single-wiki setups, that wiki's namespaces are always returned). + $this->client->request( 'GET', '/api/project/assessments/wiki.that.doesnt.exist.org' ); + if ( $this->isSingle ) { + static::assertEquals( 200, $this->client->getResponse()->getStatusCode() ); + } else { + static::assertEquals( 404, $this->client->getResponse()->getStatusCode() ); + } + + if ( static::getContainer()->getParameter( 'app.is_wmf' ) ) { + $this->client->request( 'GET', '/api/project/assessments/en.wikipedia.org' ); + static::assertEquals( 200, $this->client->getResponse()->getStatusCode() ); + + $response = (array)json_decode( $this->client->getResponse()->getContent(), true ); + static::assertEquals( 'en.wikipedia.org', $response['project'] ); + static::assertArraySubset( + [ 'FA', 'A', 'GA', 'bplus', 'B', 'C', 'Start' ], + array_keys( $response['assessments']['class'] ) + ); + + $this->client->request( 'GET', '/api/project/assessments' ); + static::assertTrue( $this->client->getResponse()->isSuccessful(), "Failed: /api/project/assessments" ); + } + } + + /** + * Test the wikify endpoint. + */ + public function testWikify(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } + + $this->client->request( 'GET', '/api/project/parser/en.wikipedia.org?wikitext=[[Foo]]' ); + static::assertTrue( $this->client->getResponse()->isSuccessful() ); + static::assertEquals( + "Foo", + json_decode( $this->client->getResponse()->getContent(), true ) + ); + } } diff --git a/tests/Controller/EditCounterControllerTest.php b/tests/Controller/EditCounterControllerTest.php index e0cd3f1b8..7448378df 100644 --- a/tests/Controller/EditCounterControllerTest.php +++ b/tests/Controller/EditCounterControllerTest.php @@ -1,6 +1,6 @@ client->request('GET', '/ec'); - static::assertEquals(200, $this->client->getResponse()->getStatusCode()); - - // For now... - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } - - $crawler = $this->client->request('GET', '/ec/de.wikipedia.org'); - static::assertEquals(200, $this->client->getResponse()->getStatusCode()); - - // Should populate project input field. - static::assertEquals('de.wikipedia.org', $crawler->filter('#project_input')->attr('value')); - - $routes = [ - '/ec-generalstats', - '/ec-namespacetotals', - '/ec-timecard', - '/ec-yearcounts', - '/ec-monthcounts', - '/ec-rightschanges', - ]; - - foreach ($routes as $route) { - $this->client->request('GET', $route); - static::assertTrue($this->client->getResponse()->isSuccessful(), "Failed: $route"); - } - } - - /** - * Test that the Edit Counter index pages and redirects for the subtools are correct. - */ - public function testSubtools(): void - { - // Cookies should not affect the index pages of subtools. - $cookie = new Cookie('XtoolsEditCounterOptions', 'general-stats'); - $this->client->getCookieJar()->set($cookie); - - $subtools = [ - 'general-stats', 'namespace-totals', 'year-counts', 'month-counts', 'timecard', 'rights-changes', - ]; - - foreach ($subtools as $subtool) { - $crawler = $this->client->request('GET', '/ec-'.str_replace('-', '', $subtool)); - static::assertEquals(200, $this->client->getResponse()->getStatusCode()); - static::assertEquals(1, count($crawler->filter('.checkbox input:checked'))); - static::assertEquals($subtool, $crawler->filter('.checkbox input:checked')->attr('value')); - } - - // For now... - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } - - // Requesting only one subtool should redirect to the dedicated route. - $this->client->request('GET', '/ec/en.wikipedia/Example?sections=rights-changes'); - static::assertTrue($this->client->getResponse()->isRedirect('/ec-rightschanges/en.wikipedia/Example')); - } - - /** - * Test setting of section preferences that are stored in a cookie. - */ - public function testCookies(): void - { - // For now... - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } - - $cookie = new Cookie('XtoolsEditCounterOptions', 'year-counts|rights-changes'); - $this->client->getCookieJar()->set($cookie); - - // Index page should have only the 'general stats' and 'rights changes' options checked. - $crawler = $this->client->request('GET', '/ec'); - static::assertEquals( - ['year-counts', 'rights-changes'], - $crawler->filter('.checkbox input:checked')->extract(['value']) - ); - - // Fill in username and project then submit. - $form = $crawler->selectButton('Submit')->form(); - $form['project'] = 'en.wikipedia'; - $form['username'] = 'Example'; - $this->client->submit($form); - - // Make sure only the requested sections are shown. - static::assertEquals(302, $this->client->getResponse()->getStatusCode()); - $crawler = $this->client->followRedirect(); - static::assertCount(2, $crawler->filter('.xt-toc a')); - static::assertStringContainsString('Year counts', $crawler->filter('.xt-toc')->text()); - static::assertStringContainsString('Rights changes', $crawler->filter('.xt-toc')->text()); - } - - /** - * Check that the result pages return successful responses. - */ - public function testResultPages(): void - { - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } - - $this->assertSuccessfulRoutes([ - '/ec/en.wikipedia/Example', - '/ec-generalstats/en.wikipedia/Example', - '/ec-namespacetotals/en.wikipedia/Example', - '/ec-timecard/en.wikipedia/Example', - '/ec-yearcounts/en.wikipedia/Example', - '/ec-monthcounts/en.wikipedia/Example', - '/ec-monthcounts/en.wikipedia/Example?format=wikitext', - '/ec-rightschanges/en.wikipedia/Example', - ]); - } - - /** - * Test that API endpoints return a successful response. - */ - public function testApis(): void - { - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } - - $this->assertSuccessfulRoutes([ - '/api/user/log_counts/enwiki/Example', - '/api/user/namespace_totals/enwiki/Example', - '/api/user/month_counts/enwiki/Example', - '/api/user/timecard/enwiki/Example', - ]); - } +class EditCounterControllerTest extends ControllerTestAdapter { + /** + * Test that the Edit Counter index pages display correctly. + */ + public function testIndexPages(): void { + $this->client->request( 'GET', '/ec' ); + static::assertEquals( 200, $this->client->getResponse()->getStatusCode() ); + + // For now... + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } + + $crawler = $this->client->request( 'GET', '/ec/de.wikipedia.org' ); + static::assertEquals( 200, $this->client->getResponse()->getStatusCode() ); + + // Should populate project input field. + static::assertEquals( 'de.wikipedia.org', $crawler->filter( '#project_input' )->attr( 'value' ) ); + + $routes = [ + '/ec-generalstats', + '/ec-namespacetotals', + '/ec-timecard', + '/ec-yearcounts', + '/ec-monthcounts', + '/ec-rightschanges', + ]; + + foreach ( $routes as $route ) { + $this->client->request( 'GET', $route ); + static::assertTrue( $this->client->getResponse()->isSuccessful(), "Failed: $route" ); + } + } + + /** + * Test that the Edit Counter index pages and redirects for the subtools are correct. + */ + public function testSubtools(): void { + // Cookies should not affect the index pages of subtools. + $cookie = new Cookie( 'XtoolsEditCounterOptions', 'general-stats' ); + $this->client->getCookieJar()->set( $cookie ); + + $subtools = [ + 'general-stats', 'namespace-totals', 'year-counts', 'month-counts', 'timecard', 'rights-changes', + ]; + + foreach ( $subtools as $subtool ) { + $crawler = $this->client->request( 'GET', '/ec-' . str_replace( '-', '', $subtool ) ); + static::assertEquals( 200, $this->client->getResponse()->getStatusCode() ); + static::assertCount( 1, $crawler->filter( '.checkbox input:checked' ) ); + static::assertEquals( $subtool, $crawler->filter( '.checkbox input:checked' )->attr( 'value' ) ); + } + + // For now... + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } + + // Requesting only one subtool should redirect to the dedicated route. + $this->client->request( 'GET', '/ec/en.wikipedia/Example?sections=rights-changes' ); + static::assertTrue( $this->client->getResponse()->isRedirect( '/ec-rightschanges/en.wikipedia/Example' ) ); + } + + /** + * Test setting of section preferences that are stored in a cookie. + */ + public function testCookies(): void { + // For now... + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } + + $cookie = new Cookie( 'XtoolsEditCounterOptions', 'year-counts|rights-changes' ); + $this->client->getCookieJar()->set( $cookie ); + + // Index page should have only the 'general stats' and 'rights changes' options checked. + $crawler = $this->client->request( 'GET', '/ec' ); + static::assertEquals( + [ 'year-counts', 'rights-changes' ], + $crawler->filter( '.checkbox input:checked' )->extract( [ 'value' ] ) + ); + + // Fill in username and project then submit. + $form = $crawler->selectButton( 'Submit' )->form(); + $form['project'] = 'en.wikipedia'; + $form['username'] = 'Example'; + $this->client->submit( $form ); + + // Make sure only the requested sections are shown. + static::assertEquals( 302, $this->client->getResponse()->getStatusCode() ); + $crawler = $this->client->followRedirect(); + static::assertCount( 2, $crawler->filter( '.xt-toc a' ) ); + static::assertStringContainsString( 'Year counts', $crawler->filter( '.xt-toc' )->text() ); + static::assertStringContainsString( 'Rights changes', $crawler->filter( '.xt-toc' )->text() ); + } + + /** + * Check that the result pages return successful responses. + */ + public function testResultPages(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } + + $this->assertSuccessfulRoutes( [ + '/ec/en.wikipedia/Example', + '/ec-generalstats/en.wikipedia/Example', + '/ec-namespacetotals/en.wikipedia/Example', + '/ec-timecard/en.wikipedia/Example', + '/ec-yearcounts/en.wikipedia/Example', + '/ec-monthcounts/en.wikipedia/Example', + '/ec-monthcounts/en.wikipedia/Example?format=wikitext', + '/ec-rightschanges/en.wikipedia/Example', + ] ); + } + + /** + * Test that API endpoints return a successful response. + */ + public function testApis(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } + + $this->assertSuccessfulRoutes( [ + '/api/user/log_counts/enwiki/Example', + '/api/user/namespace_totals/enwiki/Example', + '/api/user/month_counts/enwiki/Example', + '/api/user/timecard/enwiki/Example', + ] ); + } } diff --git a/tests/Controller/EditSummaryControllerTest.php b/tests/Controller/EditSummaryControllerTest.php index 3ff98dac8..4c0992b9e 100644 --- a/tests/Controller/EditSummaryControllerTest.php +++ b/tests/Controller/EditSummaryControllerTest.php @@ -1,6 +1,6 @@ client->request('GET', '/editsummary/de.wikipedia'); - static::assertEquals(200, $this->client->getResponse()->getStatusCode()); +class EditSummaryControllerTest extends ControllerTestAdapter { + /** + * Test that the Edit Summaries index page displays correctly. + */ + public function testIndex(): void { + $crawler = $this->client->request( 'GET', '/editsummary/de.wikipedia' ); + static::assertEquals( 200, $this->client->getResponse()->getStatusCode() ); - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } - // should populate project input field - static::assertEquals('de.wikipedia.org', $crawler->filter('#project_input')->attr('value')); - } + // should populate project input field + static::assertEquals( 'de.wikipedia.org', $crawler->filter( '#project_input' )->attr( 'value' ) ); + } - /** - * Test all other routes return successful responses. - */ - public function testRoutes(): void - { - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } + /** + * Test all other routes return successful responses. + */ + public function testRoutes(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } - $this->assertSuccessfulRoutes([ - '/editsummary/en.wikipedia/Example', - '/editsummary/en.wikipedia/Example/1', - '/api/user/edit_summaries/en.wikipedia/Example/1', - ]); - } + $this->assertSuccessfulRoutes( [ + '/editsummary/en.wikipedia/Example', + '/editsummary/en.wikipedia/Example/1', + '/api/user/edit_summaries/en.wikipedia/Example/1', + ] ); + } } diff --git a/tests/Controller/GlobalContribsControllerTest.php b/tests/Controller/GlobalContribsControllerTest.php index 63db31df7..992f6054c 100644 --- a/tests/Controller/GlobalContribsControllerTest.php +++ b/tests/Controller/GlobalContribsControllerTest.php @@ -1,6 +1,6 @@ getParameter('app.is_wmf')) { - return; - } +class GlobalContribsControllerTest extends ControllerTestAdapter { + /** + * Test that each route returns a successful response. + */ + public function testRoutes(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } - $this->assertSuccessfulRoutes([ - '/globalcontribs', - '/globalcontribs/Example', - '/api/user/globalcontribs/Example', - ]); - } + $this->assertSuccessfulRoutes( [ + '/globalcontribs', + '/globalcontribs/Example', + '/api/user/globalcontribs/Example', + ] ); + } } diff --git a/tests/Controller/MetaControllerTest.php b/tests/Controller/MetaControllerTest.php index 680ce0ccb..0f7e0e233 100644 --- a/tests/Controller/MetaControllerTest.php +++ b/tests/Controller/MetaControllerTest.php @@ -1,6 +1,6 @@ client->request('GET', '/meta'); - static::assertEquals(200, $this->client->getResponse()->getStatusCode()); +class MetaControllerTest extends ControllerTestAdapter { + /** + * Test that the Meta index page displays correctly. + */ + public function testIndex(): void { + $this->client->request( 'GET', '/meta' ); + static::assertEquals( 200, $this->client->getResponse()->getStatusCode() ); - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } - // Should redirect since we have supplied all necessary parameters. - $this->client->request('GET', '/meta?start=2017-10-01&end=2017-10-10'); - static::assertEquals(302, $this->client->getResponse()->getStatusCode()); - } + // Should redirect since we have supplied all necessary parameters. + $this->client->request( 'GET', '/meta?start=2017-10-01&end=2017-10-10' ); + static::assertEquals( 302, $this->client->getResponse()->getStatusCode() ); + } } diff --git a/tests/Controller/OverridableXtoolsController.php b/tests/Controller/OverridableXtoolsController.php index 6bd9074bf..c0a286a0a 100644 --- a/tests/Controller/OverridableXtoolsController.php +++ b/tests/Controller/OverridableXtoolsController.php @@ -1,6 +1,6 @@ overrides = $overrides; - } + /** + * @param ContainerInterface $container + * @param RequestStack $requestStack + * @param ManagerRegistry $managerRegistry + * @param CacheItemPoolInterface $cache + * @param FlashBagInterface $flashBag + * @param Client $guzzle + * @param I18nHelper $i18n + * @param ProjectRepository $projectRepo + * @param UserRepository $userRepo + * @param PageRepository $pageRepo + * @param Environment $twig + * @param bool $isWMF + * @param string $defaultProject + * @param string[] $overrides Keys are method names, values are what they should return. + */ + public function __construct( + ContainerInterface $container, + RequestStack $requestStack, + ManagerRegistry $managerRegistry, + CacheItemPoolInterface $cache, + FlashBagInterface $flashBag, + Client $guzzle, + I18nHelper $i18n, + ProjectRepository $projectRepo, + UserRepository $userRepo, + PageRepository $pageRepo, + Environment $twig, + bool $isWMF, + string $defaultProject, + array $overrides = [] + ) { + parent::__construct( + $container, + $requestStack, + $managerRegistry, + $cache, + $guzzle, + $i18n, + $projectRepo, + $userRepo, + $pageRepo, + $twig, + $isWMF, + $defaultProject + ); + $this->overrides = $overrides; + } - /** - * @inheritDoc - */ - public function getIndexRoute(): string - { - return $this->overrides['getIndexRoute'] ?? 'homepage'; - } + /** + * @inheritDoc + */ + public function getIndexRoute(): string { + return $this->overrides['getIndexRoute'] ?? 'homepage'; + } - /** - * @inheritDoc - */ - public function tooHighEditCountRoute(): ?string - { - return $this->overrides['tooHighEditCountRoute'] ?? parent::tooHighEditCountRoute(); - } + /** + * @inheritDoc + */ + public function tooHighEditCountRoute(): ?string { + return $this->overrides['tooHighEditCountRoute'] ?? parent::tooHighEditCountRoute(); + } - /** - * @inheritDoc - */ - public function tooHighEditCountActionAllowlist(): array - { - return $this->overrides['tooHighEditCountActionAllowlist'] ?? parent::tooHighEditCountActionAllowlist(); - } + /** + * @inheritDoc + */ + public function tooHighEditCountActionAllowlist(): array { + return $this->overrides['tooHighEditCountActionAllowlist'] ?? parent::tooHighEditCountActionAllowlist(); + } - /** - * @inheritDoc - */ - public function supportedProjects(): array - { - return $this->overrides['supportedProjects'] ?? parent::supportedProjects(); - } + /** + * @inheritDoc + */ + public function supportedProjects(): array { + return $this->overrides['supportedProjects'] ?? parent::supportedProjects(); + } - /** - * @inheritDoc - */ - public function restrictedApiActions(): array - { - return $this->overrides['restrictedApiActions'] ?? parent::restrictedApiActions(); - } + /** + * @inheritDoc + */ + public function restrictedApiActions(): array { + return $this->overrides['restrictedApiActions'] ?? parent::restrictedApiActions(); + } - /** - * @inheritDoc - */ - public function maxDays(): ?int - { - return $this->overrides['maxDays'] ?? parent::maxDays(); - } + /** + * @inheritDoc + */ + public function maxDays(): ?int { + return $this->overrides['maxDays'] ?? parent::maxDays(); + } - /** - * @inheritDoc - */ - public function defaultDays(): ?int - { - return $this->overrides['defaultDays'] ?? parent::defaultDays(); - } + /** + * @inheritDoc + */ + public function defaultDays(): ?int { + return $this->overrides['defaultDays'] ?? parent::defaultDays(); + } - /** - * @inheritDoc - */ - protected function maxLimit(): int - { - return $this->overrides['maxLimit'] ?? parent::maxLimit(); - } + /** + * @inheritDoc + */ + protected function maxLimit(): int { + return $this->overrides['maxLimit'] ?? parent::maxLimit(); + } } diff --git a/tests/Controller/PageInfoControllerTest.php b/tests/Controller/PageInfoControllerTest.php index 101db2f5a..ba7adeb08 100644 --- a/tests/Controller/PageInfoControllerTest.php +++ b/tests/Controller/PageInfoControllerTest.php @@ -1,6 +1,6 @@ client->request('GET', '/pageinfo/de.wikipedia'); - static::assertEquals(200, $this->client->getResponse()->getStatusCode()); - - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } - - // should populate project input field - static::assertEquals('de.wikipedia.org', $crawler->filter('#project_input')->attr('value')); - } - - /** - * Test the method that sets up a AdminStats instance. - */ - public function testPageInfoApi(): void - { - // For now... - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } - - $this->client->request('GET', '/api/page/pageinfo/en.wikipedia.org/Main_Page'); - - $response = $this->client->getResponse(); - static::assertEquals(200, $response->getStatusCode()); - - $data = json_decode($response->getContent(), true); - - // Some basic tests that should always hold true. - static::assertEquals($data['project'], 'en.wikipedia.org'); - static::assertEquals($data['page'], 'Main Page'); - static::assertTrue($data['revisions'] > 4000); - static::assertTrue($data['editors'] > 400); - static::assertEquals($data['creator'], 'TwoOneTwo'); - static::assertEquals($data['created_at'], '2002-01-26T15:28:12Z'); - static::assertEquals($data['created_rev_id'], 139992); - - static::assertEquals( - [ - 'project', 'page', 'watchers', 'pageviews', 'pageviews_offset', 'revisions', 'editors', - 'anon_edits', 'minor_edits', 'creator', 'creator_editcount', 'created_at', 'created_rev_id', - 'modified_at', 'secs_since_last_edit', 'modified_rev_id', 'assessment', 'elapsed_time', - ], - array_keys($data) - ); - } - - /** - * Check response codes of index and result pages. - */ - public function testHtmlRoutes(): void - { - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } - - $this->assertSuccessfulRoutes([ - '/pageinfo', - '/pageinfo/en.wikipedia.org/Ravine du Sud', - '/pageinfo/en.wikipedia.org/Ravine du Sud/2018-01-01', - '/pageinfo/en.wikipedia.org/Ravine du Sud/2018-01-01?format=wikitext', - ]); - - // Should redirect because there are no revisions. - $this->client->request('GET', '/pageinfo/en.wikipedia.org/Ravine du Sud/'.date('Y-m-d')); - static::assertTrue($this->client->getResponse()->isRedirect()); - } - - /** - * Check response codes of other API endpoints. - */ - public function testApis(): void - { - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } - - $this->assertSuccessfulRoutes([ - '/api/page/pageinfo/en.wikipedia/Ravine_du_Sud?format=html', - '/api/page/prose/en.wikipedia/Ravine_du_Sud', - '/api/page/assessments/en.wikipedia/Ravine_du_Sud', - '/api/page/links/en.wikipedia/Ravine_du_Sud', - '/api/page/top_editors/en.wikipedia/Ravine_du_Sud', - '/api/page/top_editors/en.wikipedia/Ravine_du_Sud/2018-01-01/2018-02-01', - '/api/page/bot_data/en.wikipedia/Ravine_du_Sud', - '/api/page/automated_edits/enwiki/Ravine_du_Sud', - ]); - } - - /** - * Test that cross-origin resource sharing (CORS) is set up correctly. - */ - public function testCors(): void - { - $this->client->request('GET', '/pageinfo'); - static::assertNull($this->client->getResponse()->headers->get('Vary')); - - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } - - $this->client->request('GET', '/api/page/pageinfo/en.wikipedia.org/Ravine_du_Sud?format=html'); - static::assertSame('Origin', $this->client->getResponse()->headers->get('Vary')); - } +class PageInfoControllerTest extends ControllerTestAdapter { + /** + * Test that the AdminStats index page displays correctly when given a project. + */ + public function testProjectIndex(): void { + $crawler = $this->client->request( 'GET', '/pageinfo/de.wikipedia' ); + static::assertEquals( 200, $this->client->getResponse()->getStatusCode() ); + + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } + + // should populate project input field + static::assertEquals( 'de.wikipedia.org', $crawler->filter( '#project_input' )->attr( 'value' ) ); + } + + /** + * Test the method that sets up a AdminStats instance. + */ + public function testPageInfoApi(): void { + // For now... + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } + + $this->client->request( 'GET', '/api/page/pageinfo/en.wikipedia.org/Main_Page' ); + + $response = $this->client->getResponse(); + static::assertEquals( 200, $response->getStatusCode() ); + + $data = json_decode( $response->getContent(), true ); + + // Some basic tests that should always hold true. + static::assertEquals( 'en.wikipedia.org', $data['project'] ); + static::assertEquals( 'Main Page', $data['page'] ); + static::assertTrue( $data['revisions'] > 4000 ); + static::assertTrue( $data['editors'] > 400 ); + static::assertEquals( 'TwoOneTwo', $data['creator'] ); + static::assertEquals( '2002-01-26T15:28:12Z', $data['created_at'] ); + static::assertEquals( 139992, $data['created_rev_id'] ); + + static::assertEquals( + [ + 'project', 'page', 'watchers', 'pageviews', 'pageviews_offset', 'revisions', 'editors', + 'anon_edits', 'minor_edits', 'creator', 'creator_editcount', 'created_at', 'created_rev_id', + 'modified_at', 'secs_since_last_edit', 'modified_rev_id', 'assessment', 'elapsed_time', + ], + array_keys( $data ) + ); + } + + /** + * Check response codes of index and result pages. + */ + public function testHtmlRoutes(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } + + $this->assertSuccessfulRoutes( [ + '/pageinfo', + '/pageinfo/en.wikipedia.org/Ravine du Sud', + '/pageinfo/en.wikipedia.org/Ravine du Sud/2018-01-01', + '/pageinfo/en.wikipedia.org/Ravine du Sud/2018-01-01?format=wikitext', + ] ); + + // Should redirect because there are no revisions. + $this->client->request( 'GET', '/pageinfo/en.wikipedia.org/Ravine du Sud/' . date( 'Y-m-d' ) ); + static::assertTrue( $this->client->getResponse()->isRedirect() ); + } + + /** + * Check response codes of other API endpoints. + */ + public function testApis(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } + + $this->assertSuccessfulRoutes( [ + '/api/page/pageinfo/en.wikipedia/Ravine_du_Sud?format=html', + '/api/page/prose/en.wikipedia/Ravine_du_Sud', + '/api/page/assessments/en.wikipedia/Ravine_du_Sud', + '/api/page/links/en.wikipedia/Ravine_du_Sud', + '/api/page/top_editors/en.wikipedia/Ravine_du_Sud', + '/api/page/top_editors/en.wikipedia/Ravine_du_Sud/2018-01-01/2018-02-01', + '/api/page/bot_data/en.wikipedia/Ravine_du_Sud', + '/api/page/automated_edits/enwiki/Ravine_du_Sud', + ] ); + } + + /** + * Test that cross-origin resource sharing (CORS) is set up correctly. + */ + public function testCors(): void { + $this->client->request( 'GET', '/pageinfo' ); + static::assertNull( $this->client->getResponse()->headers->get( 'Vary' ) ); + + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } + + $this->client->request( 'GET', '/api/page/pageinfo/en.wikipedia.org/Ravine_du_Sud?format=html' ); + static::assertSame( 'Origin', $this->client->getResponse()->headers->get( 'Vary' ) ); + } } diff --git a/tests/Controller/PagesControllerTest.php b/tests/Controller/PagesControllerTest.php index aacce494b..8f99e8a14 100644 --- a/tests/Controller/PagesControllerTest.php +++ b/tests/Controller/PagesControllerTest.php @@ -1,6 +1,6 @@ getParameter('app.is_wmf')) { - return; - } - - $crawler = $this->client->request('GET', '/pages/de.wikipedia.org'); - static::assertEquals(200, $this->client->getResponse()->getStatusCode()); - - // should populate project input field - static::assertEquals('de.wikipedia.org', $crawler->filter('#project_input')->attr('value')); - - // assert that the namespaces were correctly loaded from API - $namespaceOptions = $crawler->filter('#namespace_select option'); - static::assertEquals('Diskussion', trim($namespaceOptions->eq(2)->text())); // Talk in German - } - - /** - * Test that all other routes return successful responses. - */ - public function testRoutes(): void - { - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } - - $this->assertSuccessfulRoutes([ - '/pages/en.wikipedia/Example', - '/pages/en.wikipedia/Example/0', - '/pages/en.wikipedia.org/MusikVarmint/4', - '/pages/en.wikipedia.org/MusikVarmint/4?format=wikitext', - '/pages/en.wikipedia/Example/0/noredirects/all/2018-01-01//2018-01-15T12:00:00', - '/pages/en.wikipedia/Foobar/0/noredirects/all/2018-01-01//2018-01-15T12:00:00?format=wikitext', - '/pages/en.wikipedia/Foobar/0/noredirects/all//2018-01-01/2018-01-15T12:00:00?format=csv', - '/pages/en.wikipedia/Foobar/0/noredirects/all///2018-01-15T12:00:00?format=tsv', - '/api/user/pages_count/en.wikipedia/Example/0/noredirects/deleted', - ]); - } +class PagesControllerTest extends ControllerTestAdapter { + /** + * Test that the Pages tool index page displays correctly. + */ + public function testIndex(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } + + $crawler = $this->client->request( 'GET', '/pages/de.wikipedia.org' ); + static::assertEquals( 200, $this->client->getResponse()->getStatusCode() ); + + // should populate project input field + static::assertEquals( 'de.wikipedia.org', $crawler->filter( '#project_input' )->attr( 'value' ) ); + + // assert that the namespaces were correctly loaded from API + $namespaceOptions = $crawler->filter( '#namespace_select option' ); + // Talk in German + static::assertEquals( 'Diskussion', trim( $namespaceOptions->eq( 2 )->text() ) ); + } + + /** + * Test that all other routes return successful responses. + */ + public function testRoutes(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } + + $this->assertSuccessfulRoutes( [ + '/pages/en.wikipedia/Example', + '/pages/en.wikipedia/Example/0', + '/pages/en.wikipedia.org/MusikVarmint/4', + '/pages/en.wikipedia.org/MusikVarmint/4?format=wikitext', + '/pages/en.wikipedia/Example/0/noredirects/all/2018-01-01//2018-01-15T12:00:00', + '/pages/en.wikipedia/Foobar/0/noredirects/all/2018-01-01//2018-01-15T12:00:00?format=wikitext', + '/pages/en.wikipedia/Foobar/0/noredirects/all//2018-01-01/2018-01-15T12:00:00?format=csv', + '/pages/en.wikipedia/Foobar/0/noredirects/all///2018-01-15T12:00:00?format=tsv', + '/api/user/pages_count/en.wikipedia/Example/0/noredirects/deleted', + ] ); + } } diff --git a/tests/Controller/SimpleEditCounterControllerTest.php b/tests/Controller/SimpleEditCounterControllerTest.php index 18896787c..e9c99b9be 100644 --- a/tests/Controller/SimpleEditCounterControllerTest.php +++ b/tests/Controller/SimpleEditCounterControllerTest.php @@ -1,6 +1,6 @@ getParameter('app.is_wmf')) { - return; - } +class SimpleEditCounterControllerTest extends ControllerTestAdapter { + /** + * Test that all routes return successful responses. + */ + public function testRoutes(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } - $this->assertSuccessfulRoutes([ - '/sc', - '/sc/enwiki', - '/sc/en.wikipedia/Example', - '/sc/en.wikipedia/Example/1/2018-01-01/2018-02-01', - '/sc/en.wikipedia/ipr-174.197.128.0/1/2018-01-01/2018-02-01', - '/api/user/simple_editcount/en.wikipedia.org/Example/1/2018-01-01/2018-02-01', - ]); - } + $this->assertSuccessfulRoutes( [ + '/sc', + '/sc/enwiki', + '/sc/en.wikipedia/Example', + '/sc/en.wikipedia/Example/1/2018-01-01/2018-02-01', + '/sc/en.wikipedia/ipr-174.197.128.0/1/2018-01-01/2018-02-01', + '/api/user/simple_editcount/en.wikipedia.org/Example/1/2018-01-01/2018-02-01', + ] ); + } } diff --git a/tests/Controller/TopEditsControllerTest.php b/tests/Controller/TopEditsControllerTest.php index d3989fa0e..da35312ad 100644 --- a/tests/Controller/TopEditsControllerTest.php +++ b/tests/Controller/TopEditsControllerTest.php @@ -1,6 +1,6 @@ client->request('GET', '/topedits'); - static::assertEquals(200, $this->client->getResponse()->getStatusCode()); +class TopEditsControllerTest extends ControllerTestAdapter { + /** + * Test that the form can be retrieved. + */ + public function testIndex(): void { + // Check basics. + $this->client->request( 'GET', '/topedits' ); + static::assertEquals( 200, $this->client->getResponse()->getStatusCode() ); - // For now... - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } + // For now... + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } - // Should populate the appropriate fields. - $crawler = $this->client->request('GET', '/topedits/de.wikipedia.org?namespace=3&article=Test'); - static::assertEquals(200, $this->client->getResponse()->getStatusCode()); - static::assertEquals('de.wikipedia.org', $crawler->filter('#project_input')->attr('value')); - static::assertEquals(3, $crawler->filter('#namespace_select option:selected')->attr('value')); - static::assertEquals('Test', $crawler->filter('#page_input')->attr('value')); + // Should populate the appropriate fields. + $crawler = $this->client->request( 'GET', '/topedits/de.wikipedia.org?namespace=3&article=Test' ); + static::assertEquals( 200, $this->client->getResponse()->getStatusCode() ); + static::assertEquals( 'de.wikipedia.org', $crawler->filter( '#project_input' )->attr( 'value' ) ); + static::assertEquals( 3, $crawler->filter( '#namespace_select option:selected' )->attr( 'value' ) ); + static::assertEquals( 'Test', $crawler->filter( '#page_input' )->attr( 'value' ) ); - // Legacy URL params. - $crawler = $this->client->request('GET', '/topedits?namespace=5&page=Test&wiki=wikipedia&lang=fr'); - static::assertEquals(200, $this->client->getResponse()->getStatusCode()); - static::assertEquals('fr.wikipedia.org', $crawler->filter('#project_input')->attr('value')); - static::assertEquals(5, $crawler->filter('#namespace_select option:selected')->attr('value')); - static::assertEquals('Test', $crawler->filter('#page_input')->attr('value')); - } + // Legacy URL params. + $crawler = $this->client->request( 'GET', '/topedits?namespace=5&page=Test&wiki=wikipedia&lang=fr' ); + static::assertEquals( 200, $this->client->getResponse()->getStatusCode() ); + static::assertEquals( 'fr.wikipedia.org', $crawler->filter( '#project_input' )->attr( 'value' ) ); + static::assertEquals( 5, $crawler->filter( '#namespace_select option:selected' )->attr( 'value' ) ); + static::assertEquals( 'Test', $crawler->filter( '#page_input' )->attr( 'value' ) ); + } - /** - * Test all other routes. - */ - public function testRoutes(): void - { - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } + /** + * Test all other routes. + */ + public function testRoutes(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } - $this->assertSuccessfulRoutes([ - '/topedits/enwiki/Example', - '/topedits/enwiki/Example/1', - '/topedits/enwiki/MusikVarmint/0?format=wikitext', - '/topedits/enwiki/Example/1/Main Page', - '/api/user/top_edits/test.wikipedia/MusikPuppet/1', - '/api/user/top_edits/test.wikipedia/MusikPuppet/1/Main_Page', + $this->assertSuccessfulRoutes( [ + '/topedits/enwiki/Example', + '/topedits/enwiki/Example/1', + '/topedits/enwiki/MusikVarmint/0?format=wikitext', + '/topedits/enwiki/Example/1/Main Page', + '/api/user/top_edits/test.wikipedia/MusikPuppet/1', + '/api/user/top_edits/test.wikipedia/MusikPuppet/1/Main_Page', - // Former but with nonexistent namespace. - '/topedits/en.wikipedia/L235/447', - ]); - } + // Former but with nonexistent namespace. + '/topedits/en.wikipedia/L235/447', + ] ); + } - /** - * Routes that should return - */ - public function testNotOptedInRoutes(): void - { - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } + /** + * Routes that should return + */ + public function testNotOptedInRoutes(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } - $this->assertUnsuccessfulRoutes([ - // TODO: make HTML routes return proper codes for 'user hasn't opted in' errors. + $this->assertUnsuccessfulRoutes( [ + // TODO: make HTML routes return proper codes for 'user hasn't opted in' errors. // '/topedits/testwiki/MusikPuppet', // '/topedits/testwiki/MusikPuppet/0', - '/api/user/top_edits/test.wikipedia/MusikPuppet6', - '/api/user/top_edits/test.wikipedia/MusikPuppet6/all', - ], Response::HTTP_UNAUTHORIZED); - } + '/api/user/top_edits/test.wikipedia/MusikPuppet6', + '/api/user/top_edits/test.wikipedia/MusikPuppet6/all', + ], Response::HTTP_UNAUTHORIZED ); + } } diff --git a/tests/Controller/XtoolsControllerTest.php b/tests/Controller/XtoolsControllerTest.php index 95c52aa8f..a62395ff0 100644 --- a/tests/Controller/XtoolsControllerTest.php +++ b/tests/Controller/XtoolsControllerTest.php @@ -1,6 +1,6 @@ i18n = static::getContainer()->get('app.i18n_helper'); - } - - /** - * Create a new controller, making a Request with the given params. - * @param array $requestParams Parameters to use when instantiating the Request object. - * @param array $methodOverrides Keys are method names, values are what they should return. - * @return XtoolsController - */ - private function getControllerWithRequest(array $requestParams = [], array $methodOverrides = []): XtoolsController - { - $session = $this->createSession($this->client); - $requestStack = $this->getRequestStack($session, $requestParams); - - return new OverridableXtoolsController( - static::getContainer(), - $requestStack, - static::getContainer()->get('doctrine'), - static::getContainer()->get('cache.app'), - $session->getFlashBag(), - static::getContainer()->get('eight_points_guzzle.client.xtools'), - $this->i18n, - static::getContainer()->get('App\Repository\ProjectRepository'), - static::getContainer()->get('App\Repository\UserRepository'), - static::getContainer()->get('App\Repository\PageRepository'), - static::getContainer()->get('twig'), - static::getContainer()->getParameter('app.is_wmf'), - static::getContainer()->getParameter('default_project'), - $methodOverrides - ); - } - - /** - * Make sure all parameters are correctly parsed. - * @dataProvider paramsProvider - * @param array $params - * @param array $expected - */ - public function testParseQueryParams(array $params, array $expected): void - { - // Untestable in CI build :( - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } - - $controller = $this->getControllerWithRequest($params); - $result = $controller->parseQueryParams(); - static::assertEquals($expected, $result); - } - - /** - * Data for self::testRevisionsProcessed(). - * @return string[] - */ - public function paramsProvider(): array - { - return [ - [ - // Modern parameters. - [ - 'project' => 'en.wikipedia.org', - 'username' => 'Jimbo Wales', - 'namespace' => '0', - 'page' => 'Test', - 'start' => '2016-01-01', - 'end' => '2017-01-01', - ], [ - 'project' => 'en.wikipedia.org', - 'username' => 'Jimbo Wales', - 'namespace' => '0', - 'page' => 'Test', - 'start' => '2016-01-01', - 'end' => '2017-01-01', - ], - ], [ - // Legacy parameters mixed with modern. - [ - 'project' => 'enwiki', - 'user' => 'GoldenRing', - 'namespace' => '0', - 'article' => 'Test', - ], [ - 'project' => 'enwiki', - 'username' => 'GoldenRing', - 'namespace' => '0', - 'page' => 'Test', - ], - ], [ - // Missing parameters. - [ - 'project' => 'en.wikipedia', - 'page' => 'Test', - ], [ - 'project' => 'en.wikipedia', - 'page' => 'Test', - ], - ], [ - // Legacy style. - [ - 'wiki' => 'wikipedia', - 'lang' => 'de', - 'article' => 'Test', - 'name' => 'Bob Dylan', - 'begin' => '2016-01-01', - 'end' => '2017-01-01', - ], [ - 'project' => 'de.wikipedia.org', - 'page' => 'Test', - 'username' => 'Bob Dylan', - 'start' => '2016-01-01', - 'end' => '2017-01-01', - ], - ], [ - // Legacy style with metawiki. - [ - 'wiki' => 'wikimedia', - 'lang' => 'meta', - 'page' => 'Test', - ], [ - 'project' => 'meta.wikimedia.org', - 'page' => 'Test', - ], - ], [ - // Legacy style of the legacy style. - [ - 'wikilang' => 'da', - 'wikifam' => '.wikipedia.org', - 'page' => '311', - ], [ - 'project' => 'da.wikipedia.org', - 'page' => '311', - ], - ], [ - // Language-neutral project. - [ - 'wiki' => 'wikidata', - 'lang' => 'www', - 'page' => 'Q12345', - ], [ - 'project' => 'www.wikidata.org', - 'page' => 'Q12345', - ], - ], [ - // Language-neutral, ultra legacy style. - [ - 'wikifam' => 'wikidata', - 'wikilang' => 'www', - 'page' => 'Q12345', - ], [ - 'project' => 'www.wikidata.org', - 'page' => 'Q12345', - ], - ], - ]; - } - - /** - * Getting a Project from the project query string. - */ - public function testProjectFromQuery(): void - { - // Untestable on Travis :( - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } - - $controller = $this->getControllerWithRequest(['project' => 'de.wiktionary.org']); - static::assertEquals( - 'de.wiktionary.org', - $controller->getProjectFromQuery()->getDomain() - ); - - $controller = $this->getControllerWithRequest(); - static::assertEquals( - 'en.wikipedia.org', - $controller->getProjectFromQuery()->getDomain() - ); - } - - /** - * Validating the project and user parameters. - */ - public function testValidateProjectAndUser(): void - { - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } - - $controller = $this->getControllerWithRequest([ - 'project' => 'fr.wikibooks.org', - 'username' => 'MusikAnimal', - 'namespace' => '0', - ]); - - $project = $controller->validateProject('fr.wikibooks.org'); - static::assertEquals('fr.wikibooks.org', $project->getDomain()); - - $user = $controller->validateUser('MusikAnimal'); - static::assertEquals('MusikAnimal', $user->getUsername()); - - static::expectException(XtoolsHttpException::class); - static::expectExceptionMessage('The requested user does not exist'); - $controller->validateUser('Not a real user 8723849237'); - } - - /** - * Invalid projects. - */ - public function testInvalidProject(): void - { - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } - - static::expectException(XtoolsHttpException::class); - static::expectExceptionMessage('invalid.project.og is not a valid project'); - $this->getControllerWithRequest(['project' => 'invalid.project.og']) - ->validateProject('invalid.project.org'); - } - - /** - * Users with too high of an edit count. - */ - public function testTooHighEditCount(): void - { - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } - - static::expectException(XtoolsHttpException::class); - static::expectExceptionMessage('User has made too many edits! (Maximum 350000)'); - $this->getControllerWithRequest([ - 'project' => 'en.wikipedia', - '_controller' => 'App\Controller\DefaultController::indexAction', - ], [ - 'tooHighEditCountRoute' => 'homepage', - ])->validateUser('Materialscientist'); - } - - /** - * Users with a high enough edit count that the user must login. - */ - public function testEditCountRequireLogin(): void - { - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } - - static::expectException(AccessDeniedHttpException::class); - static::expectExceptionMessage('error-login-required'); - $this->getControllerWithRequest([ - 'project' => 'en.wikipedia', - '_controller' => 'App\Controller\DefaultController::indexAction', - ])->validateUser('Before My Ken'); - } - - /** - * Make sure standardized params are properly parsed. - */ - public function testGetParams(): void - { - // Untestable on Travis :( - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } - - $controller = $this->getControllerWithRequest([ - 'project' => 'enwiki', - 'username' => 'Jimbo Wales', - 'namespace' => '0', - 'article' => 'Foo', - 'redirects' => '', - ]); - - static::assertEquals([ - 'project' => 'enwiki', - 'username' => 'Jimbo Wales', - 'namespace' => '0', - 'article' => 'Foo', - ], $controller->getParams()); - } - - /** - * Validate a page exists on a project. - */ - public function testValidatePage(): void - { - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } - - $controller = $this->getControllerWithRequest(['project' => 'enwiki']); - static::expectException(XtoolsHttpException::class); - $controller->validatePage('Test adjfaklsdjf'); - - static::assertInstanceOf( - 'Xtools\Page', - $controller->validatePage('Bob Dylan') - ); - } - - /** - * Converting start/end dates into UTC timestamps. - */ - public function testUTCFromDateParams(): void - { - $controller = $this->getControllerWithRequest(); - - // Both dates given, and are valid. - static::assertEquals( - [strtotime('2017-01-01'), strtotime('2017-08-01')], - $controller->getUnixFromDateParams('2017-01-01', '2017-08-01') - ); - - // End date exceeds current date. - [$start, $end] = $controller->getUnixFromDateParams('2017-01-01', '2050-08-01'); - static::assertEquals(strtotime('2017-01-01'), $start); - static::assertEquals(date('Y-m-d', time()), date('Y-m-d', $end)); - - // Start date is after end date. - static::assertEquals( - [strtotime('2017-08-01'), strtotime('2017-09-01')], - $controller->getUnixFromDateParams('2017-09-01', '2017-08-01') - ); - - // Start date is empty, should become false. - static::assertEquals( - [false, strtotime('2017-08-01')], - $controller->getUnixFromDateParams(null, '2017-08-01') - ); - - // Both dates empty. End date should become today. - static::assertEquals( - [false, strtotime('today midnight')], - $controller->getUnixFromDateParams(null, null) - ); - - // XtoolsController::getUnixFromDateParams() will now enforce a maximum date span of 5 days. - $controller = $this->getControllerWithRequest([], [ - 'maxDays' => 5, - ]); - - // Both dates given, exceeding max days, so start date should be end date - max days. - static::assertEquals( - [strtotime('2017-08-05'), strtotime('2017-08-10')], - $controller->getUnixFromDateParams('2017-08-01', '2017-08-10') - ); - - // Only end date given, start should also be end date - max days. - static::assertEquals( - [strtotime('2017-08-05'), strtotime('2017-08-10')], - $controller->getUnixFromDateParams(false, '2017-08-10') - ); - - // Start date after end date, exceeding max days. - static::assertEquals( - [strtotime('2017-08-05'), strtotime('2017-08-10')], - $controller->getUnixFromDateParams('2017-08-10', '2017-07-01') - ); - } - - /** - * Test involving fetching and settings cookies. - */ - public function testCookies(): void - { - $crawler = $this->client->request('GET', '/sc'); - static::assertEquals( - static::getContainer()->getParameter('default_project'), - $crawler->filter('#project_input')->attr('value') - ); - - // For now... - if (!static::getContainer()->getParameter('app.is_wmf')) { - return; - } - - $cookie = new Cookie('XtoolsProject', 'test.wikipedia'); - $this->client->getCookieJar()->set($cookie); - - $crawler = $this->client->request('GET', '/sc'); - static::assertEquals('test.wikipedia.org', $crawler->filter('#project_input')->attr('value')); - - $this->client->request('GET', '/sc/enwiki/Example'); - static::assertEquals( - 'en.wikipedia.org', - $this->client->getResponse()->headers->getCookies()[0]->getValue() - ); - } - - /** - * IP range handling. - */ - public function testIpRangeRestriction(): void - { - // No exception. - $this->getControllerWithRequest([ - 'project' => 'fr.wikipedia', - 'user' => '174.197.128.0/18', - ]); - - static::expectException(XtoolsHttpException::class); - static::expectExceptionMessage('The requested IP range is larger than the CIDR limit of /16.'); - $this->getControllerWithRequest([ - 'project' => 'fr.wikipedia', - 'user' => '174.197.128.0/1', - ]); - } - - public function testAddFullPageTitlesAndContinue(): void - { - $controller = $this->getControllerWithRequest([ - 'project' => 'test.wikipedia', - 'limit' => 2, - ]); - $out = [ 'foo' => 'bar' ]; - $data = [ - [ 'page_title' => 'Test_page', 'namespace' => 0, 'timestamp' => '2020-01-02T12:59:59' ], - [ 'page_title' => 'Test_page', 'namespace' => 1, 'timestamp' => '2020-01-03T12:59:59' ], - ]; - $newOut = $controller->addFullPageTitlesAndContinue('edits', $out, $data); - - $this->assertSame([ - 'foo' => 'bar', - 'edits' => [ - [ - 'full_page_title' => 'Test_page', - 'page_title' => 'Test_page', - 'namespace' => 0, - 'timestamp' => '2020-01-02T12:59:59', - ], - [ - 'full_page_title' => 'Talk:Test_page', - 'page_title' => 'Test_page', - 'namespace' => 1, - 'timestamp' => '2020-01-03T12:59:59', - ], - ], - 'continue' => '2020-01-03T12:59:59Z', - ], $newOut); - } - - public function testFormattedApiResponse(): void - { - $controller = $this->getControllerWithRequest([ - 'project' => 'en.wikipedia', - 'categories' => 'Foo|Bar|Baz', - ]); - $controller->addFlashMessage('warning', 'You had better watch yourself!'); - $response = json_decode( - $controller->getFormattedApiResponse(['data' => ['test' => 5]], Response::HTTP_BAD_GATEWAY)->getContent(), - true - ); - static::assertArraySubset([ - 'warning' => ['You had better watch yourself!'], - 'project' => 'en.wikipedia.org', - 'categories' => ['Foo', 'Bar', 'Baz'], - 'data' => ['test' => 5], - ], $response); - static::assertGreaterThan(0, $response['elapsed_time']); - } +class XtoolsControllerTest extends ControllerTestAdapter { + use ArraySubsetAsserts; + use SessionHelper; + + protected I18nHelper $i18n; + protected ReflectionClass $reflectionClass; + protected XtoolsController $controller; + + /** + * Set up the tests. + */ + public function setUp(): void { + parent::setUp(); + $this->i18n = static::getContainer()->get( 'app.i18n_helper' ); + } + + /** + * Create a new controller, making a Request with the given params. + * @param array $requestParams Parameters to use when instantiating the Request object. + * @param array $methodOverrides Keys are method names, values are what they should return. + * @return XtoolsController + */ + private function getControllerWithRequest( + array $requestParams = [], array $methodOverrides = [] + ): XtoolsController { + $session = $this->createSession( $this->client ); + $requestStack = $this->getRequestStack( $session, $requestParams ); + + return new OverridableXtoolsController( + static::getContainer(), + $requestStack, + static::getContainer()->get( 'doctrine' ), + static::getContainer()->get( 'cache.app' ), + $session->getFlashBag(), + static::getContainer()->get( 'eight_points_guzzle.client.xtools' ), + $this->i18n, + static::getContainer()->get( 'App\Repository\ProjectRepository' ), + static::getContainer()->get( 'App\Repository\UserRepository' ), + static::getContainer()->get( 'App\Repository\PageRepository' ), + static::getContainer()->get( 'twig' ), + static::getContainer()->getParameter( 'app.is_wmf' ), + static::getContainer()->getParameter( 'default_project' ), + $methodOverrides + ); + } + + /** + * Make sure all parameters are correctly parsed. + * @dataProvider paramsProvider + * @param array $params + * @param array $expected + */ + public function testParseQueryParams( array $params, array $expected ): void { + // Untestable in CI build :( + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } + + $controller = $this->getControllerWithRequest( $params ); + $result = $controller->parseQueryParams(); + static::assertEquals( $expected, $result ); + } + + /** + * Data for self::testRevisionsProcessed(). + * @return string[] + */ + public function paramsProvider(): array { + return [ + [ + // Modern parameters. + [ + 'project' => 'en.wikipedia.org', + 'username' => 'Jimbo Wales', + 'namespace' => '0', + 'page' => 'Test', + 'start' => '2016-01-01', + 'end' => '2017-01-01', + ], [ + 'project' => 'en.wikipedia.org', + 'username' => 'Jimbo Wales', + 'namespace' => '0', + 'page' => 'Test', + 'start' => '2016-01-01', + 'end' => '2017-01-01', + ], + ], [ + // Legacy parameters mixed with modern. + [ + 'project' => 'enwiki', + 'user' => 'GoldenRing', + 'namespace' => '0', + 'article' => 'Test', + ], [ + 'project' => 'enwiki', + 'username' => 'GoldenRing', + 'namespace' => '0', + 'page' => 'Test', + ], + ], [ + // Missing parameters. + [ + 'project' => 'en.wikipedia', + 'page' => 'Test', + ], [ + 'project' => 'en.wikipedia', + 'page' => 'Test', + ], + ], [ + // Legacy style. + [ + 'wiki' => 'wikipedia', + 'lang' => 'de', + 'article' => 'Test', + 'name' => 'Bob Dylan', + 'begin' => '2016-01-01', + 'end' => '2017-01-01', + ], [ + 'project' => 'de.wikipedia.org', + 'page' => 'Test', + 'username' => 'Bob Dylan', + 'start' => '2016-01-01', + 'end' => '2017-01-01', + ], + ], [ + // Legacy style with metawiki. + [ + 'wiki' => 'wikimedia', + 'lang' => 'meta', + 'page' => 'Test', + ], [ + 'project' => 'meta.wikimedia.org', + 'page' => 'Test', + ], + ], [ + // Legacy style of the legacy style. + [ + 'wikilang' => 'da', + 'wikifam' => '.wikipedia.org', + 'page' => '311', + ], [ + 'project' => 'da.wikipedia.org', + 'page' => '311', + ], + ], [ + // Language-neutral project. + [ + 'wiki' => 'wikidata', + 'lang' => 'www', + 'page' => 'Q12345', + ], [ + 'project' => 'www.wikidata.org', + 'page' => 'Q12345', + ], + ], [ + // Language-neutral, ultra legacy style. + [ + 'wikifam' => 'wikidata', + 'wikilang' => 'www', + 'page' => 'Q12345', + ], [ + 'project' => 'www.wikidata.org', + 'page' => 'Q12345', + ], + ], + ]; + } + + /** + * Getting a Project from the project query string. + */ + public function testProjectFromQuery(): void { + // Untestable on Travis :( + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } + + $controller = $this->getControllerWithRequest( [ 'project' => 'de.wiktionary.org' ] ); + static::assertEquals( + 'de.wiktionary.org', + $controller->getProjectFromQuery()->getDomain() + ); + + $controller = $this->getControllerWithRequest(); + static::assertEquals( + 'en.wikipedia.org', + $controller->getProjectFromQuery()->getDomain() + ); + } + + /** + * Validating the project and user parameters. + */ + public function testValidateProjectAndUser(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } + + $controller = $this->getControllerWithRequest( [ + 'project' => 'fr.wikibooks.org', + 'username' => 'MusikAnimal', + 'namespace' => '0', + ] ); + + $project = $controller->validateProject( 'fr.wikibooks.org' ); + static::assertEquals( 'fr.wikibooks.org', $project->getDomain() ); + + $user = $controller->validateUser( 'MusikAnimal' ); + static::assertEquals( 'MusikAnimal', $user->getUsername() ); + + static::expectException( XtoolsHttpException::class ); + static::expectExceptionMessage( 'The requested user does not exist' ); + $controller->validateUser( 'Not a real user 8723849237' ); + } + + /** + * Invalid projects. + */ + public function testInvalidProject(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } + + static::expectException( XtoolsHttpException::class ); + static::expectExceptionMessage( 'invalid.project.og is not a valid project' ); + $this->getControllerWithRequest( [ 'project' => 'invalid.project.og' ] ) + ->validateProject( 'invalid.project.org' ); + } + + /** + * Users with too high of an edit count. + */ + public function testTooHighEditCount(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } + + static::expectException( XtoolsHttpException::class ); + static::expectExceptionMessage( 'User has made too many edits! (Maximum 350000)' ); + $this->getControllerWithRequest( [ + 'project' => 'en.wikipedia', + '_controller' => 'App\Controller\DefaultController::indexAction', + ], [ + 'tooHighEditCountRoute' => 'homepage', + ] )->validateUser( 'Materialscientist' ); + } + + /** + * Users with a high enough edit count that the user must login. + */ + public function testEditCountRequireLogin(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } + + static::expectException( AccessDeniedHttpException::class ); + static::expectExceptionMessage( 'error-login-required' ); + $this->getControllerWithRequest( [ + 'project' => 'en.wikipedia', + '_controller' => 'App\Controller\DefaultController::indexAction', + ] )->validateUser( 'Before My Ken' ); + } + + /** + * Make sure standardized params are properly parsed. + */ + public function testGetParams(): void { + // Untestable on Travis :( + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } + + $controller = $this->getControllerWithRequest( [ + 'project' => 'enwiki', + 'username' => 'Jimbo Wales', + 'namespace' => '0', + 'article' => 'Foo', + 'redirects' => '', + ] ); + + static::assertEquals( [ + 'project' => 'enwiki', + 'username' => 'Jimbo Wales', + 'namespace' => '0', + 'article' => 'Foo', + ], $controller->getParams() ); + } + + /** + * Validate a page exists on a project. + */ + public function testValidatePage(): void { + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } + + $controller = $this->getControllerWithRequest( [ 'project' => 'enwiki' ] ); + static::expectException( XtoolsHttpException::class ); + $controller->validatePage( 'Test adjfaklsdjf' ); + + static::assertInstanceOf( + 'Xtools\Page', + $controller->validatePage( 'Bob Dylan' ) + ); + } + + /** + * Converting start/end dates into UTC timestamps. + */ + public function testUTCFromDateParams(): void { + $controller = $this->getControllerWithRequest(); + + // Both dates given, and are valid. + static::assertEquals( + [ strtotime( '2017-01-01' ), strtotime( '2017-08-01' ) ], + $controller->getUnixFromDateParams( '2017-01-01', '2017-08-01' ) + ); + + // End date exceeds current date. + [ $start, $end ] = $controller->getUnixFromDateParams( '2017-01-01', '2050-08-01' ); + static::assertEquals( strtotime( '2017-01-01' ), $start ); + static::assertEquals( date( 'Y-m-d', time() ), date( 'Y-m-d', $end ) ); + + // Start date is after end date. + static::assertEquals( + [ strtotime( '2017-08-01' ), strtotime( '2017-09-01' ) ], + $controller->getUnixFromDateParams( '2017-09-01', '2017-08-01' ) + ); + + // Start date is empty, should become false. + static::assertEquals( + [ false, strtotime( '2017-08-01' ) ], + $controller->getUnixFromDateParams( null, '2017-08-01' ) + ); + + // Both dates empty. End date should become today. + static::assertEquals( + [ false, strtotime( 'today midnight' ) ], + $controller->getUnixFromDateParams( null, null ) + ); + + // XtoolsController::getUnixFromDateParams() will now enforce a maximum date span of 5 days. + $controller = $this->getControllerWithRequest( [], [ + 'maxDays' => 5, + ] ); + + // Both dates given, exceeding max days, so start date should be end date - max days. + static::assertEquals( + [ strtotime( '2017-08-05' ), strtotime( '2017-08-10' ) ], + $controller->getUnixFromDateParams( '2017-08-01', '2017-08-10' ) + ); + + // Only end date given, start should also be end date - max days. + static::assertEquals( + [ strtotime( '2017-08-05' ), strtotime( '2017-08-10' ) ], + $controller->getUnixFromDateParams( false, '2017-08-10' ) + ); + + // Start date after end date, exceeding max days. + static::assertEquals( + [ strtotime( '2017-08-05' ), strtotime( '2017-08-10' ) ], + $controller->getUnixFromDateParams( '2017-08-10', '2017-07-01' ) + ); + } + + /** + * Test involving fetching and settings cookies. + */ + public function testCookies(): void { + $crawler = $this->client->request( 'GET', '/sc' ); + static::assertEquals( + static::getContainer()->getParameter( 'default_project' ), + $crawler->filter( '#project_input' )->attr( 'value' ) + ); + + // For now... + if ( !static::getContainer()->getParameter( 'app.is_wmf' ) ) { + return; + } + + $cookie = new Cookie( 'XtoolsProject', 'test.wikipedia' ); + $this->client->getCookieJar()->set( $cookie ); + + $crawler = $this->client->request( 'GET', '/sc' ); + static::assertEquals( 'test.wikipedia.org', $crawler->filter( '#project_input' )->attr( 'value' ) ); + + $this->client->request( 'GET', '/sc/enwiki/Example' ); + static::assertEquals( + 'en.wikipedia.org', + $this->client->getResponse()->headers->getCookies()[0]->getValue() + ); + } + + /** + * IP range handling. + */ + public function testIpRangeRestriction(): void { + // No exception. + $this->getControllerWithRequest( [ + 'project' => 'fr.wikipedia', + 'user' => '174.197.128.0/18', + ] ); + + static::expectException( XtoolsHttpException::class ); + static::expectExceptionMessage( 'The requested IP range is larger than the CIDR limit of /16.' ); + $this->getControllerWithRequest( [ + 'project' => 'fr.wikipedia', + 'user' => '174.197.128.0/1', + ] ); + } + + public function testAddFullPageTitlesAndContinue(): void { + $controller = $this->getControllerWithRequest( [ + 'project' => 'test.wikipedia', + 'limit' => 2, + ] ); + $out = [ 'foo' => 'bar' ]; + $data = [ + [ 'page_title' => 'Test_page', 'namespace' => 0, 'timestamp' => '2020-01-02T12:59:59' ], + [ 'page_title' => 'Test_page', 'namespace' => 1, 'timestamp' => '2020-01-03T12:59:59' ], + ]; + $newOut = $controller->addFullPageTitlesAndContinue( 'edits', $out, $data ); + + $this->assertSame( [ + 'foo' => 'bar', + 'edits' => [ + [ + 'full_page_title' => 'Test_page', + 'page_title' => 'Test_page', + 'namespace' => 0, + 'timestamp' => '2020-01-02T12:59:59', + ], + [ + 'full_page_title' => 'Talk:Test_page', + 'page_title' => 'Test_page', + 'namespace' => 1, + 'timestamp' => '2020-01-03T12:59:59', + ], + ], + 'continue' => '2020-01-03T12:59:59Z', + ], $newOut ); + } + + public function testFormattedApiResponse(): void { + $controller = $this->getControllerWithRequest( [ + 'project' => 'en.wikipedia', + 'categories' => 'Foo|Bar|Baz', + ] ); + $controller->addFlashMessage( 'warning', 'You had better watch yourself!' ); + $response = json_decode( + $controller->getFormattedApiResponse( + [ 'data' => [ 'test' => 5 ] ], + Response::HTTP_BAD_GATEWAY + )->getContent(), + true + ); + static::assertArraySubset( [ + 'warning' => [ 'You had better watch yourself!' ], + 'project' => 'en.wikipedia.org', + 'categories' => [ 'Foo', 'Bar', 'Baz' ], + 'data' => [ 'test' => 5 ], + ], $response ); + static::assertGreaterThan( 0, $response['elapsed_time'] ); + } } diff --git a/tests/Exception/BadGatewayExceptionTest.php b/tests/Exception/BadGatewayExceptionTest.php index 8f305c19e..9bdc35869 100644 --- a/tests/Exception/BadGatewayExceptionTest.php +++ b/tests/Exception/BadGatewayExceptionTest.php @@ -1,18 +1,19 @@ getMsgParams()); - static::assertEquals('api-error-wikimedia', $exception->getMessage()); - } +/** + * @covers \App\Exception\BadGatewayException + */ +class BadGatewayExceptionTest extends TestCase { + public function testMsgParams(): void { + $exception = new BadGatewayException( 'api-error-wikimedia', [ 'REST' ] ); + static::assertEquals( [ 'REST' ], $exception->getMsgParams() ); + static::assertEquals( 'api-error-wikimedia', $exception->getMessage() ); + } } diff --git a/tests/Helper/AutomatedEditsTest.php b/tests/Helper/AutomatedEditsTest.php index b27f21dee..bfbbe1df5 100644 --- a/tests/Helper/AutomatedEditsTest.php +++ b/tests/Helper/AutomatedEditsTest.php @@ -1,6 +1,6 @@ aeh = $this->getAutomatedEditsHelper($client); - } - - /** - * Test that the merge of per-wiki config and global config works - */ - public function testTools(): void - { - $this->setProject(); - $tools = $this->aeh->getTools($this->project); - - static::assertArraySubset( - [ - 'regex' => '\(\[\[WP:HG', - 'tags' => ['huggle'], - 'link' => 'w:en:Wikipedia:Huggle', - 'revert' => 'Reverted edits by.*?WP:HG', - ], - $tools['Huggle'] - ); - - static::assertEquals(1, array_count_values(array_keys($tools))['Huggle']); - } - - /** - * Make sure the right tool is detected - */ - public function testTool(): void - { - $this->setProject(); - static::assertArraySubset( - [ - 'name' => 'Huggle', - 'regex' => '\(\[\[WP:HG', - 'tags' => ['huggle'], - 'link' => 'w:en:Wikipedia:Huggle', - 'revert' => 'Reverted edits by.*?WP:HG', - ], - $this->aeh->getTool( - 'Level 2 warning re. [[Barack Obama]] ([[WP:HG|HG]]) (3.2.0)', - $this->project - ) - ); - } - - /** - * Tests that given edit summary is properly asserted as a revert - */ - public function testIsAutomated(): void - { - $this->setProject(); - static::assertTrue($this->aeh->isAutomated( - 'Level 2 warning re. [[Barack Obama]] ([[WP:HG|HG]]) (3.2.0)', - $this->project - )); - static::assertFalse($this->aeh->isAutomated( - 'You should try [[WP:Huggle]]', - $this->project - )); - } - - /** - * Test that the revert-related tools of getTools() are properly fetched - */ - public function testRevertTools(): void - { - $this->setProject(); - $tools = $this->aeh->getTools($this->project); - - static::assertArraySubset( - ['Huggle' => [ - 'regex' => '\(\[\[WP:HG', - 'tags' => ['huggle'], - 'link' => 'w:en:Wikipedia:Huggle', - 'revert' => 'Reverted edits by.*?WP:HG', - ]], - $tools - ); - - static::assertContains('Undo', array_keys($tools)); - } - - /** - * Test that regex is properly concatenated when merging rules. - */ - public function testRegexConcat(): void - { - $projectRepo = $this->createMock(ProjectRepository::class); - $projectRepo->expects($this->once()) - ->method('getOne') - ->willReturn([ - 'url' => 'https://ar.wikipedia.org', - 'dbName' => 'arwiki', - 'lang' => 'ar', - ]); - $project = new Project('ar.wikipedia.org'); - $project->setRepository($projectRepo); - - static::assertArraySubset( - ['HotCat' => [ - 'regex' => 'باستخدام \[\[ويكيبيديا:المصناف الفوري|\|HotCat\]\]' . - '|Gadget-Hotcat(?:check)?\.js\|Script|\]\] via HotCat|\[\[WP:HC\|', - 'link' => 'ويكيبيديا:المصناف الفوري', - 'label' => 'المصناف الفوري', - ]], - $this->aeh->getTools($project) - ); - } - - /** - * Was the edit a revert, based on the edit summary? - */ - public function testIsRevert(): void - { - $this->setProject(); - static::assertTrue($this->aeh->isRevert( - 'Reverted edits by Mogultalk (talk) ([[WP:HG|HG]]) (3.2.0)', - $this->project - )); - static::assertFalse($this->aeh->isRevert( - 'You should have reverted this edit using [[WP:HG|Huggle]]', - $this->project - )); - } - - /** - * Set the Project. This is done here because we don't want to use - * en.wikipedia for self::testRegexConcat(). - */ - private function setProject(): void - { - $projectRepo = $this->createMock(ProjectRepository::class); - $projectRepo->expects($this->once()) - ->method('getOne') - ->willReturn([ - 'url' => 'https://en.wikipedia.org', - 'dbName' => 'enwiki', - 'lang' => 'en', - ]); - $this->project = new Project('en.wikipedia.org'); - $this->project->setRepository($projectRepo); - } +class AutomatedEditsTest extends TestAdapter { + use ArraySubsetAsserts; + use SessionHelper; + + protected AutomatedEditsHelper $aeh; + protected Project $project; + + /** + * Set up the AutomatedEditsHelper object for testing. + */ + public function setUp(): void { + $client = static::createClient(); + $this->aeh = $this->getAutomatedEditsHelper( $client ); + } + + /** + * Test that the merge of per-wiki config and global config works + */ + public function testTools(): void { + $this->setProject(); + $tools = $this->aeh->getTools( $this->project ); + + static::assertArraySubset( + [ + 'regex' => '\(\[\[WP:HG', + 'tags' => [ 'huggle' ], + 'link' => 'w:en:Wikipedia:Huggle', + 'revert' => 'Reverted edits by.*?WP:HG', + ], + $tools['Huggle'] + ); + + static::assertSame( 1, array_count_values( array_keys( $tools ) )['Huggle'] ); + } + + /** + * Make sure the right tool is detected + */ + public function testTool(): void { + $this->setProject(); + static::assertArraySubset( + [ + 'name' => 'Huggle', + 'regex' => '\(\[\[WP:HG', + 'tags' => [ 'huggle' ], + 'link' => 'w:en:Wikipedia:Huggle', + 'revert' => 'Reverted edits by.*?WP:HG', + ], + $this->aeh->getTool( + 'Level 2 warning re. [[Barack Obama]] ([[WP:HG|HG]]) (3.2.0)', + $this->project + ) + ); + } + + /** + * Tests that given edit summary is properly asserted as a revert + */ + public function testIsAutomated(): void { + $this->setProject(); + static::assertTrue( $this->aeh->isAutomated( + 'Level 2 warning re. [[Barack Obama]] ([[WP:HG|HG]]) (3.2.0)', + $this->project + ) ); + static::assertFalse( $this->aeh->isAutomated( + 'You should try [[WP:Huggle]]', + $this->project + ) ); + } + + /** + * Test that the revert-related tools of getTools() are properly fetched + */ + public function testRevertTools(): void { + $this->setProject(); + $tools = $this->aeh->getTools( $this->project ); + + static::assertArraySubset( + [ 'Huggle' => [ + 'regex' => '\(\[\[WP:HG', + 'tags' => [ 'huggle' ], + 'link' => 'w:en:Wikipedia:Huggle', + 'revert' => 'Reverted edits by.*?WP:HG', + ] ], + $tools + ); + + static::assertContains( 'Undo', array_keys( $tools ) ); + } + + /** + * Test that regex is properly concatenated when merging rules. + */ + public function testRegexConcat(): void { + $projectRepo = $this->createMock( ProjectRepository::class ); + $projectRepo->expects( $this->once() ) + ->method( 'getOne' ) + ->willReturn( [ + 'url' => 'https://ar.wikipedia.org', + 'dbName' => 'arwiki', + 'lang' => 'ar', + ] ); + $project = new Project( 'ar.wikipedia.org' ); + $project->setRepository( $projectRepo ); + + static::assertArraySubset( + [ 'HotCat' => [ + 'regex' => 'باستخدام \[\[ويكيبيديا:المصناف الفوري|\|HotCat\]\]' . + '|Gadget-Hotcat(?:check)?\.js\|Script|\]\] via HotCat|\[\[WP:HC\|', + 'link' => 'ويكيبيديا:المصناف الفوري', + 'label' => 'المصناف الفوري', + ] ], + $this->aeh->getTools( $project ) + ); + } + + /** + * Was the edit a revert, based on the edit summary? + */ + public function testIsRevert(): void { + $this->setProject(); + static::assertTrue( $this->aeh->isRevert( + 'Reverted edits by Mogultalk (talk) ([[WP:HG|HG]]) (3.2.0)', + $this->project + ) ); + static::assertFalse( $this->aeh->isRevert( + 'You should have reverted this edit using [[WP:HG|Huggle]]', + $this->project + ) ); + } + + /** + * Set the Project. This is done here because we don't want to use + * en.wikipedia for self::testRegexConcat(). + */ + private function setProject(): void { + $projectRepo = $this->createMock( ProjectRepository::class ); + $projectRepo->expects( $this->once() ) + ->method( 'getOne' ) + ->willReturn( [ + 'url' => 'https://en.wikipedia.org', + 'dbName' => 'enwiki', + 'lang' => 'en', + ] ); + $this->project = new Project( 'en.wikipedia.org' ); + $this->project->setRepository( $projectRepo ); + } } diff --git a/tests/Helper/I18nHelperTest.php b/tests/Helper/I18nHelperTest.php index e478c5125..87e1642e6 100644 --- a/tests/Helper/I18nHelperTest.php +++ b/tests/Helper/I18nHelperTest.php @@ -1,6 +1,6 @@ session = $this->createSession(static::createClient()); - $this->i18n = new I18nHelper( - $this->getRequestStack($this->session), - static::getContainer()->getParameter('kernel.project_dir') - ); - } + public function setUp(): void { + $this->session = $this->createSession( static::createClient() ); + $this->i18n = new I18nHelper( + $this->getRequestStack( $this->session ), + static::getContainer()->getParameter( 'kernel.project_dir' ) + ); + } - public function testGetters(): void - { - static::assertEquals(Intuition::class, get_class($this->i18n->getIntuition())); - static::assertEquals('en', $this->i18n->getLang()); - static::assertEquals('English', $this->i18n->getLangName()); - static::assertGreaterThan(10, count($this->i18n->getAllLangs())); - } + public function testGetters(): void { + static::assertEquals( Intuition::class, get_class( $this->i18n->getIntuition() ) ); + static::assertEquals( 'en', $this->i18n->getLang() ); + static::assertEquals( 'English', $this->i18n->getLangName() ); + static::assertGreaterThan( 10, count( $this->i18n->getAllLangs() ) ); + } - public function testRTLAndFallbacks(): void - { - static::assertTrue($this->i18n->isRTL('ar')); - static::assertEquals(['zh-hans', 'en'], array_values($this->i18n->getFallbacks('zh'))); - } + public function testRTLAndFallbacks(): void { + static::assertTrue( $this->i18n->isRTL( 'ar' ) ); + static::assertEquals( [ 'zh-hans', 'en' ], array_values( $this->i18n->getFallbacks( 'zh' ) ) ); + } - public function testMessageHelpers(): void - { - static::assertEquals('Edit Counter', $this->i18n->msg('tool-editcounter')); - static::assertTrue($this->i18n->msgExists('tool-editcounter')); - static::assertEquals('foobar', $this->i18n->msgIfExists('foobar')); - } + public function testMessageHelpers(): void { + static::assertEquals( 'Edit Counter', $this->i18n->msg( 'tool-editcounter' ) ); + static::assertTrue( $this->i18n->msgExists( 'tool-editcounter' ) ); + static::assertEquals( 'foobar', $this->i18n->msgIfExists( 'foobar' ) ); + } - public function testNumberFormatting(): void - { - static::assertEquals('1,234,567.89', $this->i18n->numberFormat(1234567.89132, 2)); - static::assertEquals('5%', $this->i18n->percentFormat(5)); - static::assertEquals('5.43%', $this->i18n->percentFormat(5.4321, null, 2)); - static::assertEquals('50%', $this->i18n->percentFormat(100, 200)); - } + public function testNumberFormatting(): void { + static::assertEquals( '1,234,567.89', $this->i18n->numberFormat( 1234567.89132, 2 ) ); + static::assertEquals( '5%', $this->i18n->percentFormat( 5 ) ); + static::assertEquals( '5.43%', $this->i18n->percentFormat( 5.4321, null, 2 ) ); + static::assertEquals( '50%', $this->i18n->percentFormat( 100, 200 ) ); + } - public function testDateFormat(): void - { - $datetime = '2023-01-23 12:34'; - static::assertEquals($datetime, $this->i18n->dateFormat('2023-01-23T12:34')); - static::assertEquals($datetime, $this->i18n->dateFormat(new DateTime($datetime))); - static::assertEquals($datetime, $this->i18n->dateFormat(1674477240)); - } + public function testDateFormat(): void { + $datetime = '2023-01-23 12:34'; + static::assertEquals( $datetime, $this->i18n->dateFormat( '2023-01-23T12:34' ) ); + static::assertEquals( $datetime, $this->i18n->dateFormat( new DateTime( $datetime ) ) ); + static::assertEquals( $datetime, $this->i18n->dateFormat( 1674477240 ) ); + } - public function testGetIntuitionInvalidLang(): void - { - $invalidI18n = new I18nHelper( - $this->getRequestStack($this->session, ['uselang' => 'invalid-lang']), - static::getContainer()->getParameter('kernel.project_dir') - ); - static::assertEquals('en', $invalidI18n->getLang()); - } + public function testGetIntuitionInvalidLang(): void { + $invalidI18n = new I18nHelper( + $this->getRequestStack( $this->session, [ 'uselang' => 'invalid-lang' ] ), + static::getContainer()->getParameter( 'kernel.project_dir' ) + ); + static::assertEquals( 'en', $invalidI18n->getLang() ); + } } diff --git a/tests/Model/AdminStatsTest.php b/tests/Model/AdminStatsTest.php index 1f5a396ac..94362cf12 100644 --- a/tests/Model/AdminStatsTest.php +++ b/tests/Model/AdminStatsTest.php @@ -1,6 +1,6 @@ project = $this->createMock(Project::class); - $this->project->method('getUsersInGroups') - ->willReturn([ - 'Bob' => ['sysop', 'checkuser'], - 'Sarah' => ['epcoordinator'], - ]); - - $this->asRepo = $this->createMock(AdminStatsRepository::class); - - // This logic is tested with integration tests. - // Here we just stub empty arrays so AdminStats won't error outl. - $this->asRepo->method('getUserGroups') - ->willReturn(['local' => [], 'global' => []]); - } - - /** - * Basic getters. - */ - public function testBasics(): void - { - $startUTC = strtotime('2017-01-01'); - $endUTC = strtotime('2017-03-01'); - - $this->asRepo->expects(static::once()) - ->method('getStats') - ->willReturn($this->adminStatsFactory()); - $this->asRepo->method('getRelevantUserGroup') - ->willReturn('sysop'); - - // Single namespace, with defaults. - $as = new AdminStats($this->asRepo, $this->project, $startUTC, $endUTC, 'admin', []); - - $as->prepareStats(); - - static::assertEquals(1483228800, $as->getStart()); - static::assertEquals(1488326400, $as->getEnd()); - static::assertEquals(60, $as->numDays()); - static::assertEquals(1, $as->getNumInRelevantUserGroup()); - static::assertEquals(1, $as->getNumWithActionsNotInGroup()); - } - - /** - * Getting admins and their relevant user groups. - */ - public function testAdminsAndGroups(): void - { - $as = new AdminStats($this->asRepo, $this->project, 0, 0, 'admin', []); - $this->asRepo->expects($this->exactly(0)) - ->method('getStats') - ->willReturn($this->adminStatsFactory()); - $as->setRepository($this->asRepo); - - static::assertEquals( - [ - 'Bob' => ['sysop', 'checkuser'], - 'Sarah' => ['epcoordinator'], - ], - $as->getUsersAndGroups() - ); - } - - /** - * Test preparation and getting of actual stats. - */ - public function testStats(): void - { - $as = new AdminStats($this->asRepo, $this->project, 0, 0, 'admin', []); - $this->asRepo->expects($this->once()) - ->method('getStats') - ->willReturn($this->adminStatsFactory()); - $as->setRepository($this->asRepo); - $ret = $as->prepareStats(); - - // Test results. - static::assertEquals( - [ - 'Bob' => array_merge( - $this->adminStatsFactory()[0], - ['user-groups' => ['sysop', 'checkuser']] - ), - 'Sarah' => array_merge( - $this->adminStatsFactory()[1], // empty results - ['username' => 'Sarah', 'user-groups' => ['epcoordinator']] - ), - ], - $ret - ); - - // At this point get stats should be the same. - static::assertEquals($ret, $as->getStats()); - } - - /** - * Factory of what database will return. - */ - private function adminStatsFactory(): array - { - return [ - [ - 'username' => 'Bob', - 'delete' => 5, - 'restore' => 3, - 'block' => 0, - 'unblock' => 1, - 'protect' => 3, - 'unprotect' => 2, - 'rights' => 4, - 'import' => 2, - 'total' => 20, - ], - [ - 'username' => 'Sarah', - 'delete' => 1, - 'restore' => 0, - 'block' => 0, - 'unblock' => 0, - 'protect' => 0, - 'unprotect' => 0, - 'rights' => 0, - 'import' => 0, - 'total' => 0, - ], - ]; - } - - /** - * Test construction of "totals" row - */ - public function testTotalsRow(): void - { - $as = new AdminStats($this->asRepo, $this->project, 0, 0, 'admin', []); - $this->asRepo->expects($this->once()) - ->method('getStats') - ->willReturn($this->adminStatsFactory()); - $as->setRepository($this->asRepo); - $as->prepareStats(); - static::assertEquals( - [ - 'delete' => 5+1, - 'restore' => 3+0, - 'block' => 0+0, - 'unblock' => 1+0, - 'protect' => 3+0, - 'unprotect' => 2+0, - 'rights' => 4+0, - 'import' => 2+0, - 'total' => 20+0, - ], - $as->getTotalsRow() - ); - } +class AdminStatsTest extends TestAdapter { + protected AdminStatsRepository $asRepo; + protected Project $project; + protected ProjectRepository $projectRepo; + + /** + * Set up container, class instances and mocks. + */ + public function setUp(): void { + $this->project = $this->createMock( Project::class ); + $this->project->method( 'getUsersInGroups' ) + ->willReturn( [ + 'Bob' => [ 'sysop', 'checkuser' ], + 'Sarah' => [ 'epcoordinator' ], + ] ); + + $this->asRepo = $this->createMock( AdminStatsRepository::class ); + + // This logic is tested with integration tests. + // Here we just stub empty arrays so AdminStats won't error outl. + $this->asRepo->method( 'getUserGroups' ) + ->willReturn( [ 'local' => [], 'global' => [] ] ); + } + + /** + * Basic getters. + */ + public function testBasics(): void { + $startUTC = strtotime( '2017-01-01' ); + $endUTC = strtotime( '2017-03-01' ); + + $this->asRepo->expects( static::once() ) + ->method( 'getStats' ) + ->willReturn( $this->adminStatsFactory() ); + $this->asRepo->method( 'getRelevantUserGroup' ) + ->willReturn( 'sysop' ); + + // Single namespace, with defaults. + $as = new AdminStats( $this->asRepo, $this->project, $startUTC, $endUTC, 'admin', [] ); + + $as->prepareStats(); + + static::assertEquals( 1483228800, $as->getStart() ); + static::assertEquals( 1488326400, $as->getEnd() ); + static::assertEquals( 60, $as->numDays() ); + static::assertSame( 1, $as->getNumInRelevantUserGroup() ); + static::assertSame( 1, $as->getNumWithActionsNotInGroup() ); + } + + /** + * Getting admins and their relevant user groups. + */ + public function testAdminsAndGroups(): void { + $as = new AdminStats( $this->asRepo, $this->project, 0, 0, 'admin', [] ); + $this->asRepo->expects( $this->never() ) + ->method( 'getStats' ) + ->willReturn( $this->adminStatsFactory() ); + $as->setRepository( $this->asRepo ); + + static::assertEquals( + [ + 'Bob' => [ 'sysop', 'checkuser' ], + 'Sarah' => [ 'epcoordinator' ], + ], + $as->getUsersAndGroups() + ); + } + + /** + * Test preparation and getting of actual stats. + */ + public function testStats(): void { + $as = new AdminStats( $this->asRepo, $this->project, 0, 0, 'admin', [] ); + $this->asRepo->expects( $this->once() ) + ->method( 'getStats' ) + ->willReturn( $this->adminStatsFactory() ); + $as->setRepository( $this->asRepo ); + $ret = $as->prepareStats(); + + // Test results. + static::assertEquals( + [ + 'Bob' => array_merge( + $this->adminStatsFactory()[0], + [ 'user-groups' => [ 'sysop', 'checkuser' ] ] + ), + 'Sarah' => array_merge( + // empty results + $this->adminStatsFactory()[1], + [ 'username' => 'Sarah', 'user-groups' => [ 'epcoordinator' ] ] + ), + ], + $ret + ); + + // At this point get stats should be the same. + static::assertEquals( $ret, $as->getStats() ); + } + + /** + * Factory of what database will return. + */ + private function adminStatsFactory(): array { + return [ + [ + 'username' => 'Bob', + 'delete' => 5, + 'restore' => 3, + 'block' => 0, + 'unblock' => 1, + 'protect' => 3, + 'unprotect' => 2, + 'rights' => 4, + 'import' => 2, + 'total' => 20, + ], + [ + 'username' => 'Sarah', + 'delete' => 1, + 'restore' => 0, + 'block' => 0, + 'unblock' => 0, + 'protect' => 0, + 'unprotect' => 0, + 'rights' => 0, + 'import' => 0, + 'total' => 0, + ], + ]; + } + + /** + * Test construction of "totals" row + */ + public function testTotalsRow(): void { + $as = new AdminStats( $this->asRepo, $this->project, 0, 0, 'admin', [] ); + $this->asRepo->expects( $this->once() ) + ->method( 'getStats' ) + ->willReturn( $this->adminStatsFactory() ); + $as->setRepository( $this->asRepo ); + $as->prepareStats(); + static::assertEquals( + [ + 'delete' => 5 + 1, + 'restore' => 3 + 0, + 'block' => 0 + 0, + 'unblock' => 1 + 0, + 'protect' => 3 + 0, + 'unprotect' => 2 + 0, + 'rights' => 4 + 0, + 'import' => 2 + 0, + 'total' => 20 + 0, + ], + $as->getTotalsRow() + ); + } } diff --git a/tests/Model/AuthorshipTest.php b/tests/Model/AuthorshipTest.php index d99d8e019..0ad6a27e8 100644 --- a/tests/Model/AuthorshipTest.php +++ b/tests/Model/AuthorshipTest.php @@ -1,6 +1,6 @@ createMock(AuthorshipRepository::class); - $authorshipRepo->expects($this->once()) - ->method('getData') - ->willReturn([ - 'revisions' => [[ - '123' => [ - 'time' => '2018-04-16T13:51:11Z', - 'tokens' => [ - [ - 'editor' => '1', - 'str' => 'foo', - ], [ - 'editor' => '0|192.168.0.1', - 'str' => 'bar', - ], [ - 'editor' => '0|192.168.0.1', - 'str' => 'baz', - ], [ - 'editor' => '2', - 'str' => 'foobar', - ], - ], - ], - ]], - ]); - $authorshipRepo->expects($this->once()) - ->method('getUsernamesFromIds') - ->willReturn([ - ['user_id' => 1, 'user_name' => 'Mick Jagger'], - ['user_id' => 2, 'user_name' => 'Mr. Rogers'], - ]); - $project = new Project('test.example.org'); - $pageRepo = $this->createMock(PageRepository::class); - $page = new Page($pageRepo, $project, 'Test page'); - $authorship = new Authorship($authorshipRepo, $page, null, 2); - $authorship->prepareData(); +class AuthorshipTest extends TestAdapter { + /** + * Authorship stats from WhoColor API. + */ + public function testAuthorship(): void { + /** @var AuthorshipRepository|MockObject $authorshipRepo */ + $authorshipRepo = $this->createMock( AuthorshipRepository::class ); + $authorshipRepo->expects( $this->once() ) + ->method( 'getData' ) + ->willReturn( [ + 'revisions' => [ [ + '123' => [ + 'time' => '2018-04-16T13:51:11Z', + 'tokens' => [ + [ + 'editor' => '1', + 'str' => 'foo', + ], [ + 'editor' => '0|192.168.0.1', + 'str' => 'bar', + ], [ + 'editor' => '0|192.168.0.1', + 'str' => 'baz', + ], [ + 'editor' => '2', + 'str' => 'foobar', + ], + ], + ], + ] ], + ] ); + $authorshipRepo->expects( $this->once() ) + ->method( 'getUsernamesFromIds' ) + ->willReturn( [ + [ 'user_id' => 1, 'user_name' => 'Mick Jagger' ], + [ 'user_id' => 2, 'user_name' => 'Mr. Rogers' ], + ] ); + $project = new Project( 'test.example.org' ); + $pageRepo = $this->createMock( PageRepository::class ); + $page = new Page( $pageRepo, $project, 'Test page' ); + $authorship = new Authorship( $authorshipRepo, $page, null, 2 ); + $authorship->prepareData(); - static::assertEquals( - [ - 'Mr. Rogers' => [ - 'count' => 6, - 'percentage' => 40.0, - ], - '192.168.0.1' => [ - 'count' => 6, - 'percentage' => 40.0, - ], - ], - $authorship->getList() - ); + static::assertEquals( + [ + 'Mr. Rogers' => [ + 'count' => 6, + 'percentage' => 40.0, + ], + '192.168.0.1' => [ + 'count' => 6, + 'percentage' => 40.0, + ], + ], + $authorship->getList() + ); - static::assertEquals(3, $authorship->getTotalAuthors()); - static::assertEquals(15, $authorship->getTotalCount()); - static::assertEquals([ - 'count' => 3, - 'percentage' => 20.0, - 'numEditors' => 1, - ], $authorship->getOthers()); - } + static::assertEquals( 3, $authorship->getTotalAuthors() ); + static::assertEquals( 15, $authorship->getTotalCount() ); + static::assertEquals( [ + 'count' => 3, + 'percentage' => 20.0, + 'numEditors' => 1, + ], $authorship->getOthers() ); + } } diff --git a/tests/Model/AutoEditsTest.php b/tests/Model/AutoEditsTest.php index 4c7bc1b06..1adbe404c 100644 --- a/tests/Model/AutoEditsTest.php +++ b/tests/Model/AutoEditsTest.php @@ -1,6 +1,6 @@ aeRepo = $this->createMock(AutoEditsRepository::class); - $this->pageRepo = $this->createMock(PageRepository::class); - $this->editRepo = $this->createMock(EditRepository::class); - $this->userRepo = $this->createMock(UserRepository::class); - $this->project = new Project('test.example.org'); - $this->projectRepo = $this->getProjectRepo(); - $this->projectRepo->method('getMetadata') - ->willReturn(['namespaces' => [ - '0' => '', - '1' => 'Talk', - ]]); - $this->project->setRepository($this->projectRepo); - $this->user = new User($this->userRepo, 'Test user'); - } + /** + * Set up class instances and mocks. + */ + public function setUp(): void { + $this->aeRepo = $this->createMock( AutoEditsRepository::class ); + $this->pageRepo = $this->createMock( PageRepository::class ); + $this->editRepo = $this->createMock( EditRepository::class ); + $this->userRepo = $this->createMock( UserRepository::class ); + $this->project = new Project( 'test.example.org' ); + $this->projectRepo = $this->getProjectRepo(); + $this->projectRepo->method( 'getMetadata' ) + ->willReturn( [ 'namespaces' => [ + '0' => '', + '1' => 'Talk', + ] ] ); + $this->project->setRepository( $this->projectRepo ); + $this->user = new User( $this->userRepo, 'Test user' ); + } - /** - * The constructor. - */ - public function testConstructor(): void - { - $autoEdits = $this->getAutoEdits( - 1, - strtotime('2017-01-01'), - strtotime('2018-01-01'), - 'Twinkle', - 50 - ); + /** + * The constructor. + */ + public function testConstructor(): void { + $autoEdits = $this->getAutoEdits( + 1, + strtotime( '2017-01-01' ), + strtotime( '2018-01-01' ), + 'Twinkle', + 50 + ); - static::assertEquals(1, $autoEdits->getNamespace()); - static::assertEquals('2017-01-01', $autoEdits->getStartDate()); - static::assertEquals('2018-01-01', $autoEdits->getEndDate()); - static::assertEquals('Twinkle', $autoEdits->getTool()); - static::assertEquals(50, $autoEdits->getOffset()); - } + static::assertSame( 1, $autoEdits->getNamespace() ); + static::assertEquals( '2017-01-01', $autoEdits->getStartDate() ); + static::assertEquals( '2018-01-01', $autoEdits->getEndDate() ); + static::assertEquals( 'Twinkle', $autoEdits->getTool() ); + static::assertEquals( 50, $autoEdits->getOffset() ); + } - /** - * User's non-automated edits - */ - public function testGetNonAutomatedEdits(): void - { - $rev = [ - 'page_title' => 'Test_page', - 'namespace' => '0', - 'rev_id' => '123', - 'timestamp' => '20170101000000', - 'minor' => '0', - 'length' => '5', - 'length_change' => '-5', - 'comment' => 'Test', - ]; + /** + * User's non-automated edits + */ + public function testGetNonAutomatedEdits(): void { + $rev = [ + 'page_title' => 'Test_page', + 'namespace' => '0', + 'rev_id' => '123', + 'timestamp' => '20170101000000', + 'minor' => '0', + 'length' => '5', + 'length_change' => '-5', + 'comment' => 'Test', + ]; - $this->aeRepo->expects(static::once()) - ->method('getNonAutomatedEdits') - ->willReturn([$rev]); + $this->aeRepo->expects( static::once() ) + ->method( 'getNonAutomatedEdits' ) + ->willReturn( [ $rev ] ); - $autoEdits = $this->getAutoEdits(); - $rawEdits = $autoEdits->getNonAutomatedEdits(true); - static::assertSame([ - 'page_title' => 'Test page', - 'namespace' => 0, - 'username' => 'Test user', - 'rev_id' => 123, - 'timestamp' => '2017-01-01T00:00:00Z', - 'minor' => false, - 'length' => 5, - 'length_change' => -5, - 'comment' => 'Test', - ], $rawEdits[0]); + $autoEdits = $this->getAutoEdits(); + $rawEdits = $autoEdits->getNonAutomatedEdits( true ); + static::assertSame( [ + 'page_title' => 'Test page', + 'namespace' => 0, + 'username' => 'Test user', + 'rev_id' => 123, + 'timestamp' => '2017-01-01T00:00:00Z', + 'minor' => false, + 'length' => 5, + 'length_change' => -5, + 'comment' => 'Test', + ], $rawEdits[0] ); - $page = Page::newFromRow($this->pageRepo, $this->project, [ - 'page_title' => 'Test_page', - 'namespace' => 0, - 'length' => 5, - ]); - $edit = new Edit( - $this->editRepo, - $this->userRepo, - $page, - array_merge($rev, ['user' => $this->user]) - ); - static::assertEquals($edit, $autoEdits->getNonAutomatedEdits()[0]); + $page = Page::newFromRow( $this->pageRepo, $this->project, [ + 'page_title' => 'Test_page', + 'namespace' => 0, + 'length' => 5, + ] ); + $edit = new Edit( + $this->editRepo, + $this->userRepo, + $page, + array_merge( $rev, [ 'user' => $this->user ] ) + ); + static::assertEquals( $edit, $autoEdits->getNonAutomatedEdits()[0] ); - // One more time to ensure things are re-queried. - static::assertEquals($edit, $autoEdits->getNonAutomatedEdits()[0]); - } + // One more time to ensure things are re-queried. + static::assertEquals( $edit, $autoEdits->getNonAutomatedEdits()[0] ); + } - /** - * Test fetching the tools and counts. - */ - public function testToolCounts(): void - { - $toolCounts = [ - 'Twinkle' => [ - 'link' => 'Project:Twinkle', - 'label' => 'Twinkle', - 'count' => '13', - ], - 'HotCat' => [ - 'link' => 'Special:MyLanguage/Project:HotCat', - 'label' => 'HotCat', - 'count' => '5', - ], - ]; + /** + * Test fetching the tools and counts. + */ + public function testToolCounts(): void { + $toolCounts = [ + 'Twinkle' => [ + 'link' => 'Project:Twinkle', + 'label' => 'Twinkle', + 'count' => '13', + ], + 'HotCat' => [ + 'link' => 'Special:MyLanguage/Project:HotCat', + 'label' => 'HotCat', + 'count' => '5', + ], + ]; - $this->aeRepo->expects(static::once()) - ->method('getToolCounts') - ->willReturn($toolCounts); - $autoEdits = $this->getAutoEdits(); + $this->aeRepo->expects( static::once() ) + ->method( 'getToolCounts' ) + ->willReturn( $toolCounts ); + $autoEdits = $this->getAutoEdits(); - static::assertEquals($toolCounts, $autoEdits->getToolCounts()); - static::assertEquals(18, $autoEdits->getToolsTotal()); - } + static::assertEquals( $toolCounts, $autoEdits->getToolCounts() ); + static::assertEquals( 18, $autoEdits->getToolsTotal() ); + } - /** - * User's (semi-)automated edits - */ - public function testGetAutomatedEdits(): void - { - $rev = [ - 'page_title' => 'Test_page', - 'namespace' => '1', - 'rev_id' => '123', - 'timestamp' => '20170101000000', - 'minor' => '0', - 'length' => '5', - 'length_change' => '-5', - 'comment' => 'Test ([[WP:TW|TW]])', - ]; + /** + * User's (semi-)automated edits + */ + public function testGetAutomatedEdits(): void { + $rev = [ + 'page_title' => 'Test_page', + 'namespace' => '1', + 'rev_id' => '123', + 'timestamp' => '20170101000000', + 'minor' => '0', + 'length' => '5', + 'length_change' => '-5', + 'comment' => 'Test ([[WP:TW|TW]])', + ]; - $this->aeRepo->expects(static::once()) - ->method('getAutomatedEdits') - ->willReturn([$rev]); + $this->aeRepo->expects( static::once() ) + ->method( 'getAutomatedEdits' ) + ->willReturn( [ $rev ] ); - $autoEdits = $this->getAutoEdits(); - $editObjs = $autoEdits->getAutomatedEdits(true); - static::assertSame([ - 'page_title' => 'Test page', - 'namespace' => 1, - 'username' => 'Test user', - 'rev_id' => 123, - 'timestamp' => '2017-01-01T00:00:00Z', - 'minor' => false, - 'length' => 5, - 'length_change' => -5, - 'comment' => 'Test ([[WP:TW|TW]])', - ], $editObjs[0]); + $autoEdits = $this->getAutoEdits(); + $editObjs = $autoEdits->getAutomatedEdits( true ); + static::assertSame( [ + 'page_title' => 'Test page', + 'namespace' => 1, + 'username' => 'Test user', + 'rev_id' => 123, + 'timestamp' => '2017-01-01T00:00:00Z', + 'minor' => false, + 'length' => 5, + 'length_change' => -5, + 'comment' => 'Test ([[WP:TW|TW]])', + ], $editObjs[0] ); - $page = Page::newFromRow($this->pageRepo, $this->project, [ - 'page_title' => 'Test_page', - 'namespace' => 1, - 'length' => 5, - ]); - $edit = new Edit( - $this->editRepo, - $this->userRepo, - $page, - array_merge($rev, ['user' => $this->user]) - ); - static::assertEquals($edit, $autoEdits->getAutomatedEdits()[0]); + $page = Page::newFromRow( $this->pageRepo, $this->project, [ + 'page_title' => 'Test_page', + 'namespace' => 1, + 'length' => 5, + ] ); + $edit = new Edit( + $this->editRepo, + $this->userRepo, + $page, + array_merge( $rev, [ 'user' => $this->user ] ) + ); + static::assertEquals( $edit, $autoEdits->getAutomatedEdits()[0] ); - // One more time to ensure things are re-queried. - static::assertEquals($edit, $autoEdits->getAutomatedEdits()[0]); - } + // One more time to ensure things are re-queried. + static::assertEquals( $edit, $autoEdits->getAutomatedEdits()[0] ); + } - /** - * Counting non-automated edits. - */ - public function testCounts(): void - { - $this->aeRepo->expects(static::once()) - ->method('countAutomatedEdits') - ->willReturn(50); - /** @var MockObject|UserRepository $userRepo */ - $userRepo = $this->createMock(UserRepository::class); - $userRepo->expects(static::once()) - ->method('countEdits') - ->willReturn(200); - $this->user->setRepository($userRepo); + /** + * Counting non-automated edits. + */ + public function testCounts(): void { + $this->aeRepo->expects( static::once() ) + ->method( 'countAutomatedEdits' ) + ->willReturn( 50 ); + /** @var MockObject|UserRepository $userRepo */ + $userRepo = $this->createMock( UserRepository::class ); + $userRepo->expects( static::once() ) + ->method( 'countEdits' ) + ->willReturn( 200 ); + $this->user->setRepository( $userRepo ); - $autoEdits = $this->getAutoEdits(); - $autoEdits->setRepository($this->aeRepo); - static::assertEquals(50, $autoEdits->getAutomatedCount()); - static::assertEquals(200, $autoEdits->getEditCount()); - static::assertEquals(25, $autoEdits->getAutomatedPercentage()); + $autoEdits = $this->getAutoEdits(); + $autoEdits->setRepository( $this->aeRepo ); + static::assertEquals( 50, $autoEdits->getAutomatedCount() ); + static::assertEquals( 200, $autoEdits->getEditCount() ); + static::assertEquals( 25, $autoEdits->getAutomatedPercentage() ); - // Again to ensure they're not re-queried. - static::assertEquals(50, $autoEdits->getAutomatedCount()); - static::assertEquals(200, $autoEdits->getEditCount()); - static::assertEquals(25, $autoEdits->getAutomatedPercentage()); - } + // Again to ensure they're not re-queried. + static::assertEquals( 50, $autoEdits->getAutomatedCount() ); + static::assertEquals( 200, $autoEdits->getEditCount() ); + static::assertEquals( 25, $autoEdits->getAutomatedPercentage() ); + } - /** - * @param int|string $namespace Namespace ID or 'all' - * @param false|int $start Start date as Unix timestamp. - * @param false|int $end End date as Unix timestamp. - * @param null $tool The tool we're searching for when fetching (semi-)automated edits. - * @param false|int $offset Unix timestamp. Used for pagination. - * @param int|null $limit Number of results to return. - * @return AutoEdits - */ - private function getAutoEdits( - $namespace = 1, - $start = false, - $end = false, - $tool = null, - $offset = false, - ?int $limit = null - ): AutoEdits { - return new AutoEdits( - $this->aeRepo, - $this->editRepo, - $this->pageRepo, - $this->userRepo, - $this->project, - $this->user, - $namespace, - $start, - $end, - $tool, - $offset, - $limit - ); - } + /** + * @param int|string $namespace Namespace ID or 'all' + * @param false|int $start Start date as Unix timestamp. + * @param false|int $end End date as Unix timestamp. + * @param null $tool The tool we're searching for when fetching (semi-)automated edits. + * @param false|int $offset Unix timestamp. Used for pagination. + * @param int|null $limit Number of results to return. + * @return AutoEdits + */ + private function getAutoEdits( + $namespace = 1, + $start = false, + $end = false, + $tool = null, + $offset = false, + ?int $limit = null + ): AutoEdits { + return new AutoEdits( + $this->aeRepo, + $this->editRepo, + $this->pageRepo, + $this->userRepo, + $this->project, + $this->user, + $namespace, + $start, + $end, + $tool, + $offset, + $limit + ); + } - /** - * Tests the sandbox functionality, bypassing the cache. - * @todo Find a way to actually test that it bypasses the cache! - */ - public function testUseSandbox(): void - { - $this->aeRepo->expects(static::once()) - ->method('getUseSandbox') - ->willReturn(true); - $this->aeRepo->expects(static::never()) - ->method('setCache'); - $autoEdits = $this->getAutoEdits(); - $autoEdits->setRepository($this->aeRepo); + /** + * Tests the sandbox functionality, bypassing the cache. + * @todo Find a way to actually test that it bypasses the cache! + */ + public function testUseSandbox(): void { + $this->aeRepo->expects( static::once() ) + ->method( 'getUseSandbox' ) + ->willReturn( true ); + $this->aeRepo->expects( static::never() ) + ->method( 'setCache' ); + $autoEdits = $this->getAutoEdits(); + $autoEdits->setRepository( $this->aeRepo ); - static::assertTrue($autoEdits->getUseSandbox()); - } + static::assertTrue( $autoEdits->getUseSandbox() ); + } } diff --git a/tests/Model/BlameTest.php b/tests/Model/BlameTest.php index f0d5bac8a..b64bc6b11 100644 --- a/tests/Model/BlameTest.php +++ b/tests/Model/BlameTest.php @@ -1,6 +1,6 @@ project = new Project('test.example.org'); - $pageRepo = $this->createMock(PageRepository::class); - $this->page = new Page($pageRepo, $this->project, 'Test page'); - $this->blameRepo = $this->createMock(BlameRepository::class); - } + /** + * Set up shared mocks and class instances. + */ + public function setUp(): void { + $this->project = new Project( 'test.example.org' ); + $pageRepo = $this->createMock( PageRepository::class ); + $this->page = new Page( $pageRepo, $this->project, 'Test page' ); + $this->blameRepo = $this->createMock( BlameRepository::class ); + } - /** - * @covers \App\Model\Blame::getQuery - * @covers \App\Model\Blame::getTokenizedQuery - */ - public function testBasics(): void - { - $blame = new Blame($this->blameRepo, $this->page, "Foo bar\nBAZ"); - static::assertEquals("Foo bar\nBAZ", $blame->getQuery()); - static::assertEquals('foobarbaz', $blame->getTokenizedQuery()); - } + /** + * @covers \App\Model\Blame::getQuery + * @covers \App\Model\Blame::getTokenizedQuery + */ + public function testBasics(): void { + $blame = new Blame( $this->blameRepo, $this->page, "Foo bar\nBAZ" ); + static::assertEquals( "Foo bar\nBAZ", $blame->getQuery() ); + static::assertEquals( 'foobarbaz', $blame->getTokenizedQuery() ); + } - /** - * @covers \App\Model\Blame::prepareData - * @covers \App\Model\Blame::searchTokens - */ - public function testPrepareData(): void - { - $this->blameRepo->expects($this->once()) - ->method('getData') - ->willReturn([ - 'revisions' => [[ - '123' => [ - 'time' => '2018-04-16T13:51:11Z', - 'tokens' => [ - [ - 'o_rev_id' => 1, - 'editor' => 'MusikAnimal', - 'str' => 'loremfoo', - ], [ - 'o_rev_id' => 1, - 'editor' => 'MusikAnimal', - 'str' => 'bar', - ], [ - 'o_rev_id' => 2, - 'editor' => '0|192.168.0.1', - 'str' => 'baz', - ], [ - 'o_rev_id' => 3, - 'editor' => 'Matthewrbowker', - 'str' => 'foobar', - ], - ], - ], - ]], - ]); - $this->blameRepo->expects($this->exactly(2)) - ->method('getEditFromRevId') - ->willReturn($this->createMock('App\Model\Edit')); + /** + * @covers \App\Model\Blame::prepareData + * @covers \App\Model\Blame::searchTokens + */ + public function testPrepareData(): void { + $this->blameRepo->expects( $this->once() ) + ->method( 'getData' ) + ->willReturn( [ + 'revisions' => [ [ + '123' => [ + 'time' => '2018-04-16T13:51:11Z', + 'tokens' => [ + [ + 'o_rev_id' => 1, + 'editor' => 'MusikAnimal', + 'str' => 'loremfoo', + ], [ + 'o_rev_id' => 1, + 'editor' => 'MusikAnimal', + 'str' => 'bar', + ], [ + 'o_rev_id' => 2, + 'editor' => '0|192.168.0.1', + 'str' => 'baz', + ], [ + 'o_rev_id' => 3, + 'editor' => 'Matthewrbowker', + 'str' => 'foobar', + ], + ], + ], + ] ], + ] ); + $this->blameRepo->expects( $this->exactly( 2 ) ) + ->method( 'getEditFromRevId' ) + ->willReturn( $this->createMock( 'App\Model\Edit' ) ); - $blame = new Blame($this->blameRepo, $this->page, 'Foo bar'); - $blame->prepareData(); - $matches = $blame->getMatches(); + $blame = new Blame( $this->blameRepo, $this->page, 'Foo bar' ); + $blame->prepareData(); + $matches = $blame->getMatches(); - static::assertCount(2, $matches); - static::assertEquals([3, 1], array_keys($matches)); - } + static::assertCount( 2, $matches ); + static::assertEquals( [ 3, 1 ], array_keys( $matches ) ); + } } diff --git a/tests/Model/CategoryEditsTest.php b/tests/Model/CategoryEditsTest.php index de4be7ff3..08772101b 100644 --- a/tests/Model/CategoryEditsTest.php +++ b/tests/Model/CategoryEditsTest.php @@ -1,6 +1,6 @@ project = $this->createMock(Project::class); - $this->project->method('getNamespaces') - ->willReturn([ - 0 => '', - 1 => 'Talk', - ]); - $this->userRepo = $this->createMock(UserRepository::class); - $this->userRepo->method('countEdits') - ->willReturn(500); - $this->user = new User($this->userRepo, 'Test user'); - - $this->ceRepo = $this->createMock(CategoryEditsRepository::class); - $this->editRepo = $this->createMock(EditRepository::class); - $this->pageRepo = $this->createMock(PageRepository::class); - $this->ce = new CategoryEdits( - $this->ceRepo, - $this->project, - $this->user, - ['Living_people', 'Musicians_from_New_York_City'], - strtotime('2017-01-01'), - strtotime('2017-02-01'), - 50 - ); - } - - /** - * Basic getters. - */ - public function testBasics(): void - { - static::assertEquals('2017-01-01', $this->ce->getStartDate()); - static::assertEquals('2017-02-01', $this->ce->getEndDate()); - static::assertEquals(50, $this->ce->getOffset()); - static::assertEquals( - ['Living_people', 'Musicians_from_New_York_City'], - $this->ce->getCategories() - ); - static::assertEquals( - 'Living_people|Musicians_from_New_York_City', - $this->ce->getCategoriesPiped() - ); - static::assertEquals( - ['Living people', 'Musicians from New York City'], - $this->ce->getCategoriesNormalized() - ); - } - - /** - * Methods around counting edits in category. - */ - public function testCategoryCounts(): void - { - $this->ceRepo->expects($this->once()) - ->method('countCategoryEdits') - ->willReturn(200); - $this->ceRepo->expects($this->once()) - ->method('getCategoryCounts') - ->willReturn([ - 'Living_people' => 150, - 'Musicians_from_New_York_City' => 50, - ]); - $this->ce->setRepository($this->ceRepo); - - static::assertEquals(500, $this->ce->getEditCount()); - static::assertEquals(200, $this->ce->getCategoryEditCount()); - static::assertEquals(40.0, $this->ce->getCategoryPercentage()); - static::assertEquals( - ['Living_people' => 150, 'Musicians_from_New_York_City' => 50], - $this->ce->getCategoryCounts() - ); - - // Shouldn't call the repo method again (asserted by the $this->once() above). - $this->ce->getCategoryCounts(); - } - - /** - * Category edits. - */ - public function testCategoryEdits(): void - { - $revs = [ - [ - 'page_title' => 'Test_page', - 'namespace' => '1', - 'rev_id' => '123', - 'timestamp' => '20170103000000', - 'minor' => '0', - 'length' => '5', - 'length_change' => '-5', - 'comment' => 'Test', - ], - [ - 'page_title' => 'Foo_bar', - 'namespace' => '0', - 'rev_id' => '321', - 'timestamp' => '20170115000000', - 'minor' => '1', - 'length' => '10', - 'length_change' => '5', - 'comment' => 'Weeee', - ], - ]; - - $pages = [ - Page::newFromRow($this->pageRepo, $this->project, [ - 'page_title' => 'Test_page', - 'namespace' => 1, - ]), - Page::newFromRow($this->pageRepo, $this->project, [ - 'page_title' => 'Foo_bar', - 'namespace' => 0, - ]), - ]; - - $edits = [ - new Edit($this->editRepo, $this->userRepo, $pages[0], array_merge($revs[0], ['user' => $this->user])), - new Edit($this->editRepo, $this->userRepo, $pages[1], array_merge($revs[1], ['user' => $this->user])), - ]; - - $this->ceRepo->expects($this->exactly(2)) - ->method('getCategoryEdits') - ->willReturn($revs); - $this->ceRepo->expects($this->once()) - ->method('getEditsFromRevs') - ->willReturn($edits); - - static::assertEquals($revs, $this->ce->getCategoryEdits(true)); - static::assertEquals($edits, $this->ce->getCategoryEdits()); - - // Shouldn't call the repo method again (asserted by the ->exactly(2) above). - $this->ce->getCategoryEdits(); - } +class CategoryEditsTest extends TestAdapter { + protected CategoryEdits $ce; + protected CategoryEditsRepository $ceRepo; + protected EditRepository $editRepo; + protected PageRepository $pageRepo; + protected Project $project; + protected User $user; + protected UserRepository $userRepo; + + /** + * Set up class instances and mocks. + */ + public function setUp(): void { + $this->project = $this->createMock( Project::class ); + $this->project->method( 'getNamespaces' ) + ->willReturn( [ + 0 => '', + 1 => 'Talk', + ] ); + $this->userRepo = $this->createMock( UserRepository::class ); + $this->userRepo->method( 'countEdits' ) + ->willReturn( 500 ); + $this->user = new User( $this->userRepo, 'Test user' ); + + $this->ceRepo = $this->createMock( CategoryEditsRepository::class ); + $this->editRepo = $this->createMock( EditRepository::class ); + $this->pageRepo = $this->createMock( PageRepository::class ); + $this->ce = new CategoryEdits( + $this->ceRepo, + $this->project, + $this->user, + [ 'Living_people', 'Musicians_from_New_York_City' ], + strtotime( '2017-01-01' ), + strtotime( '2017-02-01' ), + 50 + ); + } + + /** + * Basic getters. + */ + public function testBasics(): void { + static::assertEquals( '2017-01-01', $this->ce->getStartDate() ); + static::assertEquals( '2017-02-01', $this->ce->getEndDate() ); + static::assertEquals( 50, $this->ce->getOffset() ); + static::assertEquals( + [ 'Living_people', 'Musicians_from_New_York_City' ], + $this->ce->getCategories() + ); + static::assertEquals( + 'Living_people|Musicians_from_New_York_City', + $this->ce->getCategoriesPiped() + ); + static::assertEquals( + [ 'Living people', 'Musicians from New York City' ], + $this->ce->getCategoriesNormalized() + ); + } + + /** + * Methods around counting edits in category. + */ + public function testCategoryCounts(): void { + $this->ceRepo->expects( $this->once() ) + ->method( 'countCategoryEdits' ) + ->willReturn( 200 ); + $this->ceRepo->expects( $this->once() ) + ->method( 'getCategoryCounts' ) + ->willReturn( [ + 'Living_people' => 150, + 'Musicians_from_New_York_City' => 50, + ] ); + $this->ce->setRepository( $this->ceRepo ); + + static::assertEquals( 500, $this->ce->getEditCount() ); + static::assertEquals( 200, $this->ce->getCategoryEditCount() ); + static::assertEquals( 40.0, $this->ce->getCategoryPercentage() ); + static::assertEquals( + [ 'Living_people' => 150, 'Musicians_from_New_York_City' => 50 ], + $this->ce->getCategoryCounts() + ); + + // Shouldn't call the repo method again (asserted by the $this->once() above). + $this->ce->getCategoryCounts(); + } + + /** + * Category edits. + */ + public function testCategoryEdits(): void { + $revs = [ + [ + 'page_title' => 'Test_page', + 'namespace' => '1', + 'rev_id' => '123', + 'timestamp' => '20170103000000', + 'minor' => '0', + 'length' => '5', + 'length_change' => '-5', + 'comment' => 'Test', + ], + [ + 'page_title' => 'Foo_bar', + 'namespace' => '0', + 'rev_id' => '321', + 'timestamp' => '20170115000000', + 'minor' => '1', + 'length' => '10', + 'length_change' => '5', + 'comment' => 'Weeee', + ], + ]; + + $pages = [ + Page::newFromRow( $this->pageRepo, $this->project, [ + 'page_title' => 'Test_page', + 'namespace' => 1, + ] ), + Page::newFromRow( $this->pageRepo, $this->project, [ + 'page_title' => 'Foo_bar', + 'namespace' => 0, + ] ), + ]; + + $edits = [ + new Edit( $this->editRepo, $this->userRepo, $pages[0], array_merge( $revs[0], [ 'user' => $this->user ] ) ), + new Edit( $this->editRepo, $this->userRepo, $pages[1], array_merge( $revs[1], [ 'user' => $this->user ] ) ), + ]; + + $this->ceRepo->expects( $this->exactly( 2 ) ) + ->method( 'getCategoryEdits' ) + ->willReturn( $revs ); + $this->ceRepo->expects( $this->once() ) + ->method( 'getEditsFromRevs' ) + ->willReturn( $edits ); + + static::assertEquals( $revs, $this->ce->getCategoryEdits( true ) ); + static::assertEquals( $edits, $this->ce->getCategoryEdits() ); + + // Shouldn't call the repo method again (asserted by the ->exactly(2) above). + $this->ce->getCategoryEdits(); + } } diff --git a/tests/Model/EditCounterTest.php b/tests/Model/EditCounterTest.php index e9f007ee0..aaa04c778 100644 --- a/tests/Model/EditCounterTest.php +++ b/tests/Model/EditCounterTest.php @@ -1,6 +1,6 @@ createSession(static::createClient()); - $this->i18n = new I18nHelper( - $this->getRequestStack($session), - static::getContainer()->getParameter('kernel.project_dir') - ); - - $this->editCounterRepo = $this->createMock(EditCounterRepository::class); - $this->projectRepo = $this->getProjectRepo(); - $this->project = new Project('test.example.org'); - $this->project->setRepository($this->projectRepo); - - $this->userRepo = $this->createMock(UserRepository::class); - $this->user = new User($this->userRepo, 'Testuser'); - - $this->editCounter = new EditCounter( - $this->editCounterRepo, - $this->i18n, - $this->createMock(UserRights::class), - $this->project, - $this->user, - $this->createMock(AutomatedEditsHelper::class) - ); - $this->editCounter->setRepository($this->editCounterRepo); - } - - /** - * Log counts and associated getters. - */ - public function testLogCounts(): void - { - static::assertInstanceOf(UserRights::class, $this->editCounter->getUserRights()); - $this->editCounterRepo->expects(static::once()) - ->method('getLogCounts') - ->willReturn([ - 'delete-delete' => 0, - 'move-move' => 1, - 'block-block' => 2, - 'block-reblock' => 3, - 'block-unblock' => 4, - // intentionally does not include 'protect-protect' - 'protect-modify' => 5, - 'protect-unprotect' => 6, - 'delete-revision' => 7, - 'upload-upload' => 8, - // intentionally does not include 'delete-event' - 'rights-rights' => 9, - 'abusefilter-modify' => 10, - 'thanks-thank' => 11, - 'patrol-patrol' => 12, - 'merge-merge' => 13, - // Imports should add up to 6 - 'import-import' => 1, - 'import-interwiki' => 2, - 'import-upload' => 3, - // Content model changes, sum 3 - 'contentmodel-new' => 1, - 'contentmodel-change' => 2, - // Review approvals, sum 10 - 'review-approve' => 1, - 'review-approve2' => 2, - 'review-approve-i' => 3, - 'review-approve2-i' => 4, - // Account creation, sum 3 - 'newusers-create2' => 1, - 'newusers-byemail' => 2, - // PageTriage reviews, sum 9 - 'pagetriage-curation-reviewed' => 2, - 'pagetriage-curation-reviewed-article' => 3, - 'pagetriage-curation-reviewed-redirect' => 4, - ]); - static::assertEquals(0, $this->editCounter->getLogCounts()['delete-delete']); - static::assertEquals(0, $this->editCounter->countPagesDeleted()); - static::assertEquals(1, $this->editCounter->countPagesMoved()); - static::assertEquals(2, $this->editCounter->countBlocksSet()); - static::assertEquals(3, $this->editCounter->countReblocksSet()); - static::assertEquals(4, $this->editCounter->countUnblocksSet()); - static::assertEquals(0, $this->editCounter->countPagesProtected()); - static::assertEquals(5, $this->editCounter->countPagesReprotected()); - static::assertEquals(6, $this->editCounter->countPagesUnprotected()); - static::assertEquals(7, $this->editCounter->countEditsDeleted()); - static::assertEquals(8, $this->editCounter->countFilesUploaded()); - static::assertEquals(0, $this->editCounter->countLogsDeleted()); - static::assertEquals(9, $this->editCounter->countRightsModified()); - static::assertEquals(10, $this->editCounter->countAbuseFilterChanges()); - static::assertEquals(11, $this->editCounter->thanks()); - static::assertEquals(12, $this->editCounter->patrols()); - static::assertEquals(13, $this->editCounter->merges()); - static::assertEquals(6, $this->editCounter->countPagesImported()); - static::assertEquals(3, $this->editCounter->countContentModelChanges()); - static::assertEquals(10, $this->editCounter->approvals()); - static::assertEquals(3, $this->editCounter->accountsCreated()); - static::assertEquals(9, $this->editCounter->reviews()); - } - - /** - * Get counts of revisions: deleted, not-deleted, total, and edit summary usage. - */ - public function testLiveAndDeletedEdits(): void - { - $this->editCounterRepo->expects(static::once()) - ->method('getPairData') - ->willReturn([ - 'deleted' => 10, - 'live' => 100, - 'with_comments' => 75, - 'minor' => 5, - 'day' => 10, - 'week' => 15, - ]); - - static::assertEquals(100, $this->editCounter->countLiveRevisions()); - static::assertEquals(10, $this->editCounter->countDeletedRevisions()); - static::assertEquals(110, $this->editCounter->countAllRevisions()); - static::assertEquals(100, $this->editCounter->countLast5000()); - static::assertEquals(5, $this->editCounter->countMinorRevisions()); - static::assertEquals(10, $this->editCounter->countRevisionsInLast('day')); - static::assertEquals(15, $this->editCounter->countRevisionsInLast('week')); - } - - /** - * A first and last actions, and number of days between. - */ - public function testFirstLastActions(): void - { - $this->editCounterRepo->expects(static::once())->method('getFirstAndLatestActions')->willReturn([ - 'rev_first' => [ - 'id' => 123, - 'timestamp' => '20170510100000', - 'type' => null, - ], - 'rev_latest' => [ - 'id' => 321, - 'timestamp' => '20170515150000', - 'type' => null, - ], - 'log_latest' => [ - 'id' => 456, - 'timestamp' => '20170510150000', - 'type' => 'thanks', - ], - ]); - static::assertEquals( - [ - 'id' => 123, - 'timestamp' => '20170510100000', - 'type' => null, - ], - $this->editCounter->getFirstAndLatestActions()['rev_first'] - ); - static::assertEquals( - [ - 'id' => 321, - 'timestamp' => '20170515150000', - 'type' => null, - ], - $this->editCounter->getFirstAndLatestActions()['rev_latest'] - ); - static::assertEquals(5, $this->editCounter->getDays()); - } - - /** - * Test that page counts are reported correctly. - */ - public function testPageCounts(): void - { - $this->editCounterRepo->expects(static::once()) - ->method('getPairData') - ->willReturn([ - 'edited-live' => 3, - 'edited-deleted' => 1, - 'created-live' => 6, - 'created-deleted' => 2, - ]); - - static::assertEquals(3, $this->editCounter->countLivePagesEdited()); - static::assertEquals(1, $this->editCounter->countDeletedPagesEdited()); - static::assertEquals(4, $this->editCounter->countAllPagesEdited()); - - static::assertEquals(6, $this->editCounter->countCreatedPagesLive()); - static::assertEquals(2, $this->editCounter->countPagesCreatedDeleted()); - static::assertEquals(8, $this->editCounter->countPagesCreated()); - } - - /** - * Test that namespace totals are reported correctly. - */ - public function testNamespaceTotals(): void - { - $namespaceTotals = [ - // Namespace IDs => Edit counts - '1' => '3', - '2' => '6', - '3' => '9', - '4' => '12', - ]; - $this->editCounterRepo->expects(static::once()) - ->method('getNamespaceTotals') - ->willReturn($namespaceTotals); - - static::assertEquals($namespaceTotals, $this->editCounter->namespaceTotals()); - static::assertEquals(30, $this->editCounter->liveRevisionsFromNamespaces()); - } - - /** - * Test that month counts are properly put together. - */ - public function testMonthCounts(): void - { - $mockTime = new DateTime('2017-04-30 23:59:59'); - - $this->editCounterRepo->expects(static::once()) - ->method('getMonthCounts') - ->willReturn([ - [ - 'year' => '2016', - 'month' => '12', - 'namespace' => '0', - 'count' => '10', - ], - [ - 'year' => '2017', - 'month' => '3', - 'namespace' => '0', - 'count' => '20', - ], - [ - 'year' => '2017', - 'month' => '2', - 'namespace' => '1', - 'count' => '50', - ], - ]); - - // Mock current time by passing it in (dummy parameter, so to speak). - $monthCounts = $this->editCounter->monthCounts($mockTime); - - // Make sure zeros were filled in for months with no edits, and for each namespace. - static::assertArraySubset( - [ - '2017-01' => 0, - '2017-02' => 0, - '2017-03' => 20, - '2017-04' => 0, - ], - $monthCounts['totals'][0] - ); - static::assertArraySubset( - [ - '2016-12' => 0, - ], - $monthCounts['totals'][1] - ); - - // Assert only active months are reported. - static::assertArrayNotHasKey('2016-11', $monthCounts['totals'][0]); - static::assertArrayHasKey('2016-12', $monthCounts['totals'][0]); - static::assertArrayHasKey('2017-04', $monthCounts['totals'][0]); - static::assertArrayNotHasKey('2017-05', $monthCounts['totals'][0]); - - // Assert that only active namespaces are reported. - static::assertSame([0, 1], array_keys($monthCounts['totals'])); - - // Labels for the months - static::assertSame( - ['2016-12', '2017-01', '2017-02', '2017-03', '2017-04'], - $monthCounts['monthLabels'] - ); - - // Labels for the years - static::assertSame(['2016', '2017'], $monthCounts['yearLabels']); - - // Month counts by namespace. - $monthsWithNamespaces = $this->editCounter->monthCountsWithNamespaces($mockTime); - static::assertSame( - $monthCounts['monthLabels'], - array_keys($monthsWithNamespaces) - ); - static::assertSame([0, 1], array_keys($monthsWithNamespaces['2017-03'])); - - $yearTotals = $this->editCounter->yearTotals($mockTime); - static::assertSame(['2016' => 10, '2017' => 70], $yearTotals); - } - - /** - * Test that year counts are properly put together. - */ - public function testYearCounts(): void - { - $this->editCounterRepo->expects(static::once()) - ->method('getMonthCounts') - ->willReturn([ - [ - 'year' => '2015', - 'month' => '6', - 'namespace' => '1', - 'count' => '5', - ], - [ - 'year' => '2016', - 'month' => '12', - 'namespace' => '0', - 'count' => '10', - ], - [ - 'year' => '2017', - 'month' => '3', - 'namespace' => '0', - 'count' => '20', - ], - [ - 'year' => '2017', - 'month' => '2', - 'namespace' => '1', - 'count' => '50', - ], - ]); - - // Mock current time by passing it in (dummy parameter, so to speak). - $yearCounts = $this->editCounter->yearCounts(new DateTime('2017-04-30 23:59:59')); - - // Make sure zeros were filled in for months with no edits, and for each namespace. - static::assertArraySubset( - [ - 2015 => 0, - 2016 => 10, - 2017 => 20, - ], - $yearCounts['totals'][0] - ); - static::assertArraySubset( - [ - 2015 => 5, - 2016 => 0, - 2017 => 50, - ], - $yearCounts['totals'][1] - ); - - // Assert that only active years are reported - static::assertEquals([2015, 2016, 2017], array_keys($yearCounts['totals'][0])); - - // Assert that only active namespaces are reported. - static::assertEquals([0, 1], array_keys($yearCounts['totals'])); - - // Labels for the years - static::assertEquals(['2015', '2016', '2017'], $yearCounts['yearLabels']); - } - - /** - * Ensure parsing of log_params properly works, based on known formats - * @dataProvider longestBlockProvider - * @param array $blockLog - * @param int $longestDuration - */ - public function testLongestBlockSeconds(array $blockLog, int $longestDuration): void - { - $this->editCounterRepo->expects(static::once()) - ->method('getBlocksReceived') - ->with($this->project, $this->user) - ->willReturn($blockLog); - static::assertEquals($this->editCounter->getLongestBlockSeconds(), $longestDuration); - } - - /** - * Data for self::testLongestBlockSeconds(). - * @return string[] - */ - public function longestBlockProvider(): array - { - return [ - // Blocks that don't overlap, longest was 31 days. - [ - [[ - 'log_timestamp' => '20170101000000', - 'log_params' => 'a:2:{s:11:"5::duration";s:8:"72 hours"' . - ';s:8:"6::flags";s:8:"nocreate";}', - 'log_action' => 'block', - ], - [ - 'log_timestamp' => '20170301000000', - 'log_params' => 'a:2:{s:11:"5::duration";s:7:"1 month"' . - ';s:8:"6::flags";s:11:"noautoblock";}', - 'log_action' => 'block', - ]], - 2678400, // 31 days in seconds. - ], - // Blocks that do overlap, without any unblocks. Combined 10 days. - [ - [[ - 'log_timestamp' => '20170101000000', - 'log_params' => 'a:2:{s:11:"5::duration";s:7:"1 month"' . - ';s:8:"6::flags";s:8:"nocreate";}', - 'log_action' => 'block', - ], - [ - 'log_timestamp' => '20170110000000', - 'log_params' => 'a:2:{s:11:"5::duration";s:8:"24 hours"' . - ';s:8:"6::flags";s:11:"noautoblock";}', - 'log_action' => 'reblock', - ]], - 864000, // 10 days in seconds. - ], - // 30 day block that was later unblocked at only 10 days, followed by a shorter block. - [ - [[ - 'log_timestamp' => '20170101000000', - 'log_params' => 'a:2:{s:11:"5::duration";s:7:"1 month"' . - ';s:8:"6::flags";s:8:"nocreate";}', - 'log_action' => 'block', - ], - [ - 'log_timestamp' => '20170111000000', - 'log_params' => 'a:0:{}', - 'log_action' => 'unblock', - ], - [ - 'log_timestamp' => '20170201000000', - 'log_params' => 'a:2:{s:11:"5::duration";s:8:"24 hours"' . - ';s:8:"6::flags";s:11:"noautoblock";}', - 'log_action' => 'block', - ]], - 864000, // 10 days in seconds. - ], - // Blocks ending with a still active indefinite block. Older block uses legacy format. - [ - [[ - 'log_timestamp' => '20170101000000', - 'log_params' => "1 month\nnoautoblock", - 'log_action' => 'block', - ], - [ - 'log_timestamp' => '20170301000000', - 'log_params' => 'a:2:{s:11:"5::duration";s:10:"indefinite"' . - ';s:8:"6::flags";s:11:"noautoblock";}', - 'log_action' => 'block', - ]], - -1, // Indefinite - ], - // Block that's active, with an explicit expiry set. - [ - [[ - 'log_timestamp' => '20170927203624', - 'log_params' => 'a:2:{s:11:"5::duration";s:29:"Sat, 06 Oct 2026 12:36:00 GMT"' . - ';s:8:"6::flags";s:11:"noautoblock";}', - 'log_action' => 'block', - ]], - 285091176, - ], - // Two indefinite blocks. - [ - [[ - 'log_timestamp' => '20160513200200', - 'log_params' => 'a:2:{s:11:"5::duration";s:10:"indefinite"' . - ';s:8:"6::flags";s:19:"nocreate,nousertalk";}', - 'log_action' => 'block', - ], - [ - 'log_timestamp' => '20160717021328', - 'log_params' => 'a:2:{s:11:"5::duration";s:8:"infinite"' . - ';s:8:"6::flags";s:31:"nocreate,noautoblock,nousertalk";}', - 'log_action' => 'reblock', - ]], - -1, - ], - ]; - } - - /** - * Parsing block log entries. - * @dataProvider blockLogProvider - * @param array $logEntry - * @param array $assertion - */ - public function testParseBlockLogEntry(array $logEntry, array $assertion): void - { - static::assertEquals( - $this->editCounter->parseBlockLogEntry($logEntry), - $assertion - ); - } - - /** - * Data for self::testParseBlockLogEntry(). - * @return array - */ - public function blockLogProvider(): array - { - return [ - [ - [ - 'log_timestamp' => '20170701000000', - 'log_params' => 'a:2:{s:11:"5::duration";s:7:"60 days";' . - 's:8:"6::flags";s:8:"nocreate";}', - ], - [1498867200, 5184000], - ], - [ - [ - 'log_timestamp' => '20170101000000', - 'log_params' => "9 weeks\nnoautoblock", - ], - [1483228800, 5443200], - ], - [ - [ - 'log_timestamp' => '20170101000000', - 'log_params' => "invalid format", - ], - [1483228800, null], - ], - [ - [ - 'log_timestamp' => '20170101000000', - 'log_params' => "infinity\nnocreate", - ], - [1483228800, -1], - ], - [ - [ - 'log_timestamp' => '20170927203205', - 'log_params' => 'a:2:{s:11:"5::duration";s:19:"2017-09-30 12:36 PM";' . - 's:8:"6::flags";s:11:"noautoblock";}', - ], - [1506544325, 230635], - ], - ]; - } +class EditCounterTest extends TestAdapter { + use ArraySubsetAsserts; + use SessionHelper; + + protected EditCounter $editCounter; + protected EditCounterRepository $editCounterRepo; + protected I18nHelper $i18n; + protected Project $project; + protected ProjectRepository $projectRepo; + protected User $user; + protected UserRepository $userRepo; + + /** + * Set up shared mocks and class instances. + */ + public function setUp(): void { + $session = $this->createSession( static::createClient() ); + $this->i18n = new I18nHelper( + $this->getRequestStack( $session ), + static::getContainer()->getParameter( 'kernel.project_dir' ) + ); + + $this->editCounterRepo = $this->createMock( EditCounterRepository::class ); + $this->projectRepo = $this->getProjectRepo(); + $this->project = new Project( 'test.example.org' ); + $this->project->setRepository( $this->projectRepo ); + + $this->userRepo = $this->createMock( UserRepository::class ); + $this->user = new User( $this->userRepo, 'Testuser' ); + + $this->editCounter = new EditCounter( + $this->editCounterRepo, + $this->i18n, + $this->createMock( UserRights::class ), + $this->project, + $this->user, + $this->createMock( AutomatedEditsHelper::class ) + ); + $this->editCounter->setRepository( $this->editCounterRepo ); + } + + /** + * Log counts and associated getters. + */ + public function testLogCounts(): void { + static::assertInstanceOf( UserRights::class, $this->editCounter->getUserRights() ); + $this->editCounterRepo->expects( static::once() ) + ->method( 'getLogCounts' ) + ->willReturn( [ + 'delete-delete' => 0, + 'move-move' => 1, + 'block-block' => 2, + 'block-reblock' => 3, + 'block-unblock' => 4, + // intentionally does not include 'protect-protect' + 'protect-modify' => 5, + 'protect-unprotect' => 6, + 'delete-revision' => 7, + 'upload-upload' => 8, + // intentionally does not include 'delete-event' + 'rights-rights' => 9, + 'abusefilter-modify' => 10, + 'thanks-thank' => 11, + 'patrol-patrol' => 12, + 'merge-merge' => 13, + // Imports should add up to 6 + 'import-import' => 1, + 'import-interwiki' => 2, + 'import-upload' => 3, + // Content model changes, sum 3 + 'contentmodel-new' => 1, + 'contentmodel-change' => 2, + // Review approvals, sum 10 + 'review-approve' => 1, + 'review-approve2' => 2, + 'review-approve-i' => 3, + 'review-approve2-i' => 4, + // Account creation, sum 3 + 'newusers-create2' => 1, + 'newusers-byemail' => 2, + // PageTriage reviews, sum 9 + 'pagetriage-curation-reviewed' => 2, + 'pagetriage-curation-reviewed-article' => 3, + 'pagetriage-curation-reviewed-redirect' => 4, + ] ); + static::assertSame( 0, $this->editCounter->getLogCounts()['delete-delete'] ); + static::assertSame( 0, $this->editCounter->countPagesDeleted() ); + static::assertSame( 1, $this->editCounter->countPagesMoved() ); + static::assertEquals( 2, $this->editCounter->countBlocksSet() ); + static::assertEquals( 3, $this->editCounter->countReblocksSet() ); + static::assertEquals( 4, $this->editCounter->countUnblocksSet() ); + static::assertSame( 0, $this->editCounter->countPagesProtected() ); + static::assertEquals( 5, $this->editCounter->countPagesReprotected() ); + static::assertEquals( 6, $this->editCounter->countPagesUnprotected() ); + static::assertEquals( 7, $this->editCounter->countEditsDeleted() ); + static::assertEquals( 8, $this->editCounter->countFilesUploaded() ); + static::assertSame( 0, $this->editCounter->countLogsDeleted() ); + static::assertEquals( 9, $this->editCounter->countRightsModified() ); + static::assertEquals( 10, $this->editCounter->countAbuseFilterChanges() ); + static::assertEquals( 11, $this->editCounter->thanks() ); + static::assertEquals( 12, $this->editCounter->patrols() ); + static::assertEquals( 13, $this->editCounter->merges() ); + static::assertEquals( 6, $this->editCounter->countPagesImported() ); + static::assertEquals( 3, $this->editCounter->countContentModelChanges() ); + static::assertEquals( 10, $this->editCounter->approvals() ); + static::assertEquals( 3, $this->editCounter->accountsCreated() ); + static::assertEquals( 9, $this->editCounter->reviews() ); + } + + /** + * Get counts of revisions: deleted, not-deleted, total, and edit summary usage. + */ + public function testLiveAndDeletedEdits(): void { + $this->editCounterRepo->expects( static::once() ) + ->method( 'getPairData' ) + ->willReturn( [ + 'deleted' => 10, + 'live' => 100, + 'with_comments' => 75, + 'minor' => 5, + 'day' => 10, + 'week' => 15, + ] ); + + static::assertEquals( 100, $this->editCounter->countLiveRevisions() ); + static::assertEquals( 10, $this->editCounter->countDeletedRevisions() ); + static::assertEquals( 110, $this->editCounter->countAllRevisions() ); + static::assertEquals( 100, $this->editCounter->countLast5000() ); + static::assertEquals( 5, $this->editCounter->countMinorRevisions() ); + static::assertEquals( 10, $this->editCounter->countRevisionsInLast( 'day' ) ); + static::assertEquals( 15, $this->editCounter->countRevisionsInLast( 'week' ) ); + } + + /** + * A first and last actions, and number of days between. + */ + public function testFirstLastActions(): void { + $this->editCounterRepo->expects( static::once() )->method( 'getFirstAndLatestActions' )->willReturn( [ + 'rev_first' => [ + 'id' => 123, + 'timestamp' => '20170510100000', + 'type' => null, + ], + 'rev_latest' => [ + 'id' => 321, + 'timestamp' => '20170515150000', + 'type' => null, + ], + 'log_latest' => [ + 'id' => 456, + 'timestamp' => '20170510150000', + 'type' => 'thanks', + ], + ] ); + static::assertEquals( + [ + 'id' => 123, + 'timestamp' => '20170510100000', + 'type' => null, + ], + $this->editCounter->getFirstAndLatestActions()['rev_first'] + ); + static::assertEquals( + [ + 'id' => 321, + 'timestamp' => '20170515150000', + 'type' => null, + ], + $this->editCounter->getFirstAndLatestActions()['rev_latest'] + ); + static::assertEquals( 5, $this->editCounter->getDays() ); + } + + /** + * Test that page counts are reported correctly. + */ + public function testPageCounts(): void { + $this->editCounterRepo->expects( static::once() ) + ->method( 'getPairData' ) + ->willReturn( [ + 'edited-live' => 3, + 'edited-deleted' => 1, + 'created-live' => 6, + 'created-deleted' => 2, + ] ); + + static::assertEquals( 3, $this->editCounter->countLivePagesEdited() ); + static::assertSame( 1, $this->editCounter->countDeletedPagesEdited() ); + static::assertEquals( 4, $this->editCounter->countAllPagesEdited() ); + + static::assertEquals( 6, $this->editCounter->countCreatedPagesLive() ); + static::assertEquals( 2, $this->editCounter->countPagesCreatedDeleted() ); + static::assertEquals( 8, $this->editCounter->countPagesCreated() ); + } + + /** + * Test that namespace totals are reported correctly. + */ + public function testNamespaceTotals(): void { + $namespaceTotals = [ + // Namespace IDs => Edit counts + '1' => '3', + '2' => '6', + '3' => '9', + '4' => '12', + ]; + $this->editCounterRepo->expects( static::once() ) + ->method( 'getNamespaceTotals' ) + ->willReturn( $namespaceTotals ); + + static::assertEquals( $namespaceTotals, $this->editCounter->namespaceTotals() ); + static::assertEquals( 30, $this->editCounter->liveRevisionsFromNamespaces() ); + } + + /** + * Test that month counts are properly put together. + */ + public function testMonthCounts(): void { + $mockTime = new DateTime( '2017-04-30 23:59:59' ); + + $this->editCounterRepo->expects( static::once() ) + ->method( 'getMonthCounts' ) + ->willReturn( [ + [ + 'year' => '2016', + 'month' => '12', + 'namespace' => '0', + 'count' => '10', + ], + [ + 'year' => '2017', + 'month' => '3', + 'namespace' => '0', + 'count' => '20', + ], + [ + 'year' => '2017', + 'month' => '2', + 'namespace' => '1', + 'count' => '50', + ], + ] ); + + // Mock current time by passing it in (dummy parameter, so to speak). + $monthCounts = $this->editCounter->monthCounts( $mockTime ); + + // Make sure zeros were filled in for months with no edits, and for each namespace. + static::assertArraySubset( + [ + '2017-01' => 0, + '2017-02' => 0, + '2017-03' => 20, + '2017-04' => 0, + ], + $monthCounts['totals'][0] + ); + static::assertArraySubset( + [ + '2016-12' => 0, + ], + $monthCounts['totals'][1] + ); + + // Assert only active months are reported. + static::assertArrayNotHasKey( '2016-11', $monthCounts['totals'][0] ); + static::assertArrayHasKey( '2016-12', $monthCounts['totals'][0] ); + static::assertArrayHasKey( '2017-04', $monthCounts['totals'][0] ); + static::assertArrayNotHasKey( '2017-05', $monthCounts['totals'][0] ); + + // Assert that only active namespaces are reported. + static::assertSame( [ 0, 1 ], array_keys( $monthCounts['totals'] ) ); + + // Labels for the months + static::assertSame( + [ '2016-12', '2017-01', '2017-02', '2017-03', '2017-04' ], + $monthCounts['monthLabels'] + ); + + // Labels for the years + static::assertSame( [ '2016', '2017' ], $monthCounts['yearLabels'] ); + + // Month counts by namespace. + $monthsWithNamespaces = $this->editCounter->monthCountsWithNamespaces( $mockTime ); + static::assertSame( + $monthCounts['monthLabels'], + array_keys( $monthsWithNamespaces ) + ); + static::assertSame( [ 0, 1 ], array_keys( $monthsWithNamespaces['2017-03'] ) ); + + $yearTotals = $this->editCounter->yearTotals( $mockTime ); + static::assertSame( [ '2016' => 10, '2017' => 70 ], $yearTotals ); + } + + /** + * Test that year counts are properly put together. + */ + public function testYearCounts(): void { + $this->editCounterRepo->expects( static::once() ) + ->method( 'getMonthCounts' ) + ->willReturn( [ + [ + 'year' => '2015', + 'month' => '6', + 'namespace' => '1', + 'count' => '5', + ], + [ + 'year' => '2016', + 'month' => '12', + 'namespace' => '0', + 'count' => '10', + ], + [ + 'year' => '2017', + 'month' => '3', + 'namespace' => '0', + 'count' => '20', + ], + [ + 'year' => '2017', + 'month' => '2', + 'namespace' => '1', + 'count' => '50', + ], + ] ); + + // Mock current time by passing it in (dummy parameter, so to speak). + $yearCounts = $this->editCounter->yearCounts( new DateTime( '2017-04-30 23:59:59' ) ); + + // Make sure zeros were filled in for months with no edits, and for each namespace. + static::assertArraySubset( + [ + 2015 => 0, + 2016 => 10, + 2017 => 20, + ], + $yearCounts['totals'][0] + ); + static::assertArraySubset( + [ + 2015 => 5, + 2016 => 0, + 2017 => 50, + ], + $yearCounts['totals'][1] + ); + + // Assert that only active years are reported + static::assertEquals( [ 2015, 2016, 2017 ], array_keys( $yearCounts['totals'][0] ) ); + + // Assert that only active namespaces are reported. + static::assertEquals( [ 0, 1 ], array_keys( $yearCounts['totals'] ) ); + + // Labels for the years + static::assertEquals( [ '2015', '2016', '2017' ], $yearCounts['yearLabels'] ); + } + + /** + * Ensure parsing of log_params properly works, based on known formats + * @dataProvider longestBlockProvider + * @param array $blockLog + * @param int $longestDuration + */ + public function testLongestBlockSeconds( array $blockLog, int $longestDuration ): void { + $this->editCounterRepo->expects( static::once() ) + ->method( 'getBlocksReceived' ) + ->with( $this->project, $this->user ) + ->willReturn( $blockLog ); + static::assertEquals( $this->editCounter->getLongestBlockSeconds(), $longestDuration ); + } + + /** + * Data for self::testLongestBlockSeconds(). + * @return string[] + */ + public function longestBlockProvider(): array { + return [ + // Blocks that don't overlap, longest was 31 days. + [ + [ [ + 'log_timestamp' => '20170101000000', + 'log_params' => 'a:2:{s:11:"5::duration";s:8:"72 hours"' . + ';s:8:"6::flags";s:8:"nocreate";}', + 'log_action' => 'block', + ], + [ + 'log_timestamp' => '20170301000000', + 'log_params' => 'a:2:{s:11:"5::duration";s:7:"1 month"' . + ';s:8:"6::flags";s:11:"noautoblock";}', + 'log_action' => 'block', + ] ], + // 31 days in seconds. + 2678400, + ], + // Blocks that do overlap, without any unblocks. Combined 10 days. + [ + [ [ + 'log_timestamp' => '20170101000000', + 'log_params' => 'a:2:{s:11:"5::duration";s:7:"1 month"' . + ';s:8:"6::flags";s:8:"nocreate";}', + 'log_action' => 'block', + ], + [ + 'log_timestamp' => '20170110000000', + 'log_params' => 'a:2:{s:11:"5::duration";s:8:"24 hours"' . + ';s:8:"6::flags";s:11:"noautoblock";}', + 'log_action' => 'reblock', + ] ], + // 10 days in seconds. + 864000, + ], + // 30 day block that was later unblocked at only 10 days, followed by a shorter block. + [ + [ [ + 'log_timestamp' => '20170101000000', + 'log_params' => 'a:2:{s:11:"5::duration";s:7:"1 month"' . + ';s:8:"6::flags";s:8:"nocreate";}', + 'log_action' => 'block', + ], + [ + 'log_timestamp' => '20170111000000', + 'log_params' => 'a:0:{}', + 'log_action' => 'unblock', + ], + [ + 'log_timestamp' => '20170201000000', + 'log_params' => 'a:2:{s:11:"5::duration";s:8:"24 hours"' . + ';s:8:"6::flags";s:11:"noautoblock";}', + 'log_action' => 'block', + ] ], + // 10 days in seconds. + 864000, + ], + // Blocks ending with a still active indefinite block. Older block uses legacy format. + [ + [ [ + 'log_timestamp' => '20170101000000', + 'log_params' => "1 month\nnoautoblock", + 'log_action' => 'block', + ], + [ + 'log_timestamp' => '20170301000000', + 'log_params' => 'a:2:{s:11:"5::duration";s:10:"indefinite"' . + ';s:8:"6::flags";s:11:"noautoblock";}', + 'log_action' => 'block', + ] ], + // Indefinite + -1, + ], + // Block that's active, with an explicit expiry set. + [ + [ [ + 'log_timestamp' => '20170927203624', + 'log_params' => 'a:2:{s:11:"5::duration";s:29:"Sat, 06 Oct 2026 12:36:00 GMT"' . + ';s:8:"6::flags";s:11:"noautoblock";}', + 'log_action' => 'block', + ] ], + 285091176, + ], + // Two indefinite blocks. + [ + [ [ + 'log_timestamp' => '20160513200200', + 'log_params' => 'a:2:{s:11:"5::duration";s:10:"indefinite"' . + ';s:8:"6::flags";s:19:"nocreate,nousertalk";}', + 'log_action' => 'block', + ], + [ + 'log_timestamp' => '20160717021328', + 'log_params' => 'a:2:{s:11:"5::duration";s:8:"infinite"' . + ';s:8:"6::flags";s:31:"nocreate,noautoblock,nousertalk";}', + 'log_action' => 'reblock', + ] ], + -1, + ], + ]; + } + + /** + * Parsing block log entries. + * @dataProvider blockLogProvider + * @param array $logEntry + * @param array $assertion + */ + public function testParseBlockLogEntry( array $logEntry, array $assertion ): void { + static::assertEquals( + $this->editCounter->parseBlockLogEntry( $logEntry ), + $assertion + ); + } + + /** + * Data for self::testParseBlockLogEntry(). + * @return array + */ + public function blockLogProvider(): array { + return [ + [ + [ + 'log_timestamp' => '20170701000000', + 'log_params' => 'a:2:{s:11:"5::duration";s:7:"60 days";' . + 's:8:"6::flags";s:8:"nocreate";}', + ], + [ 1498867200, 5184000 ], + ], + [ + [ + 'log_timestamp' => '20170101000000', + 'log_params' => "9 weeks\nnoautoblock", + ], + [ 1483228800, 5443200 ], + ], + [ + [ + 'log_timestamp' => '20170101000000', + 'log_params' => "invalid format", + ], + [ 1483228800, null ], + ], + [ + [ + 'log_timestamp' => '20170101000000', + 'log_params' => "infinity\nnocreate", + ], + [ 1483228800, -1 ], + ], + [ + [ + 'log_timestamp' => '20170927203205', + 'log_params' => 'a:2:{s:11:"5::duration";s:19:"2017-09-30 12:36 PM";' . + 's:8:"6::flags";s:11:"noautoblock";}', + ], + [ 1506544325, 230635 ], + ], + ]; + } } diff --git a/tests/Model/EditSummaryTest.php b/tests/Model/EditSummaryTest.php index df28ae5cb..6a3a98a60 100644 --- a/tests/Model/EditSummaryTest.php +++ b/tests/Model/EditSummaryTest.php @@ -1,6 +1,6 @@ project = new Project('TestProject'); - $userRepo = $this->createMock(UserRepository::class); - $this->user = new User($userRepo, 'Test user'); - $editSummaryRepo = $this->createMock(EditSummaryRepository::class); - $this->editSummary = new EditSummary( - $editSummaryRepo, - $this->project, - $this->user, - 'all', - false, - false, - 1 - ); - - // Don't care that private methods "shouldn't" be tested... - // With EditSummary many are very test-worthy and otherwise fragile. - $this->reflectionClass = new ReflectionClass($this->editSummary); - } - - public function testHasSummary(): void - { - $method = $this->reflectionClass->getMethod('hasSummary'); - $method->setAccessible(true); - - static::assertFalse( - $method->invoke($this->editSummary, ['comment' => '']) - ); - static::assertTrue( - $method->invoke($this->editSummary, ['comment' => 'Foo']) - ); - static::assertFalse( - $method->invoke($this->editSummary, ['comment' => '/* section title */ ']) - ); - static::assertTrue( - $method->invoke($this->editSummary, ['comment' => ' /* section title */']) - ); - static::assertTrue( - $method->invoke($this->editSummary, ['comment' => '/* section title */ Foo']) - ); - } - - /** - * Test that the class properties were properly updated after processing rows. - */ - public function testGetters(): void - { - $method = $this->reflectionClass->getMethod('processRow'); - $method->setAccessible(true); - - foreach ($this->getRevisions() as $revision) { - $method->invoke($this->editSummary, $revision); - } - - static::assertEquals(4, $this->editSummary->getTotalEdits()); - static::assertEquals(2, $this->editSummary->getTotalEditsMinor()); - static::assertEquals(2, $this->editSummary->getTotalEditsMajor()); - - // In self::setUp() we set the threshold for recent edits to 1. - static::assertEquals(1, $this->editSummary->getRecentEditsMinor()); - static::assertEquals(1, $this->editSummary->getRecentEditsMajor()); - - static::assertEquals(2, $this->editSummary->getTotalSummaries()); - static::assertEquals(1, $this->editSummary->getTotalSummariesMinor()); - static::assertEquals(1, $this->editSummary->getTotalSummariesMajor()); - - static::assertEquals(0, $this->editSummary->getRecentSummariesMinor()); - static::assertEquals(1, $this->editSummary->getRecentSummariesMajor()); - - static::assertEquals([ - '2016-07' => [ - 'total' => 2, - 'summaries' => 1, - ], - '2016-10' => [ - 'total' => 1, - 'summaries' => 1, - ], - '2016-11' => [ - 'total' => 1, - ], - ], $this->editSummary->getMonthCounts()); - } - - /** - * Get test revisions. - * @return string[] Rows with keys 'comment', 'rev_timestamp' and 'rev_minor_edit'. - */ - private function getRevisions(): array - { - // Ordered by rev_timestamp DESC. - return [ - [ - 'comment' => '/* Section title */', - 'rev_timestamp' => '20161103010000', - 'rev_minor_edit' => '1', - ], [ - 'comment' => 'Weeee', - 'rev_timestamp' => '20161003000000', - 'rev_minor_edit' => '0', - ], [ - 'comment' => 'This is an edit summary', - 'rev_timestamp' => '20160705000000', - 'rev_minor_edit' => '1', - ], [ - 'comment' => '', - 'rev_timestamp' => '20160701101205', - 'rev_minor_edit' => '0', - ], - ]; - } +class EditSummaryTest extends TestAdapter { + use SessionHelper; + + protected EditSummary $editSummary; + protected Project $project; + protected User $user; + + /** @var ReflectionClass So we can test private methods. */ + private ReflectionClass $reflectionClass; + + /** + * Set up shared mocks and class instances. + */ + public function setUp(): void { + $this->project = new Project( 'TestProject' ); + $userRepo = $this->createMock( UserRepository::class ); + $this->user = new User( $userRepo, 'Test user' ); + $editSummaryRepo = $this->createMock( EditSummaryRepository::class ); + $this->editSummary = new EditSummary( + $editSummaryRepo, + $this->project, + $this->user, + 'all', + false, + false, + 1 + ); + + // Don't care that private methods "shouldn't" be tested... + // With EditSummary many are very test-worthy and otherwise fragile. + $this->reflectionClass = new ReflectionClass( $this->editSummary ); + } + + public function testHasSummary(): void { + $method = $this->reflectionClass->getMethod( 'hasSummary' ); + $method->setAccessible( true ); + + static::assertFalse( + $method->invoke( $this->editSummary, [ 'comment' => '' ] ) + ); + static::assertTrue( + $method->invoke( $this->editSummary, [ 'comment' => 'Foo' ] ) + ); + static::assertFalse( + $method->invoke( $this->editSummary, [ 'comment' => '/* section title */ ' ] ) + ); + static::assertTrue( + $method->invoke( $this->editSummary, [ 'comment' => ' /* section title */' ] ) + ); + static::assertTrue( + $method->invoke( $this->editSummary, [ 'comment' => '/* section title */ Foo' ] ) + ); + } + + /** + * Test that the class properties were properly updated after processing rows. + */ + public function testGetters(): void { + $method = $this->reflectionClass->getMethod( 'processRow' ); + $method->setAccessible( true ); + + foreach ( $this->getRevisions() as $revision ) { + $method->invoke( $this->editSummary, $revision ); + } + + static::assertEquals( 4, $this->editSummary->getTotalEdits() ); + static::assertEquals( 2, $this->editSummary->getTotalEditsMinor() ); + static::assertEquals( 2, $this->editSummary->getTotalEditsMajor() ); + + // In self::setUp() we set the threshold for recent edits to 1. + static::assertSame( 1, $this->editSummary->getRecentEditsMinor() ); + static::assertSame( 1, $this->editSummary->getRecentEditsMajor() ); + + static::assertEquals( 2, $this->editSummary->getTotalSummaries() ); + static::assertSame( 1, $this->editSummary->getTotalSummariesMinor() ); + static::assertSame( 1, $this->editSummary->getTotalSummariesMajor() ); + + static::assertSame( 0, $this->editSummary->getRecentSummariesMinor() ); + static::assertSame( 1, $this->editSummary->getRecentSummariesMajor() ); + + static::assertEquals( [ + '2016-07' => [ + 'total' => 2, + 'summaries' => 1, + ], + '2016-10' => [ + 'total' => 1, + 'summaries' => 1, + ], + '2016-11' => [ + 'total' => 1, + ], + ], $this->editSummary->getMonthCounts() ); + } + + /** + * Get test revisions. + * @return string[] Rows with keys 'comment', 'rev_timestamp' and 'rev_minor_edit'. + */ + private function getRevisions(): array { + // Ordered by rev_timestamp DESC. + return [ + [ + 'comment' => '/* Section title */', + 'rev_timestamp' => '20161103010000', + 'rev_minor_edit' => '1', + ], [ + 'comment' => 'Weeee', + 'rev_timestamp' => '20161003000000', + 'rev_minor_edit' => '0', + ], [ + 'comment' => 'This is an edit summary', + 'rev_timestamp' => '20160705000000', + 'rev_minor_edit' => '1', + ], [ + 'comment' => '', + 'rev_timestamp' => '20160701101205', + 'rev_minor_edit' => '0', + ], + ]; + } } diff --git a/tests/Model/EditTest.php b/tests/Model/EditTest.php index 661e9bf8d..ee99c2bd7 100644 --- a/tests/Model/EditTest.php +++ b/tests/Model/EditTest.php @@ -1,6 +1,6 @@ client = static::createClient(); - $this->createSession($this->client); - $this->localContainer = $this->client->getContainer(); - $this->project = new Project('en.wikipedia.org'); - $this->projectRepo = $this->createMock(ProjectRepository::class); - $this->projectRepo->method('getOne') - ->willReturn([ - 'url' => 'https://en.wikipedia.org', - 'dbName' => 'enwiki', - 'lang' => 'en', - ]); - $this->projectRepo->method('getMetadata') - ->willReturn([ - 'general' => [ - 'articlePath' => '/wiki/$1', - ], - ]); - $this->project->setRepository($this->projectRepo); - $this->pageRepo = $this->createMock(PageRepository::class); - $this->pageRepo->method('getPageInfo') - ->willReturn([ - 'ns' => 0, - ]); - $this->page = new Page($this->pageRepo, $this->project, 'Test_page'); + /** + * Set up container, class instances and mocks. + */ + public function setUp(): void { + $this->client = static::createClient(); + $this->createSession( $this->client ); + $this->localContainer = $this->client->getContainer(); + $this->project = new Project( 'en.wikipedia.org' ); + $this->projectRepo = $this->createMock( ProjectRepository::class ); + $this->projectRepo->method( 'getOne' ) + ->willReturn( [ + 'url' => 'https://en.wikipedia.org', + 'dbName' => 'enwiki', + 'lang' => 'en', + ] ); + $this->projectRepo->method( 'getMetadata' ) + ->willReturn( [ + 'general' => [ + 'articlePath' => '/wiki/$1', + ], + ] ); + $this->project->setRepository( $this->projectRepo ); + $this->pageRepo = $this->createMock( PageRepository::class ); + $this->pageRepo->method( 'getPageInfo' ) + ->willReturn( [ + 'ns' => 0, + ] ); + $this->page = new Page( $this->pageRepo, $this->project, 'Test_page' ); - $this->editAttrs = [ - 'id' => '1', - 'timestamp' => '20170101100000', - 'minor' => '0', - 'length' => '12', - 'length_change' => '2', - 'username' => 'Testuser', - 'comment' => 'Test', - 'rev_sha1' => 'abcdef', - 'reverted' => 0, - ]; - } + $this->editAttrs = [ + 'id' => '1', + 'timestamp' => '20170101100000', + 'minor' => '0', + 'length' => '12', + 'length_change' => '2', + 'username' => 'Testuser', + 'comment' => 'Test', + 'rev_sha1' => 'abcdef', + 'reverted' => 0, + ]; + } - /** - * Test the basic functionality of Edit. - */ - public function testBasic(): void - { - // Also tests that giving a DateTime works; other tests use the string variant from $this->editAttrs. - $edit = $this->getEditFactory(['comment' => 'Test', 'timestamp' => new DateTime('20170101100000')]); - static::assertEquals($this->project, $edit->getProject()); - static::assertInstanceOf(DateTime::class, $edit->getTimestamp()); - static::assertEquals($this->page, $edit->getPage()); - static::assertEquals('1483264800', $edit->getTimestamp()->getTimestamp()); - static::assertEquals(1, $edit->getId()); - static::assertFalse($edit->isMinor()); - static::assertEquals('abcdef', $edit->getSha()); - static::assertEquals('1', $edit->getCacheKey()); - static::assertFalse($edit->isReverted()); - } + /** + * Test the basic functionality of Edit. + */ + public function testBasic(): void { + // Also tests that giving a DateTime works; other tests use the string variant from $this->editAttrs. + $edit = $this->getEditFactory( [ 'comment' => 'Test', 'timestamp' => new DateTime( '20170101100000' ) ] ); + static::assertEquals( $this->project, $edit->getProject() ); + static::assertInstanceOf( DateTime::class, $edit->getTimestamp() ); + static::assertEquals( $this->page, $edit->getPage() ); + static::assertSame( 1483264800, $edit->getTimestamp()->getTimestamp() ); + static::assertSame( 1, $edit->getId() ); + static::assertFalse( $edit->isMinor() ); + static::assertEquals( 'abcdef', $edit->getSha() ); + static::assertSame( '1', $edit->getCacheKey() ); + static::assertFalse( $edit->isReverted() ); + } - /** - * Using that static method. - */ - public function testGetEditFromRevs(): void - { - $editRepo = $this->createMock(EditRepository::class); - $editRepo->method('getAutoEditsHelper') - ->willReturn($this->getAutomatedEditsHelper($this->client)); - $userRepo = $this->createMock(UserRepository::class); - $edit = Edit::getEditsFromRevs( - $this->pageRepo, - $editRepo, - $userRepo, - $this->project, - new User($userRepo, 'Foobar'), - [array_merge($this->editAttrs, ['page_title' => 'Test', 'namespace' => 0])] - ); - static::assertEquals(1, $edit[0]->getId()); - } + /** + * Using that static method. + */ + public function testGetEditFromRevs(): void { + $editRepo = $this->createMock( EditRepository::class ); + $editRepo->method( 'getAutoEditsHelper' ) + ->willReturn( $this->getAutomatedEditsHelper( $this->client ) ); + $userRepo = $this->createMock( UserRepository::class ); + $edit = Edit::getEditsFromRevs( + $this->pageRepo, + $editRepo, + $userRepo, + $this->project, + new User( $userRepo, 'Foobar' ), + [ array_merge( $this->editAttrs, [ 'page_title' => 'Test', 'namespace' => 0 ] ) ] + ); + static::assertSame( 1, $edit[0]->getId() ); + } - /** - * Wikified edit summary - */ - public function testWikifiedComment(): void - { - $edit = $this->getEditFactory([ - 'comment' => ' [[test page]]', - ]); - static::assertEquals( - "<script>alert(\"XSS baby\")</script> " . - "test page", - $edit->getWikifiedSummary() - ); + /** + * Wikified edit summary + */ + public function testWikifiedComment(): void { + $edit = $this->getEditFactory( [ + 'comment' => ' [[test page]]', + ] ); + static::assertEquals( + "<script>alert(\"XSS baby\")</script> " . + "test page", + $edit->getWikifiedSummary() + ); - $edit = $this->getEditFactory([ - 'comment' => 'https://example.org', - ]); - static::assertEquals( - 'https://example.org', - $edit->getWikifiedSummary() - ); - } + $edit = $this->getEditFactory( [ + 'comment' => 'https://example.org', + ] ); + static::assertEquals( + 'https://example.org', + $edit->getWikifiedSummary() + ); + } - /** - * Make sure the right tool is detected - */ - public function testTool(): void - { - $edit = $this->getEditFactory([ - 'comment' => 'Level 2 warning re. [[Barack Obama]] ([[WP:HG|HG]]) (3.2.0)', - ]); - static::assertArraySubset( - [ 'name' => 'Huggle' ], - $edit->getTool() - ); - } + /** + * Make sure the right tool is detected + */ + public function testTool(): void { + $edit = $this->getEditFactory( [ + 'comment' => 'Level 2 warning re. [[Barack Obama]] ([[WP:HG|HG]]) (3.2.0)', + ] ); + static::assertArraySubset( + [ 'name' => 'Huggle' ], + $edit->getTool() + ); + } - /** - * Was the edit a revert, based on the edit summary? - */ - public function testIsRevert(): void - { - $edit = $this->getEditFactory([ - 'comment' => 'You should have reverted this edit using [[WP:HG|Huggle]]', - ]); - static::assertFalse($edit->isRevert()); + /** + * Was the edit a revert, based on the edit summary? + */ + public function testIsRevert(): void { + $edit = $this->getEditFactory( [ + 'comment' => 'You should have reverted this edit using [[WP:HG|Huggle]]', + ] ); + static::assertFalse( $edit->isRevert() ); - $edit->setReverted(true); - static::assertTrue($edit->isReverted()); + $edit->setReverted( true ); + static::assertTrue( $edit->isReverted() ); - $edit2 = $this->getEditFactory([ - 'comment' => 'Reverted edits by Mogultalk (talk) ([[WP:HG|HG]]) (3.2.0)', - ]); - static::assertTrue($edit2->isRevert()); - } + $edit2 = $this->getEditFactory( [ + 'comment' => 'Reverted edits by Mogultalk (talk) ([[WP:HG|HG]]) (3.2.0)', + ] ); + static::assertTrue( $edit2->isRevert() ); + } - /** - * Tests that given edit summary is properly asserted as a revert - */ - public function testIsAutomated(): void - { - $edit = $this->getEditFactory([ - 'comment' => 'You should have reverted this edit using [[WP:HG|Huggle]]', - ]); - static::assertFalse($edit->isAutomated()); + /** + * Tests that given edit summary is properly asserted as a revert + */ + public function testIsAutomated(): void { + $edit = $this->getEditFactory( [ + 'comment' => 'You should have reverted this edit using [[WP:HG|Huggle]]', + ] ); + static::assertFalse( $edit->isAutomated() ); - $edit2 = $this->getEditFactory([ - 'comment' => 'Reverted edits by Mogultalk (talk) ([[WP:HG|HG]]) (3.2.0)', - ]); - static::assertTrue($edit2->isAutomated()); - } + $edit2 = $this->getEditFactory( [ + 'comment' => 'Reverted edits by Mogultalk (talk) ([[WP:HG|HG]]) (3.2.0)', + ] ); + static::assertTrue( $edit2->isAutomated() ); + } - /** - * Test some basic getters. - */ - public function testGetters(): void - { - $edit = $this->getEditFactory(); - static::assertEquals('2017', $edit->getYear()); - static::assertEquals('01', $edit->getMonth()); - static::assertEquals(12, $edit->getLength()); - static::assertEquals(2, $edit->getSize()); - static::assertEquals(2, $edit->getLengthChange()); - static::assertEquals('Testuser', $edit->getUser()->getUsername()); - } + /** + * Test some basic getters. + */ + public function testGetters(): void { + $edit = $this->getEditFactory(); + static::assertSame( '2017', $edit->getYear() ); + static::assertSame( '01', $edit->getMonth() ); + static::assertEquals( 12, $edit->getLength() ); + static::assertEquals( 2, $edit->getSize() ); + static::assertEquals( 2, $edit->getLengthChange() ); + static::assertEquals( 'Testuser', $edit->getUser()->getUsername() ); + } - /** - * URL to the diff. - */ - public function testDiffUrl(): void - { - $edit = $this->getEditFactory(); - static::assertEquals( - 'https://en.wikipedia.org/wiki/Special:Diff/1', - $edit->getDiffUrl() - ); - } + /** + * URL to the diff. + */ + public function testDiffUrl(): void { + $edit = $this->getEditFactory(); + static::assertEquals( + 'https://en.wikipedia.org/wiki/Special:Diff/1', + $edit->getDiffUrl() + ); + } - /** - * URL to the diff. - */ - public function testPermaUrl(): void - { - $edit = $this->getEditFactory(); - static::assertEquals( - 'https://en.wikipedia.org/wiki/Special:PermaLink/1', - $edit->getPermaUrl() - ); - } + /** + * URL to the diff. + */ + public function testPermaUrl(): void { + $edit = $this->getEditFactory(); + static::assertEquals( + 'https://en.wikipedia.org/wiki/Special:PermaLink/1', + $edit->getPermaUrl() + ); + } - /** - * Was the edit made by a logged out user? - */ - public function testIsAnon(): void - { - // Edit made by User:Testuser - $edit = $this->getEditFactory(); - $project = $this->createMock(Project::class); - static::assertFalse($edit->isAnon($project)); + /** + * Was the edit made by a logged out user? + */ + public function testIsAnon(): void { + // Edit made by User:Testuser + $edit = $this->getEditFactory(); + $project = $this->createMock( Project::class ); + static::assertFalse( $edit->isAnon( $project ) ); - $edit = $this->getEditFactory([ - 'username' => '192.168.0.1', - ]); - static::assertTrue($edit->isAnon($project)); - } + $edit = $this->getEditFactory( [ + 'username' => '192.168.0.1', + ] ); + static::assertTrue( $edit->isAnon( $project ) ); + } - public function testGetForJson(): void - { - $edit = $this->getEditFactory(); - static::assertEquals( - [ - 'project' => 'en.wikipedia.org', - 'username' => 'Testuser', - 'page_title' => 'Test page', - 'namespace' => $this->page->getNamespace(), - 'rev_id' => 1, - 'timestamp' => '2017-01-01T10:00:00Z', - 'minor' => false, - 'length' => 12, - 'length_change' => 2, - 'comment' => 'Test', - 'reverted' => false, - ], - $edit->getForJson(true) - ); - } + public function testGetForJson(): void { + $edit = $this->getEditFactory(); + static::assertEquals( + [ + 'project' => 'en.wikipedia.org', + 'username' => 'Testuser', + 'page_title' => 'Test page', + 'namespace' => $this->page->getNamespace(), + 'rev_id' => 1, + 'timestamp' => '2017-01-01T10:00:00Z', + 'minor' => false, + 'length' => 12, + 'length_change' => 2, + 'comment' => 'Test', + 'reverted' => false, + ], + $edit->getForJson( true ) + ); + } - public function testDeleted(): void - { - $this->editAttrs['rev_deleted'] = Edit::DELETED_USER; - $edit = $this->getEditFactory(); - static::assertNull($edit->getUser()); - static::assertEquals(Edit::DELETED_USER, $edit->getDeleted()); - static::assertTrue($edit->deletedUser()); - static::assertFalse($edit->deletedSummary()); - } + public function testDeleted(): void { + $this->editAttrs['rev_deleted'] = Edit::DELETED_USER; + $edit = $this->getEditFactory(); + static::assertNull( $edit->getUser() ); + static::assertEquals( Edit::DELETED_USER, $edit->getDeleted() ); + static::assertTrue( $edit->deletedUser() ); + static::assertFalse( $edit->deletedSummary() ); + } - /** - * @param array $attrs - * @return Edit - */ - private function getEditFactory(array $attrs = []): Edit - { - $editRepo = $this->createMock(EditRepository::class); - $editRepo->method('getAutoEditsHelper') - ->willReturn($this->getAutomatedEditsHelper($this->client)); - $userRepo = $this->createMock(UserRepository::class); - return new Edit($editRepo, $userRepo, $this->page, array_merge($this->editAttrs, $attrs)); - } + /** + * @param array $attrs + * @return Edit + */ + private function getEditFactory( array $attrs = [] ): Edit { + $editRepo = $this->createMock( EditRepository::class ); + $editRepo->method( 'getAutoEditsHelper' ) + ->willReturn( $this->getAutomatedEditsHelper( $this->client ) ); + $userRepo = $this->createMock( UserRepository::class ); + return new Edit( $editRepo, $userRepo, $this->page, array_merge( $this->editAttrs, $attrs ) ); + } } diff --git a/tests/Model/GlobalContribsTest.php b/tests/Model/GlobalContribsTest.php index 635eb2196..abd60d000 100644 --- a/tests/Model/GlobalContribsTest.php +++ b/tests/Model/GlobalContribsTest.php @@ -1,6 +1,6 @@ globalContribsRepo = $this->createMock(GlobalContribsRepository::class); - $userRepo = $this->createMock(UserRepository::class); - $this->globalContribs = new GlobalContribs( - $this->globalContribsRepo, - $this->createMock(PageRepository::class), - $userRepo, - $this->createMock(EditRepository::class), - new User($userRepo, 'Test user') - ); - } + /** + * Set up shared mocks and class instances. + */ + public function setUp(): void { + $this->globalContribsRepo = $this->createMock( GlobalContribsRepository::class ); + $userRepo = $this->createMock( UserRepository::class ); + $this->globalContribs = new GlobalContribs( + $this->globalContribsRepo, + $this->createMock( PageRepository::class ), + $userRepo, + $this->createMock( EditRepository::class ), + new User( $userRepo, 'Test user' ) + ); + } - /** - * Get all global edit counts, or just the top N, or the overall grand total. - */ - public function testGlobalEditCounts(): void - { - $wiki1 = new Project('wiki1'); - $wiki2 = new Project('wiki2'); - $editCounts = [ - ['project' => new Project('wiki0'), 'total' => 30], - ['project' => $wiki1, 'total' => 50], - ['project' => $wiki2, 'total' => 40], - ['project' => new Project('wiki3'), 'total' => 20], - ['project' => new Project('wiki4'), 'total' => 10], - ['project' => new Project('wiki5'), 'total' => 35], - ]; - $this->globalContribsRepo->expects(static::once()) - ->method('globalEditCounts') - ->willReturn($editCounts); + /** + * Get all global edit counts, or just the top N, or the overall grand total. + */ + public function testGlobalEditCounts(): void { + $wiki1 = new Project( 'wiki1' ); + $wiki2 = new Project( 'wiki2' ); + $editCounts = [ + [ 'project' => new Project( 'wiki0' ), 'total' => 30 ], + [ 'project' => $wiki1, 'total' => 50 ], + [ 'project' => $wiki2, 'total' => 40 ], + [ 'project' => new Project( 'wiki3' ), 'total' => 20 ], + [ 'project' => new Project( 'wiki4' ), 'total' => 10 ], + [ 'project' => new Project( 'wiki5' ), 'total' => 35 ], + ]; + $this->globalContribsRepo->expects( static::once() ) + ->method( 'globalEditCounts' ) + ->willReturn( $editCounts ); - // Get the top 2. - static::assertEquals( - [ - ['project' => $wiki1, 'total' => 50], - ['project' => $wiki2, 'total' => 40], - ], - $this->globalContribs->globalEditCountsTopN(2) - ); + // Get the top 2. + static::assertEquals( + [ + [ 'project' => $wiki1, 'total' => 50 ], + [ 'project' => $wiki2, 'total' => 40 ], + ], + $this->globalContribs->globalEditCountsTopN( 2 ) + ); - // And the bottom 4. - static::assertEquals(95, $this->globalContribs->globalEditCountWithoutTopN(2)); + // And the bottom 4. + static::assertEquals( 95, $this->globalContribs->globalEditCountWithoutTopN( 2 ) ); - // Grand total. - static::assertEquals(185, $this->globalContribs->globalEditCount()); - } + // Grand total. + static::assertEquals( 185, $this->globalContribs->globalEditCount() ); + } - /** - * Test global edits. - */ - public function testGlobalEdits(): void - { - /** @var ProjectRepository|MockObject $wiki1Repo */ - $wiki1Repo = $this->createMock(ProjectRepository::class); - $wiki1Repo->expects(static::once()) - ->method('getMetadata') - ->willReturn(['namespaces' => [2 => 'User']]); - $wiki1Repo->expects(static::once()) - ->method('getOne') - ->willReturn([ - 'dbName' => 'wiki1', - 'url' => 'https://wiki1.example.org', - ]); - $wiki1 = new Project('wiki1'); - $wiki1->setRepository($wiki1Repo); + /** + * Test global edits. + */ + public function testGlobalEdits(): void { + /** @var ProjectRepository|MockObject $wiki1Repo */ + $wiki1Repo = $this->createMock( ProjectRepository::class ); + $wiki1Repo->expects( static::once() ) + ->method( 'getMetadata' ) + ->willReturn( [ 'namespaces' => [ 2 => 'User' ] ] ); + $wiki1Repo->expects( static::once() ) + ->method( 'getOne' ) + ->willReturn( [ + 'dbName' => 'wiki1', + 'url' => 'https://wiki1.example.org', + ] ); + $wiki1 = new Project( 'wiki1' ); + $wiki1->setRepository( $wiki1Repo ); - $contribs = [[ - 'dbName' => 'wiki1', - 'id' => 1, - 'timestamp' => '20180101000000', - 'unix_timestamp' => '1514764800', - 'minor' => 0, - 'deleted' => 0, - 'length' => 5, - 'length_change' => 10, - 'parent_id' => 0, - 'username' => 'Test user', - 'page_title' => 'Foo bar', - 'namespace' => '2', - 'comment' => 'My user page', - ]]; + $contribs = [ [ + 'dbName' => 'wiki1', + 'id' => 1, + 'timestamp' => '20180101000000', + 'unix_timestamp' => '1514764800', + 'minor' => 0, + 'deleted' => 0, + 'length' => 5, + 'length_change' => 10, + 'parent_id' => 0, + 'username' => 'Test user', + 'page_title' => 'Foo bar', + 'namespace' => '2', + 'comment' => 'My user page', + ] ]; - $this->globalContribsRepo->expects(static::once()) - ->method('getProjectsWithEdits') - ->willReturn([ - 'wiki1' => $wiki1, - ]); - $this->globalContribsRepo->expects(static::once()) - ->method('getRevisions') - ->willReturn($contribs); + $this->globalContribsRepo->expects( static::once() ) + ->method( 'getProjectsWithEdits' ) + ->willReturn( [ + 'wiki1' => $wiki1, + ] ); + $this->globalContribsRepo->expects( static::once() ) + ->method( 'getRevisions' ) + ->willReturn( $contribs ); - $edits = $this->globalContribs->globalEdits(); + $edits = $this->globalContribs->globalEdits(); - static::assertCount(1, $edits); - static::assertEquals('My user page', $edits['1514764800-1']->getComment()); - } + static::assertCount( 1, $edits ); + static::assertEquals( 'My user page', $edits['1514764800-1']->getComment() ); + } } diff --git a/tests/Model/LargestPagesTest.php b/tests/Model/LargestPagesTest.php index e9bb0c40e..ab1595eae 100644 --- a/tests/Model/LargestPagesTest.php +++ b/tests/Model/LargestPagesTest.php @@ -1,6 +1,6 @@ createMock(LargestPagesRepository::class), - $this->createMock(Project::class), - 0, - 'foo%', - '%bar' - ); +/** + * @covers \App\Model\LargestPages + */ +class LargestPagesTest extends TestCase { - static::assertEquals('foo%', $largestPages->getIncludePattern()); - static::assertEquals('%bar', $largestPages->getExcludePattern()); - } + public function testGetters(): void { + $largestPages = new LargestPages( + $this->createMock( LargestPagesRepository::class ), + $this->createMock( Project::class ), + 0, + 'foo%', + '%bar' + ); + + static::assertEquals( 'foo%', $largestPages->getIncludePattern() ); + static::assertEquals( '%bar', $largestPages->getExcludePattern() ); + } } diff --git a/tests/Model/ModelTest.php b/tests/Model/ModelTest.php index a9ea968fa..cd3b94bf9 100644 --- a/tests/Model/ModelTest.php +++ b/tests/Model/ModelTest.php @@ -1,6 +1,6 @@ createMock(SimpleEditCounterRepository::class); - $project = $this->createMock(Project::class); - $user = $this->createMock(User::class); - $start = '2020-01-01'; - $end = '2020-02-01'; +/** + * @covers \App\Model\Model + */ +class ModelTest extends TestAdapter { + public function testBasics(): void { + // Use SimpleEditCounter since Model is abstract. + $repo = $this->createMock( SimpleEditCounterRepository::class ); + $project = $this->createMock( Project::class ); + $user = $this->createMock( User::class ); + $start = '2020-01-01'; + $end = '2020-02-01'; - $model = new SimpleEditCounter( - $repo, - $project, - $user, - 'all', - strtotime($start), - strtotime($end) - ); + $model = new SimpleEditCounter( + $repo, + $project, + $user, + 'all', + strtotime( $start ), + strtotime( $end ) + ); - self::assertEquals($model->getRepository(), $repo); - self::assertEquals($model->getProject(), $project); - self::assertEquals($model->getUser(), $user); - self::assertNull($model->getPage()); - self::assertEquals('all', $model->getNamespace()); - self::assertEquals(strtotime($start), $model->getStart()); - self::assertEquals($start, $model->getStartDate()); - self::assertEquals(strtotime($end), $model->getEnd()); - self::assertEquals($end, $model->getEndDate()); - self::assertTrue($model->hasDateRange()); - self::assertNull($model->getLimit()); - self::assertFalse($model->getOffset()); - self::assertNull($model->getOffsetISO()); - } + self::assertEquals( $model->getRepository(), $repo ); + self::assertEquals( $model->getProject(), $project ); + self::assertEquals( $model->getUser(), $user ); + self::assertNull( $model->getPage() ); + self::assertEquals( 'all', $model->getNamespace() ); + self::assertEquals( strtotime( $start ), $model->getStart() ); + self::assertEquals( $start, $model->getStartDate() ); + self::assertEquals( strtotime( $end ), $model->getEnd() ); + self::assertEquals( $end, $model->getEndDate() ); + self::assertTrue( $model->hasDateRange() ); + self::assertNull( $model->getLimit() ); + self::assertFalse( $model->getOffset() ); + self::assertNull( $model->getOffsetISO() ); + } } diff --git a/tests/Model/PageAssessmentsTest.php b/tests/Model/PageAssessmentsTest.php index 603152b82..e1f9af907 100644 --- a/tests/Model/PageAssessmentsTest.php +++ b/tests/Model/PageAssessmentsTest.php @@ -1,6 +1,6 @@ localContainer = $client->getContainer(); - - $this->paRepo = $this->createMock(PageAssessmentsRepository::class); - $this->paRepo->expects($this->once()) - ->method('getConfig') - ->willReturn($this->localContainer->getParameter('assessments')['en.wikipedia.org']); - - $this->project = $this->createMock(Project::class); - } - - /** - * Some of the basics. - */ - public function testBasics(): void - { - $pa = new PageAssessments($this->paRepo, $this->project); - - static::assertEquals( - $this->localContainer->getParameter('assessments')['en.wikipedia.org'], - $pa->getConfig() - ); - static::assertTrue($pa->isEnabled()); - static::assertTrue($pa->hasImportanceRatings()); - static::assertTrue($pa->isSupportedNamespace(6)); - } - - /** - * Badges - */ - public function testBadges(): void - { - $pa = new PageAssessments($this->paRepo, $this->project); - - static::assertEquals( - 'https://upload.wikimedia.org/wikipedia/commons/b/bc/Featured_article_star.svg', - $pa->getBadgeURL('FA') - ); - - static::assertEquals( - 'Featured_article_star.svg', - $pa->getBadgeURL('FA', true) - ); - } - - /** - * Page assements. - */ - public function testGetAssessments(): void - { - $pageRepo = $this->createMock(PageRepository::class); - $pageRepo->method('getPageInfo')->willReturn([ - 'title' => 'Test Page', - 'ns' => 0, - ]); - $page = new Page($pageRepo, $this->project, 'Test_page'); - - $this->paRepo->expects($this->once()) - ->method('getAssessments') - ->with($page) - ->willReturn([ - [ - 'wikiproject' => 'Military history', - 'class' => 'Start', - 'importance' => 'Low', - ], - [ - 'wikiproject' => 'Firearms', - 'class' => 'C', - 'importance' => 'High', - ], - ]); - - $pa = new PageAssessments($this->paRepo, $this->project); - - $assessments = $pa->getAssessments($page); - - // Picks the first assessment. - static::assertEquals([ - 'class' => 'Start', - 'color' => '#FFAA66', - 'category' => 'Category:Start-Class articles', - 'badge' => 'https://upload.wikimedia.org/wikipedia/commons/a/a4/Symbol_start_class.svg', - ], $assessments['assessment']); - - static::assertEquals(2, count($assessments['wikiprojects'])); - } +class PageAssessmentsTest extends TestAdapter { + /** @var ContainerInterface The Symfony localContainer ($localContainer to not override self::$container). */ + protected ContainerInterface $localContainer; + + /** @var PageAssessments */ + protected $pa; + + /** @var PageAssessmentsRepository The repository for page assessments. */ + protected $paRepo; + + /** @var Project The project we're working with. */ + protected $project; + + /** + * Set up client and set container, and PageAssessmentsRepository mock. + */ + public function setUp(): void { + $client = static::createClient(); + $this->localContainer = $client->getContainer(); + + $this->paRepo = $this->createMock( PageAssessmentsRepository::class ); + $this->paRepo->expects( $this->once() ) + ->method( 'getConfig' ) + ->willReturn( $this->localContainer->getParameter( 'assessments' )['en.wikipedia.org'] ); + + $this->project = $this->createMock( Project::class ); + } + + /** + * Some of the basics. + */ + public function testBasics(): void { + $pa = new PageAssessments( $this->paRepo, $this->project ); + + static::assertEquals( + $this->localContainer->getParameter( 'assessments' )['en.wikipedia.org'], + $pa->getConfig() + ); + static::assertTrue( $pa->isEnabled() ); + static::assertTrue( $pa->hasImportanceRatings() ); + static::assertTrue( $pa->isSupportedNamespace( 6 ) ); + } + + /** + * Badges + */ + public function testBadges(): void { + $pa = new PageAssessments( $this->paRepo, $this->project ); + + static::assertEquals( + 'https://upload.wikimedia.org/wikipedia/commons/b/bc/Featured_article_star.svg', + $pa->getBadgeURL( 'FA' ) + ); + + static::assertEquals( + 'Featured_article_star.svg', + $pa->getBadgeURL( 'FA', true ) + ); + } + + /** + * Page assements. + */ + public function testGetAssessments(): void { + $pageRepo = $this->createMock( PageRepository::class ); + $pageRepo->method( 'getPageInfo' )->willReturn( [ + 'title' => 'Test Page', + 'ns' => 0, + ] ); + $page = new Page( $pageRepo, $this->project, 'Test_page' ); + + $this->paRepo->expects( $this->once() ) + ->method( 'getAssessments' ) + ->with( $page ) + ->willReturn( [ + [ + 'wikiproject' => 'Military history', + 'class' => 'Start', + 'importance' => 'Low', + ], + [ + 'wikiproject' => 'Firearms', + 'class' => 'C', + 'importance' => 'High', + ], + ] ); + + $pa = new PageAssessments( $this->paRepo, $this->project ); + + $assessments = $pa->getAssessments( $page ); + + // Picks the first assessment. + static::assertEquals( [ + 'class' => 'Start', + 'color' => '#FFAA66', + 'category' => 'Category:Start-Class articles', + 'badge' => 'https://upload.wikimedia.org/wikipedia/commons/a/a4/Symbol_start_class.svg', + ], $assessments['assessment'] ); + + static::assertCount( 2, $assessments['wikiprojects'] ); + } } diff --git a/tests/Model/PageInfoTest.php b/tests/Model/PageInfoTest.php index dd0390b0e..29ee9b783 100644 --- a/tests/Model/PageInfoTest.php +++ b/tests/Model/PageInfoTest.php @@ -1,6 +1,6 @@ getAutomatedEditsHelper(); - /** @var I18nHelper $i18nHelper */ - $i18nHelper = static::getContainer()->get('app.i18n_helper'); - $this->project = $this->getMockEnwikiProject(); - $this->pageRepo = $this->createMock(PageRepository::class); - $this->page = new Page($this->pageRepo, $this->project, 'Test page'); - $this->editRepo = $this->createMock(EditRepository::class); - $this->editRepo->method('getAutoEditsHelper') - ->willReturn($autoEditsHelper); - $this->userRepo = $this->createMock(UserRepository::class); - $this->pageInfoRepo = $this->createMock(PageInfoRepository::class); - $this->pageInfoRepo->method('getMaxPageRevisions') - ->willReturn(static::getContainer()->getParameter('app.max_page_revisions')); - $this->pageInfo = new PageInfo( - $this->pageInfoRepo, - $i18nHelper, - $autoEditsHelper, - $this->page - ); - - // Don't care that private methods "shouldn't" be tested... - // In PageInfo they are all super test-worthy and otherwise fragile. - $this->reflectionClass = new ReflectionClass($this->pageInfo); - } - - /** - * Number of revisions - */ - public function testNumRevisions(): void - { - $this->pageRepo->expects($this->once()) - ->method('getNumRevisions') - ->willReturn(10); - static::assertEquals(10, $this->pageInfo->getNumRevisions()); - // Should be cached (will error out if repo's getNumRevisions is called again). - static::assertEquals(10, $this->pageInfo->getNumRevisions()); - } - - /** - * Number of revisions processed, based on app.max_page_revisions - * @dataProvider revisionsProcessedProvider - * @param int $numRevisions - * @param int $assertion - */ - public function testRevisionsProcessed(int $numRevisions, int $assertion): void - { - $this->pageRepo->method('getNumRevisions')->willReturn($numRevisions); - static::assertEquals( - $this->pageInfo->getNumRevisionsProcessed(), - $assertion - ); - } - - /** - * Data for self::testRevisionsProcessed(). - * @return int[] - */ - public function revisionsProcessedProvider(): array - { - return [ - [1000000, 50000], - [10, 10], - ]; - } - - /** - * Whether there are too many revisions to process. - */ - public function testTooManyRevisions(): void - { - $this->pageRepo->expects($this->once()) - ->method('getNumRevisions') - ->willReturn(1000000); - static::assertTrue($this->pageInfo->tooManyRevisions()); - } - - /** - * Getting the number of edits made to the page by current or former bots. - */ - public function testBotRevisionCount(): void - { - $bots = [ - 'Foo' => [ - 'count' => 3, - 'current' => true, - ], - 'Bar' => [ - 'count' => 12, - 'current' => false, - ], - ]; - - static::assertEquals( - 15, - $this->pageInfo->getBotRevisionCount($bots) - ); - } - - public function testLinksAndRedirects(): void - { - $this->pageRepo->expects($this->once()) - ->method('countLinksAndRedirects') - ->willReturn([ - 'links_ext_count' => 5, - 'links_out_count' => 3, - 'links_in_count' => 10, - 'redirects_count' => 0, - ]); - $this->page->setRepository($this->pageRepo); - static::assertEquals(5, $this->pageInfo->linksExtCount()); - static::assertEquals(3, $this->pageInfo->linksOutCount()); - static::assertEquals(10, $this->pageInfo->linksInCount()); - static::assertEquals(0, $this->pageInfo->redirectsCount()); - } - - /** - * Test some of the more important getters. - */ - public function testGetters(): void - { - $edits = $this->setupData(); - - static::assertEquals(3, $this->pageInfo->getNumEditors()); - static::assertEquals(2, $this->pageInfo->getAnonCount()); - static::assertEquals(40, $this->pageInfo->anonPercentage()); - static::assertEquals(3, $this->pageInfo->getMinorCount()); - static::assertEquals(60, $this->pageInfo->minorPercentage()); - static::assertEquals(1, $this->pageInfo->getBotRevisionCount()); - static::assertEquals(93, $this->pageInfo->getTotalDays()); - static::assertEquals(18, (int) $this->pageInfo->averageDaysPerEdit()); - static::assertEquals(0, (int) $this->pageInfo->editsPerDay()); - static::assertEquals(1.6, $this->pageInfo->editsPerMonth()); - static::assertEquals(5, $this->pageInfo->editsPerYear()); - static::assertEquals(1.7, $this->pageInfo->editsPerEditor()); - static::assertEquals(2, $this->pageInfo->getAutomatedCount()); - static::assertEquals(1, $this->pageInfo->getRevertCount()); - - static::assertEquals(80, $this->pageInfo->topTenPercentage()); - static::assertEquals(4, $this->pageInfo->getTopTenCount()); - - static::assertEquals( - $edits[0]->getId(), - $this->pageInfo->getFirstEdit()->getId() - ); - static::assertEquals( - $edits[4]->getId(), - $this->pageInfo->getLastEdit()->getId() - ); - - static::assertEquals(1, $this->pageInfo->getMaxAddition()->getId()); - static::assertEquals(32, $this->pageInfo->getMaxDeletion()->getId()); - - static::assertEquals( - ['Mick Jagger', '192.168.0.1', '192.168.0.2'], - array_keys($this->pageInfo->getEditors()) - ); - static::assertEquals( - [ - 'label' =>'Mick Jagger', - 'value' => 2, - 'percentage' => 50, - ], - $this->pageInfo->topTenEditorsByEdits()[0] - ); - static::assertEquals( - [ - 'label' =>'Mick Jagger', - 'value' => 30, - 'percentage' => 100, - ], - $this->pageInfo->topTenEditorsByAdded()[0] - ); - - // Top 10 counts should not include bots. - static::assertFalse( - array_search( - 'XtoolsBot', - array_column($this->pageInfo->topTenEditorsByEdits(), 'label') - ) - ); - static::assertFalse( - array_search( - 'XtoolsBot', - array_column($this->pageInfo->topTenEditorsByAdded(), 'label') - ) - ); - - static::assertEquals(['Mick Jagger'], $this->pageInfo->getHumans(1)); - - static::assertEquals(3, $this->pageInfo->getMaxEditsPerMonth()); - - static::assertContains( - 'AutoWikiBrowser', - array_keys($this->pageInfo->getTools()) - ); - - static::assertEquals(1, $this->pageInfo->numDeletedRevisions()); - } - - /** - * Test that the data for each individual month and year is correct. - */ - public function testMonthYearCounts(): void - { - $this->setupData(); - - $yearMonthCounts = $this->pageInfo->getYearMonthCounts(); - - static::assertEquals([2016], array_keys($yearMonthCounts)); - static::assertEquals(['2016'], $this->pageInfo->getYearLabels()); - static::assertArraySubset([ - 'all' => 5, - 'minor' => 3, - 'anon' => 2, - 'automated' => 2, - 'size' => 20, - ], $yearMonthCounts[2016]); - - static::assertEquals( - ['07', '08', '09', '10', '11', '12'], - array_keys($yearMonthCounts[2016]['months']) - ); - static::assertEquals( - ['2016-07', '2016-08', '2016-09', '2016-10', '2016-11', '2016-12'], - $this->pageInfo->getMonthLabels() - ); - - // Just test a few, not every month. - static::assertArraySubset([ - 'all' => 1, - 'minor' => 0, - 'anon' => 0, - 'automated' => 0, - ], $yearMonthCounts[2016]['months']['07']); - static::assertArraySubset([ - 'all' => 3, - 'minor' => 2, - 'anon' => 2, - 'automated' => 2, - ], $yearMonthCounts[2016]['months']['10']); - } - - - /** - * Test data around log events. - */ - public function testLogEvents(): void - { - $this->setupData(); - - $this->pageInfoRepo->expects($this->once()) - ->method('getLogEvents') - ->willReturn([ - [ - 'log_type' => 'protect', - 'timestamp' => '20160705000000', - ], - [ - 'log_type' => 'delete', - 'timestamp' => '20160905000000', - ], - [ - 'log_type' => 'move', - 'timestamp' => '20161005000000', - ], - ]); - - $method = $this->reflectionClass->getMethod('setLogsEvents'); - $method->setAccessible(true); - $method->invoke($this->pageInfo); - - $yearMonthCounts = $this->pageInfo->getYearMonthCounts(); - - // Just test a few, not every month. - static::assertEquals([ - 'protections' => 1, - 'deletions' => 1, - 'moves' => 1, - ], $yearMonthCounts[2016]['events']); - } - - /** - * Use ReflectionClass to set up some data and populate the class properties for testing. - * - * We don't care that private methods "shouldn't" be tested... - * In PageInfo the update methods are all super test-worthy and otherwise fragile. - * - * @return Edit[] Array of Edit objects that represent the revision history. - */ - private function setupData(): array - { - $edits = [ - new Edit($this->editRepo, $this->userRepo, $this->page, [ - 'id' => 1, - 'timestamp' => '20160701101205', - 'minor' => '0', - 'length' => '30', - 'length_change' => '30', - 'username' => 'Mick Jagger', - 'comment' => 'Foo bar', - 'rev_sha1' => 'aaaaaa', - ]), - new Edit($this->editRepo, $this->userRepo, $this->page, [ - 'id' => 32, - 'timestamp' => '20160801000000', - 'minor' => '1', - 'length' => '25', - 'length_change' => '-5', - 'username' => 'Mick Jagger', - 'comment' => 'Blah', - 'rev_sha1' => 'bbbbbb', - ]), - new Edit($this->editRepo, $this->userRepo, $this->page, [ - 'id' => 40, - 'timestamp' => '20161003000000', - 'minor' => '0', - 'length' => '15', - 'length_change' => '-10', - 'username' => '192.168.0.1', - 'comment' => 'Weeee using [[WP:AWB|AWB]]', - 'rev_sha1' => 'cccccc', - ]), - new Edit($this->editRepo, $this->userRepo, $this->page, [ - 'id' => 50, - 'timestamp' => '20161003010000', - 'minor' => '1', - 'length' => '25', - 'length_change' => '10', - 'username' => '192.168.0.2', - 'comment' => 'I undo your edit cuz it bad', - 'rev_sha1' => 'bbbbbb', - ]), - new Edit($this->editRepo, $this->userRepo, $this->page, [ - 'id' => 60, - 'timestamp' => '20161003020000', - 'minor' => '1', - 'length' => '20', - 'length_change' => '-5', - 'username' => 'Offensive username', - 'comment' => 'Weeee using [[WP:AWB|AWB]]', - 'rev_sha1' => 'ddddd', - 'rev_deleted' => Edit::DELETED_USER, - ]), - ]; - - $prevEdits = [ - 'prev' => null, - 'prevSha' => null, - 'maxAddition' => null, - 'maxDeletion' => null, - ]; - - $prop = $this->reflectionClass->getProperty('firstEdit'); - $prop->setAccessible(true); - $prop->setValue($this->pageInfo, $edits[0]); - - $prop = $this->reflectionClass->getProperty('numRevisionsProcessed'); - $prop->setAccessible(true); - $prop->setValue($this->pageInfo, 5); - - $prop = $this->reflectionClass->getProperty('bots'); - $prop->setAccessible(true); - $prop->setValue($this->pageInfo, [ - 'XtoolsBot' => ['count' => 1], - ]); - - $prop = $this->reflectionClass->getProperty('numDeletedRevisions'); - $prop->setAccessible(true); - $prop->setValue($this->pageInfo, 1); - - $method = $this->reflectionClass->getMethod('updateCounts'); - $method->setAccessible(true); - $prevEdits = $method->invoke($this->pageInfo, $edits[0], $prevEdits); - $prevEdits = $method->invoke($this->pageInfo, $edits[1], $prevEdits); - $prevEdits = $method->invoke($this->pageInfo, $edits[2], $prevEdits); - $prevEdits = $method->invoke($this->pageInfo, $edits[3], $prevEdits); - $method->invoke($this->pageInfo, $edits[4], $prevEdits); - - $method = $this->reflectionClass->getMethod('doPostPrecessing'); - $method->setAccessible(true); - $method->invoke($this->pageInfo); - - return $edits; - } - - /** - * Test prose stats parser. - */ - public function testProseStats(): void - { - // We'll use a live page to better test the prose stats parser. - $client = static::getContainer()->get('eight_points_guzzle.client.xtools'); - $ret = $client->request('GET', 'https://en.wikipedia.org/api/rest_v1/page/html/Hanksy/747629772') - ->getBody() - ->getContents(); - $this->pageRepo->expects($this->once()) - ->method('getHTMLContent') - ->willReturn($ret); - $this->page->setRepository($this->pageRepo); - - static::assertEquals([ - 'bytes' => 1539, - 'characters' => 1539, - 'words' => 261, - 'references' => 13, - 'unique_references' => 12, - 'sections' => 2, - ], $this->pageInfo->getProseStats()); - } - - /** - * Various methods involving start/end dates. - */ - public function testWithDates(): void - { - $this->setupData(); - - $prop = $this->reflectionClass->getProperty('start'); - $prop->setAccessible(true); - $prop->setValue($this->pageInfo, strtotime('2016-06-30')); - - $prop = $this->reflectionClass->getProperty('end'); - $prop->setAccessible(true); - $prop->setValue($this->pageInfo, strtotime('2016-10-14')); - - static::assertTrue($this->pageInfo->hasDateRange()); - static::assertEquals('2016-06-30', $this->pageInfo->getStartDate()); - static::assertEquals('2016-10-14', $this->pageInfo->getEndDate()); - static::assertEquals([ - 'start' => '2016-06-30', - 'end' => '2016-10-14', - ], $this->pageInfo->getDateParams()); - - // Uses length of last edit because there is a date range. - static::assertEquals(20, $this->pageInfo->getLength()); - - // Pageviews with a date range. - $this->pageRepo->expects($this->once()) - ->method('getPageviews') - ->with($this->page, '2016-06-30', '2016-10-14') - ->willReturn([ - 'items' => [ - ['views' => 1000], - ['views' => 500], - ], - ]); - static::assertEquals(1500, $this->pageInfo->getPageviews()['count']); - } - - /** - * Transclusion counts. - */ - public function testTransclusionData(): void - { - $pageInfoRepo = $this->createMock(PageInfoRepository::class); - $pageInfoRepo->expects(static::once()) - ->method('getTransclusionData') - ->willReturn([ - 'categories' => 3, - 'templates' => 5, - 'files' => 2, - ]); - $this->pageInfo->setRepository($pageInfoRepo); - - static::assertEquals(3, $this->pageInfo->getNumCategories()); - static::assertEquals(5, $this->pageInfo->getNumTemplates()); - static::assertEquals(2, $this->pageInfo->getNumFiles()); - } - - public function testPageviews(): void - { - $this->pageRepo->expects($this->once()) - ->method('getPageviews') - ->willReturn([ - 'items' => [ - ['views' => 1000], - ['views' => 500], - ], - ]); - - static::assertEquals([ - 'count' => 1500, - 'formatted' => '1,500', - 'tooltip' => '', - ], $this->pageInfo->getPageviews()); - - static::assertEquals(PageInfoApi::PAGEVIEWS_OFFSET, $this->pageInfo->getPageviewsOffset()); - } - - public function testPageviewsFailing(): void - { - $this->pageRepo->expects($this->once()) - ->method('getPageviews') - ->willThrowException($this->createMock(BadGatewayException::class)); - - static::assertEquals([ - 'count' => null, - 'formatted' => 'Data unavailable', - 'tooltip' => 'There was an error connecting to the Pageviews API. ' . - 'Try refreshing this page or try again later.', - ], $this->pageInfo->getPageviews()); - } +class PageInfoTest extends TestAdapter { + use ArraySubsetAsserts; + + protected PageInfo $pageInfo; + protected PageInfoRepository $pageInfoRepo; + protected EditRepository $editRepo; + protected Page $page; + protected PageRepository $pageRepo; + protected Project $project; + protected UserRepository $userRepo; + + /** @var ReflectionClass Hack to test private methods. */ + private ReflectionClass $reflectionClass; + + /** + * Set up shared mocks and class instances. + */ + public function setUp(): void { + $autoEditsHelper = $this->getAutomatedEditsHelper(); + /** @var I18nHelper $i18nHelper */ + $i18nHelper = static::getContainer()->get( 'app.i18n_helper' ); + $this->project = $this->getMockEnwikiProject(); + $this->pageRepo = $this->createMock( PageRepository::class ); + $this->page = new Page( $this->pageRepo, $this->project, 'Test page' ); + $this->editRepo = $this->createMock( EditRepository::class ); + $this->editRepo->method( 'getAutoEditsHelper' ) + ->willReturn( $autoEditsHelper ); + $this->userRepo = $this->createMock( UserRepository::class ); + $this->pageInfoRepo = $this->createMock( PageInfoRepository::class ); + $this->pageInfoRepo->method( 'getMaxPageRevisions' ) + ->willReturn( static::getContainer()->getParameter( 'app.max_page_revisions' ) ); + $this->pageInfo = new PageInfo( + $this->pageInfoRepo, + $i18nHelper, + $autoEditsHelper, + $this->page + ); + + // Don't care that private methods "shouldn't" be tested... + // In PageInfo they are all super test-worthy and otherwise fragile. + $this->reflectionClass = new ReflectionClass( $this->pageInfo ); + } + + /** + * Number of revisions + */ + public function testNumRevisions(): void { + $this->pageRepo->expects( $this->once() ) + ->method( 'getNumRevisions' ) + ->willReturn( 10 ); + static::assertEquals( 10, $this->pageInfo->getNumRevisions() ); + // Should be cached (will error out if repo's getNumRevisions is called again). + static::assertEquals( 10, $this->pageInfo->getNumRevisions() ); + } + + /** + * Number of revisions processed, based on app.max_page_revisions + * @dataProvider revisionsProcessedProvider + * @param int $numRevisions + * @param int $assertion + */ + public function testRevisionsProcessed( int $numRevisions, int $assertion ): void { + $this->pageRepo->method( 'getNumRevisions' )->willReturn( $numRevisions ); + static::assertEquals( + $this->pageInfo->getNumRevisionsProcessed(), + $assertion + ); + } + + /** + * Data for self::testRevisionsProcessed(). + * @return int[] + */ + public function revisionsProcessedProvider(): array { + return [ + [ 1000000, 50000 ], + [ 10, 10 ], + ]; + } + + /** + * Whether there are too many revisions to process. + */ + public function testTooManyRevisions(): void { + $this->pageRepo->expects( $this->once() ) + ->method( 'getNumRevisions' ) + ->willReturn( 1000000 ); + static::assertTrue( $this->pageInfo->tooManyRevisions() ); + } + + /** + * Getting the number of edits made to the page by current or former bots. + */ + public function testBotRevisionCount(): void { + $bots = [ + 'Foo' => [ + 'count' => 3, + 'current' => true, + ], + 'Bar' => [ + 'count' => 12, + 'current' => false, + ], + ]; + + static::assertEquals( + 15, + $this->pageInfo->getBotRevisionCount( $bots ) + ); + } + + public function testLinksAndRedirects(): void { + $this->pageRepo->expects( $this->once() ) + ->method( 'countLinksAndRedirects' ) + ->willReturn( [ + 'links_ext_count' => 5, + 'links_out_count' => 3, + 'links_in_count' => 10, + 'redirects_count' => 0, + ] ); + $this->page->setRepository( $this->pageRepo ); + static::assertEquals( 5, $this->pageInfo->linksExtCount() ); + static::assertEquals( 3, $this->pageInfo->linksOutCount() ); + static::assertEquals( 10, $this->pageInfo->linksInCount() ); + static::assertSame( 0, $this->pageInfo->redirectsCount() ); + } + + /** + * Test some of the more important getters. + */ + public function testGetters(): void { + $edits = $this->setupData(); + + static::assertEquals( 3, $this->pageInfo->getNumEditors() ); + static::assertEquals( 2, $this->pageInfo->getAnonCount() ); + static::assertEquals( 40, $this->pageInfo->anonPercentage() ); + static::assertEquals( 3, $this->pageInfo->getMinorCount() ); + static::assertEquals( 60, $this->pageInfo->minorPercentage() ); + static::assertSame( 1, $this->pageInfo->getBotRevisionCount() ); + static::assertEquals( 93, $this->pageInfo->getTotalDays() ); + static::assertEquals( 18, (int)$this->pageInfo->averageDaysPerEdit() ); + static::assertSame( 0, (int)$this->pageInfo->editsPerDay() ); + static::assertEquals( 1.6, $this->pageInfo->editsPerMonth() ); + static::assertEquals( 5, $this->pageInfo->editsPerYear() ); + static::assertEquals( 1.7, $this->pageInfo->editsPerEditor() ); + static::assertEquals( 2, $this->pageInfo->getAutomatedCount() ); + static::assertSame( 1, $this->pageInfo->getRevertCount() ); + + static::assertEquals( 80, $this->pageInfo->topTenPercentage() ); + static::assertEquals( 4, $this->pageInfo->getTopTenCount() ); + + static::assertEquals( + $edits[0]->getId(), + $this->pageInfo->getFirstEdit()->getId() + ); + static::assertEquals( + $edits[4]->getId(), + $this->pageInfo->getLastEdit()->getId() + ); + + static::assertSame( 1, $this->pageInfo->getMaxAddition()->getId() ); + static::assertEquals( 32, $this->pageInfo->getMaxDeletion()->getId() ); + + static::assertEquals( + [ 'Mick Jagger', '192.168.0.1', '192.168.0.2' ], + array_keys( $this->pageInfo->getEditors() ) + ); + static::assertEquals( + [ + 'label' => 'Mick Jagger', + 'value' => 2, + 'percentage' => 50, + ], + $this->pageInfo->topTenEditorsByEdits()[0] + ); + static::assertEquals( + [ + 'label' => 'Mick Jagger', + 'value' => 30, + 'percentage' => 100, + ], + $this->pageInfo->topTenEditorsByAdded()[0] + ); + + // Top 10 counts should not include bots. + static::assertFalse( + array_search( + 'XtoolsBot', + array_column( $this->pageInfo->topTenEditorsByEdits(), 'label' ) + ) + ); + static::assertFalse( + array_search( + 'XtoolsBot', + array_column( $this->pageInfo->topTenEditorsByAdded(), 'label' ) + ) + ); + + static::assertEquals( [ 'Mick Jagger' ], $this->pageInfo->getHumans( 1 ) ); + + static::assertEquals( 3, $this->pageInfo->getMaxEditsPerMonth() ); + + static::assertContains( + 'AutoWikiBrowser', + array_keys( $this->pageInfo->getTools() ) + ); + + static::assertSame( 1, $this->pageInfo->numDeletedRevisions() ); + } + + /** + * Test that the data for each individual month and year is correct. + */ + public function testMonthYearCounts(): void { + $this->setupData(); + + $yearMonthCounts = $this->pageInfo->getYearMonthCounts(); + + static::assertEquals( [ 2016 ], array_keys( $yearMonthCounts ) ); + static::assertEquals( [ '2016' ], $this->pageInfo->getYearLabels() ); + static::assertArraySubset( [ + 'all' => 5, + 'minor' => 3, + 'anon' => 2, + 'automated' => 2, + 'size' => 20, + ], $yearMonthCounts[2016] ); + + static::assertEquals( + [ '07', '08', '09', '10', '11', '12' ], + array_keys( $yearMonthCounts[2016]['months'] ) + ); + static::assertEquals( + [ '2016-07', '2016-08', '2016-09', '2016-10', '2016-11', '2016-12' ], + $this->pageInfo->getMonthLabels() + ); + + // Just test a few, not every month. + static::assertArraySubset( [ + 'all' => 1, + 'minor' => 0, + 'anon' => 0, + 'automated' => 0, + ], $yearMonthCounts[2016]['months']['07'] ); + static::assertArraySubset( [ + 'all' => 3, + 'minor' => 2, + 'anon' => 2, + 'automated' => 2, + ], $yearMonthCounts[2016]['months']['10'] ); + } + + /** + * Test data around log events. + */ + public function testLogEvents(): void { + $this->setupData(); + + $this->pageInfoRepo->expects( $this->once() ) + ->method( 'getLogEvents' ) + ->willReturn( [ + [ + 'log_type' => 'protect', + 'timestamp' => '20160705000000', + ], + [ + 'log_type' => 'delete', + 'timestamp' => '20160905000000', + ], + [ + 'log_type' => 'move', + 'timestamp' => '20161005000000', + ], + ] ); + + $method = $this->reflectionClass->getMethod( 'setLogsEvents' ); + $method->setAccessible( true ); + $method->invoke( $this->pageInfo ); + + $yearMonthCounts = $this->pageInfo->getYearMonthCounts(); + + // Just test a few, not every month. + static::assertEquals( [ + 'protections' => 1, + 'deletions' => 1, + 'moves' => 1, + ], $yearMonthCounts[2016]['events'] ); + } + + /** + * Use ReflectionClass to set up some data and populate the class properties for testing. + * + * We don't care that private methods "shouldn't" be tested... + * In PageInfo the update methods are all super test-worthy and otherwise fragile. + * + * @return Edit[] Array of Edit objects that represent the revision history. + */ + private function setupData(): array { + $edits = [ + new Edit( $this->editRepo, $this->userRepo, $this->page, [ + 'id' => 1, + 'timestamp' => '20160701101205', + 'minor' => '0', + 'length' => '30', + 'length_change' => '30', + 'username' => 'Mick Jagger', + 'comment' => 'Foo bar', + 'rev_sha1' => 'aaaaaa', + ] ), + new Edit( $this->editRepo, $this->userRepo, $this->page, [ + 'id' => 32, + 'timestamp' => '20160801000000', + 'minor' => '1', + 'length' => '25', + 'length_change' => '-5', + 'username' => 'Mick Jagger', + 'comment' => 'Blah', + 'rev_sha1' => 'bbbbbb', + ] ), + new Edit( $this->editRepo, $this->userRepo, $this->page, [ + 'id' => 40, + 'timestamp' => '20161003000000', + 'minor' => '0', + 'length' => '15', + 'length_change' => '-10', + 'username' => '192.168.0.1', + 'comment' => 'Weeee using [[WP:AWB|AWB]]', + 'rev_sha1' => 'cccccc', + ] ), + new Edit( $this->editRepo, $this->userRepo, $this->page, [ + 'id' => 50, + 'timestamp' => '20161003010000', + 'minor' => '1', + 'length' => '25', + 'length_change' => '10', + 'username' => '192.168.0.2', + 'comment' => 'I undo your edit cuz it bad', + 'rev_sha1' => 'bbbbbb', + ] ), + new Edit( $this->editRepo, $this->userRepo, $this->page, [ + 'id' => 60, + 'timestamp' => '20161003020000', + 'minor' => '1', + 'length' => '20', + 'length_change' => '-5', + 'username' => 'Offensive username', + 'comment' => 'Weeee using [[WP:AWB|AWB]]', + 'rev_sha1' => 'ddddd', + 'rev_deleted' => Edit::DELETED_USER, + ] ), + ]; + + $prevEdits = [ + 'prev' => null, + 'prevSha' => null, + 'maxAddition' => null, + 'maxDeletion' => null, + ]; + + $prop = $this->reflectionClass->getProperty( 'firstEdit' ); + $prop->setAccessible( true ); + $prop->setValue( $this->pageInfo, $edits[0] ); + + $prop = $this->reflectionClass->getProperty( 'numRevisionsProcessed' ); + $prop->setAccessible( true ); + $prop->setValue( $this->pageInfo, 5 ); + + $prop = $this->reflectionClass->getProperty( 'bots' ); + $prop->setAccessible( true ); + $prop->setValue( $this->pageInfo, [ + 'XtoolsBot' => [ 'count' => 1 ], + ] ); + + $prop = $this->reflectionClass->getProperty( 'numDeletedRevisions' ); + $prop->setAccessible( true ); + $prop->setValue( $this->pageInfo, 1 ); + + $method = $this->reflectionClass->getMethod( 'updateCounts' ); + $method->setAccessible( true ); + $prevEdits = $method->invoke( $this->pageInfo, $edits[0], $prevEdits ); + $prevEdits = $method->invoke( $this->pageInfo, $edits[1], $prevEdits ); + $prevEdits = $method->invoke( $this->pageInfo, $edits[2], $prevEdits ); + $prevEdits = $method->invoke( $this->pageInfo, $edits[3], $prevEdits ); + $method->invoke( $this->pageInfo, $edits[4], $prevEdits ); + + $method = $this->reflectionClass->getMethod( 'doPostPrecessing' ); + $method->setAccessible( true ); + $method->invoke( $this->pageInfo ); + + return $edits; + } + + /** + * Test prose stats parser. + */ + public function testProseStats(): void { + // We'll use a live page to better test the prose stats parser. + $client = static::getContainer()->get( 'eight_points_guzzle.client.xtools' ); + $ret = $client->request( 'GET', 'https://en.wikipedia.org/api/rest_v1/page/html/Hanksy/747629772' ) + ->getBody() + ->getContents(); + $this->pageRepo->expects( $this->once() ) + ->method( 'getHTMLContent' ) + ->willReturn( $ret ); + $this->page->setRepository( $this->pageRepo ); + + static::assertEquals( [ + 'bytes' => 1539, + 'characters' => 1539, + 'words' => 261, + 'references' => 13, + 'unique_references' => 12, + 'sections' => 2, + ], $this->pageInfo->getProseStats() ); + } + + /** + * Various methods involving start/end dates. + */ + public function testWithDates(): void { + $this->setupData(); + + $prop = $this->reflectionClass->getProperty( 'start' ); + $prop->setAccessible( true ); + $prop->setValue( $this->pageInfo, strtotime( '2016-06-30' ) ); + + $prop = $this->reflectionClass->getProperty( 'end' ); + $prop->setAccessible( true ); + $prop->setValue( $this->pageInfo, strtotime( '2016-10-14' ) ); + + static::assertTrue( $this->pageInfo->hasDateRange() ); + static::assertEquals( '2016-06-30', $this->pageInfo->getStartDate() ); + static::assertEquals( '2016-10-14', $this->pageInfo->getEndDate() ); + static::assertEquals( [ + 'start' => '2016-06-30', + 'end' => '2016-10-14', + ], $this->pageInfo->getDateParams() ); + + // Uses length of last edit because there is a date range. + static::assertEquals( 20, $this->pageInfo->getLength() ); + + // Pageviews with a date range. + $this->pageRepo->expects( $this->once() ) + ->method( 'getPageviews' ) + ->with( $this->page, '2016-06-30', '2016-10-14' ) + ->willReturn( [ + 'items' => [ + [ 'views' => 1000 ], + [ 'views' => 500 ], + ], + ] ); + static::assertEquals( 1500, $this->pageInfo->getPageviews()['count'] ); + } + + /** + * Transclusion counts. + */ + public function testTransclusionData(): void { + $pageInfoRepo = $this->createMock( PageInfoRepository::class ); + $pageInfoRepo->expects( static::once() ) + ->method( 'getTransclusionData' ) + ->willReturn( [ + 'categories' => 3, + 'templates' => 5, + 'files' => 2, + ] ); + $this->pageInfo->setRepository( $pageInfoRepo ); + + static::assertEquals( 3, $this->pageInfo->getNumCategories() ); + static::assertEquals( 5, $this->pageInfo->getNumTemplates() ); + static::assertEquals( 2, $this->pageInfo->getNumFiles() ); + } + + public function testPageviews(): void { + $this->pageRepo->expects( $this->once() ) + ->method( 'getPageviews' ) + ->willReturn( [ + 'items' => [ + [ 'views' => 1000 ], + [ 'views' => 500 ], + ], + ] ); + + static::assertEquals( [ + 'count' => 1500, + 'formatted' => '1,500', + 'tooltip' => '', + ], $this->pageInfo->getPageviews() ); + + static::assertEquals( PageInfoApi::PAGEVIEWS_OFFSET, $this->pageInfo->getPageviewsOffset() ); + } + + public function testPageviewsFailing(): void { + $this->pageRepo->expects( $this->once() ) + ->method( 'getPageviews' ) + ->willThrowException( $this->createMock( BadGatewayException::class ) ); + + static::assertEquals( [ + 'count' => null, + 'formatted' => 'Data unavailable', + 'tooltip' => 'There was an error connecting to the Pageviews API. ' . + 'Try refreshing this page or try again later.', + ], $this->pageInfo->getPageviews() ); + } } diff --git a/tests/Model/PageTest.php b/tests/Model/PageTest.php index 9344e0120..42e451050 100644 --- a/tests/Model/PageTest.php +++ b/tests/Model/PageTest.php @@ -1,6 +1,6 @@ pageRepo = $this->createMock(PageRepository::class); - } - - /** - * A page has a title and an HTML display title. - */ - public function testTitles(): void - { - $project = new Project('TestProject'); - $data = [ - [$project, 'Test_Page_1', ['title' => 'Test_Page_1']], - [$project, 'Test_Page_2', ['title' => 'Test_Page_2', 'displaytitle' => 'Test page 2']], - ]; - $this->pageRepo->method('getPageInfo')->will($this->returnValueMap($data)); - - // Page with no display title. - $page = new Page($this->pageRepo, $project, 'Test_Page_1'); - static::assertEquals('Test_Page_1', $page->getTitle()); - static::assertEquals('Test_Page_1', $page->getDisplayTitle()); - - // Page with a display title. - $page = new Page($this->pageRepo, $project, 'Test_Page_2'); - static::assertEquals('Test_Page_2', $page->getTitle()); - static::assertEquals('Test page 2', $page->getDisplayTitle()); - - // Getting the unnormalized title should not call getPageInfo. - $page = new Page($this->pageRepo, $project, 'talk:Test Page_3'); - $this->pageRepo->expects($this->never())->method('getPageInfo'); - static::assertEquals('talk:Test Page_3', $page->getTitle(true)); - } - - /** - * A page either exists or doesn't. - */ - public function testExists(): void - { - $pageRepo = $this->createMock(PageRepository::class); - $project = new Project('TestProject'); - // Mock data (last element of each array is the return value). - $data = [ - [$project, 'Existing_page', []], - [$project, 'Missing_page', ['missing' => '']], - ]; - $pageRepo //->expects($this->exactly(2)) - ->method('getPageInfo') - ->will($this->returnValueMap($data)); - - // Existing page. - $page1 = new Page($this->pageRepo, $project, 'Existing_page'); - $page1->setRepository($pageRepo); - static::assertTrue($page1->exists()); - - // Missing page. - $page2 = new Page($this->pageRepo, $project, 'Missing_page'); - $page2->setRepository($pageRepo); - static::assertFalse($page2->exists()); - } - - /** - * Test basic getters - */ - public function testBasicGetters(): void - { - $project = $this->createMock(Project::class); - $project->method('getNamespaces') - ->willReturn([ - '', - 'Talk', - 'User', - ]); - - $pageRepo = $this->createMock(PageRepository::class); - $pageRepo->expects($this->once()) - ->method('getPageInfo') - ->willReturn([ - 'pageid' => '42', - 'fullurl' => 'https://example.org/User:Test:123', - 'watchers' => 5000, - 'ns' => 2, - 'length' => 300, - 'pageprops' => [ - 'wikibase_item' => 'Q95', - ], - ]); - $page = new Page($this->pageRepo, $project, 'User:Test:123'); - $page->setRepository($pageRepo); - - static::assertEquals(42, $page->getId()); - static::assertEquals('https://example.org/User:Test:123', $page->getUrl()); - static::assertEquals(5000, $page->getWatchers()); - static::assertEquals(300, $page->getLength()); - static::assertEquals(2, $page->getNamespace()); - static::assertEquals('User', $page->getNamespaceName()); - static::assertEquals('Q95', $page->getWikidataId()); - static::assertEquals('Test:123', $page->getTitleWithoutNamespace()); - } - - /** - * Test fetching of wikitext - */ - public function testWikitext(): void - { - $pageRepo = $this->getRealPageRepository(); - $page = new Page($pageRepo, $this->getMockEnwikiProject(), 'Main Page'); - - // We want to do a real-world test. enwiki's Main Page does not change much, - // and {{Main Page banner}} in particular should be there indefinitely, hopefully :) - $content = $page->getWikitext(); - static::assertStringContainsString('{{Main Page banner}}', $content); - } - - /** - * Tests wikidata item getter. - */ - public function testWikidataItems(): void - { - $wikidataItems = [ - [ - 'ips_site_id' => 'enwiki', - 'ips_site_page' => 'Google', - ], - [ - 'ips_site_id' => 'arwiki', - 'ips_site_page' => 'جوجل', - ], - ]; - - $pageRepo = $this->createMock(PageRepository::class); - $pageRepo->method('getPageInfo') - ->willReturn([ - 'pageprops' => [ - 'wikibase_item' => 'Q95', - ], - ]); - $pageRepo->expects($this->once()) - ->method('getWikidataItems') - ->willReturn($wikidataItems); - $page = new Page($this->pageRepo, new Project('TestProject'), 'Test_Page'); - $page->setRepository($pageRepo); - - static::assertArraySubset($wikidataItems, $page->getWikidataItems()); - - // If no wikidata item... - $pageRepo2 = $this->createMock(PageRepository::class); - $pageRepo2->expects($this->once()) - ->method('getPageInfo') - ->willReturn([ - 'pageprops' => [], - ]); - $page2 = new Page($this->pageRepo, new Project('TestProject'), 'Test_Page'); - $page2->setRepository($pageRepo2); - static::assertNull($page2->getWikidataId()); - static::assertEquals(0, $page2->countWikidataItems()); - } - - /** - * Tests wikidata item counter. - */ - public function testCountWikidataItems(): void - { - $page = new Page($this->pageRepo, new Project('TestProject'), 'Test_Page'); - $this->pageRepo->method('countWikidataItems') - ->with($page) - ->willReturn(2); - - static::assertEquals(2, $page->countWikidataItems()); - } - - /** - * Fetching of revisions. - */ - public function testUsersEdits(): void - { - $this->pageRepo->method('getRevisions') - ->with() - ->willReturn([ - [ - 'id' => '1', - 'timestamp' => '20170505100000', - 'length_change' => '1', - 'comment' => 'One', - ], - [ - 'id' => '2', - 'timestamp' => '20170506100000', - 'length_change' => '2', - 'comment' => 'Two', - ], - ]); - $page = new Page($this->pageRepo, new Project('exampleWiki'), 'Page'); - $user = new User($this->createMock(UserRepository::class), 'Testuser'); - static::assertCount(2, $page->getRevisions($user)); - static::assertEquals(2, $page->getNumRevisions()); - } - - /** - * Test getErros and getCheckWikiErrors. - */ - public function testErrors(): void - { - $this->markTestSkipped('Broken until T413013 is fixed'); - $checkWikiErrors = [ - [ - 'error' => '61', - 'notice' => 'This is where the error is', - 'found' => '2017-08-09 00:05:09', - 'name' => 'Reference before punctuation', - 'prio' => '3', - 'explanation' => 'This is how to fix the error', - ], - ]; - - $this->pageRepo->method('getCheckWikiErrors') - ->willReturn($checkWikiErrors); - $this->pageRepo->method('getPageInfo') - ->willReturn([ - 'pagelanguage' => 'en', - 'pageprops' => [ - 'wikibase_item' => 'Q123', - ], - ]); - $page = new Page($this->pageRepo, new Project('exampleWiki'), 'Page'); - $page->setRepository($this->pageRepo); - - static::assertEquals($checkWikiErrors, $page->getCheckWikiErrors()); - static::assertEquals($checkWikiErrors, $page->getErrors()); - } - - /** - * Tests for pageviews-related functions - */ - public function testPageviews(): void - { - $pageviewsData = [ - 'items' => [ - ['views' => 2500], - ['views' => 1000], - ], - ]; - - $this->pageRepo->method('getPageviews')->willReturn($pageviewsData); - $page = new Page($this->pageRepo, new Project('exampleWiki'), 'Page'); - $page->setRepository($this->pageRepo); - - static::assertEquals( - 3500, - $page->getPageviews('20160101', '20160201') - ); - - static::assertEquals(3500, $page->getLatestPageviews(30)); - - // When the API fails. - $this->pageRepo->expects($this->once()) - ->method('getPageviews') - ->willThrowException($this->createMock(BadGatewayException::class)); - static::assertNull($page->getPageviews('20230101', '20230131')); - } - - /** - * Is the page the Main Page? - */ - public function testIsMainPage(): void - { - $pageRepo = $this->getRealPageRepository(); - $page = new Page($pageRepo, $this->getMockEnwikiProject(), 'Main Page'); - static::assertTrue($page->isMainPage()); - } - - /** - * Links and redirects. - */ - public function testLinksAndRedirects(): void - { - $data = [ - 'links_ext_count' => '418', - 'links_out_count' => '1085', - 'links_in_count' => '33300', - 'redirects_count' => '61', - ]; - $pageRepo = $this->createMock(PageRepository::class); - $pageRepo->method('countLinksAndRedirects')->willReturn($data); - $page = new Page($this->pageRepo, new Project('exampleWiki'), 'Page'); - $page->setRepository($pageRepo); - - static::assertEquals($data, $page->countLinksAndRedirects()); - } - - private function getRealPageRepository(): PageRepository - { - static::createClient(); - return new PageRepository( - static::getContainer()->get('doctrine'), - static::getContainer()->get('cache.app'), - static::getContainer()->get('eight_points_guzzle.client.xtools'), - $this->createMock(LoggerInterface::class), - static::getContainer()->get('parameter_bag'), - true, - 30 - ); - } +class PageTest extends TestAdapter { + use ArraySubsetAsserts; + + protected PageRepository $pageRepo; + + /** + * Set up client and set container. + */ + public function setUp(): void { + $this->pageRepo = $this->createMock( PageRepository::class ); + } + + /** + * A page has a title and an HTML display title. + */ + public function testTitles(): void { + $project = new Project( 'TestProject' ); + $data = [ + [ $project, 'Test_Page_1', [ 'title' => 'Test_Page_1' ] ], + [ $project, 'Test_Page_2', [ 'title' => 'Test_Page_2', 'displaytitle' => 'Test page 2' ] ], + ]; + $this->pageRepo->method( 'getPageInfo' )->willReturnMap( $data ); + + // Page with no display title. + $page = new Page( $this->pageRepo, $project, 'Test_Page_1' ); + static::assertEquals( 'Test_Page_1', $page->getTitle() ); + static::assertEquals( 'Test_Page_1', $page->getDisplayTitle() ); + + // Page with a display title. + $page = new Page( $this->pageRepo, $project, 'Test_Page_2' ); + static::assertEquals( 'Test_Page_2', $page->getTitle() ); + static::assertEquals( 'Test page 2', $page->getDisplayTitle() ); + + // Getting the unnormalized title should not call getPageInfo. + $page = new Page( $this->pageRepo, $project, 'talk:Test Page_3' ); + $this->pageRepo->expects( $this->never() )->method( 'getPageInfo' ); + static::assertEquals( 'talk:Test Page_3', $page->getTitle( true ) ); + } + + /** + * A page either exists or doesn't. + */ + public function testExists(): void { + $pageRepo = $this->createMock( PageRepository::class ); + $project = new Project( 'TestProject' ); + // Mock data (last element of each array is the return value). + $data = [ + [ $project, 'Existing_page', [] ], + [ $project, 'Missing_page', [ 'missing' => '' ] ], + ]; + $pageRepo + ->method( 'getPageInfo' ) + ->willReturnMap( $data ); + + // Existing page. + $page1 = new Page( $this->pageRepo, $project, 'Existing_page' ); + $page1->setRepository( $pageRepo ); + static::assertTrue( $page1->exists() ); + + // Missing page. + $page2 = new Page( $this->pageRepo, $project, 'Missing_page' ); + $page2->setRepository( $pageRepo ); + static::assertFalse( $page2->exists() ); + } + + /** + * Test basic getters + */ + public function testBasicGetters(): void { + $project = $this->createMock( Project::class ); + $project->method( 'getNamespaces' ) + ->willReturn( [ + '', + 'Talk', + 'User', + ] ); + + $pageRepo = $this->createMock( PageRepository::class ); + $pageRepo->expects( $this->once() ) + ->method( 'getPageInfo' ) + ->willReturn( [ + 'pageid' => '42', + 'fullurl' => 'https://example.org/User:Test:123', + 'watchers' => 5000, + 'ns' => 2, + 'length' => 300, + 'pageprops' => [ + 'wikibase_item' => 'Q95', + ], + ] ); + $page = new Page( $this->pageRepo, $project, 'User:Test:123' ); + $page->setRepository( $pageRepo ); + + static::assertEquals( 42, $page->getId() ); + static::assertEquals( 'https://example.org/User:Test:123', $page->getUrl() ); + static::assertEquals( 5000, $page->getWatchers() ); + static::assertEquals( 300, $page->getLength() ); + static::assertEquals( 2, $page->getNamespace() ); + static::assertEquals( 'User', $page->getNamespaceName() ); + static::assertEquals( 'Q95', $page->getWikidataId() ); + static::assertEquals( 'Test:123', $page->getTitleWithoutNamespace() ); + } + + /** + * Test fetching of wikitext + */ + public function testWikitext(): void { + $pageRepo = $this->getRealPageRepository(); + $page = new Page( $pageRepo, $this->getMockEnwikiProject(), 'Main Page' ); + + // We want to do a real-world test. enwiki's Main Page does not change much, + // and {{Main Page banner}} in particular should be there indefinitely, hopefully :) + $content = $page->getWikitext(); + static::assertStringContainsString( '{{Main Page banner}}', $content ); + } + + /** + * Tests wikidata item getter. + */ + public function testWikidataItems(): void { + $wikidataItems = [ + [ + 'ips_site_id' => 'enwiki', + 'ips_site_page' => 'Google', + ], + [ + 'ips_site_id' => 'arwiki', + 'ips_site_page' => 'جوجل', + ], + ]; + + $pageRepo = $this->createMock( PageRepository::class ); + $pageRepo->method( 'getPageInfo' ) + ->willReturn( [ + 'pageprops' => [ + 'wikibase_item' => 'Q95', + ], + ] ); + $pageRepo->expects( $this->once() ) + ->method( 'getWikidataItems' ) + ->willReturn( $wikidataItems ); + $page = new Page( $this->pageRepo, new Project( 'TestProject' ), 'Test_Page' ); + $page->setRepository( $pageRepo ); + + static::assertArraySubset( $wikidataItems, $page->getWikidataItems() ); + + // If no wikidata item... + $pageRepo2 = $this->createMock( PageRepository::class ); + $pageRepo2->expects( $this->once() ) + ->method( 'getPageInfo' ) + ->willReturn( [ + 'pageprops' => [], + ] ); + $page2 = new Page( $this->pageRepo, new Project( 'TestProject' ), 'Test_Page' ); + $page2->setRepository( $pageRepo2 ); + static::assertNull( $page2->getWikidataId() ); + static::assertSame( 0, $page2->countWikidataItems() ); + } + + /** + * Tests wikidata item counter. + */ + public function testCountWikidataItems(): void { + $page = new Page( $this->pageRepo, new Project( 'TestProject' ), 'Test_Page' ); + $this->pageRepo->method( 'countWikidataItems' ) + ->with( $page ) + ->willReturn( 2 ); + + static::assertEquals( 2, $page->countWikidataItems() ); + } + + /** + * Fetching of revisions. + */ + public function testUsersEdits(): void { + $this->pageRepo->method( 'getRevisions' ) + ->with() + ->willReturn( [ + [ + 'id' => '1', + 'timestamp' => '20170505100000', + 'length_change' => '1', + 'comment' => 'One', + ], + [ + 'id' => '2', + 'timestamp' => '20170506100000', + 'length_change' => '2', + 'comment' => 'Two', + ], + ] ); + $page = new Page( $this->pageRepo, new Project( 'exampleWiki' ), 'Page' ); + $user = new User( $this->createMock( UserRepository::class ), 'Testuser' ); + static::assertCount( 2, $page->getRevisions( $user ) ); + static::assertEquals( 2, $page->getNumRevisions() ); + } + + /** + * Test getErros and getCheckWikiErrors. + */ + public function testErrors(): void { + $this->markTestSkipped( 'Broken until T413013 is fixed' ); + $checkWikiErrors = [ + [ + 'error' => '61', + 'notice' => 'This is where the error is', + 'found' => '2017-08-09 00:05:09', + 'name' => 'Reference before punctuation', + 'prio' => '3', + 'explanation' => 'This is how to fix the error', + ], + ]; + + $this->pageRepo->method( 'getCheckWikiErrors' ) + ->willReturn( $checkWikiErrors ); + $this->pageRepo->method( 'getPageInfo' ) + ->willReturn( [ + 'pagelanguage' => 'en', + 'pageprops' => [ + 'wikibase_item' => 'Q123', + ], + ] ); + $page = new Page( $this->pageRepo, new Project( 'exampleWiki' ), 'Page' ); + $page->setRepository( $this->pageRepo ); + + static::assertEquals( $checkWikiErrors, $page->getCheckWikiErrors() ); + static::assertEquals( $checkWikiErrors, $page->getErrors() ); + } + + /** + * Tests for pageviews-related functions + */ + public function testPageviews(): void { + $pageviewsData = [ + 'items' => [ + [ 'views' => 2500 ], + [ 'views' => 1000 ], + ], + ]; + + $this->pageRepo->method( 'getPageviews' )->willReturn( $pageviewsData ); + $page = new Page( $this->pageRepo, new Project( 'exampleWiki' ), 'Page' ); + $page->setRepository( $this->pageRepo ); + + static::assertEquals( + 3500, + $page->getPageviews( '20160101', '20160201' ) + ); + + static::assertEquals( 3500, $page->getLatestPageviews( 30 ) ); + + // When the API fails. + $this->pageRepo->expects( $this->once() ) + ->method( 'getPageviews' ) + ->willThrowException( $this->createMock( BadGatewayException::class ) ); + static::assertNull( $page->getPageviews( '20230101', '20230131' ) ); + } + + /** + * Is the page the Main Page? + */ + public function testIsMainPage(): void { + $pageRepo = $this->getRealPageRepository(); + $page = new Page( $pageRepo, $this->getMockEnwikiProject(), 'Main Page' ); + static::assertTrue( $page->isMainPage() ); + } + + /** + * Links and redirects. + */ + public function testLinksAndRedirects(): void { + $data = [ + 'links_ext_count' => '418', + 'links_out_count' => '1085', + 'links_in_count' => '33300', + 'redirects_count' => '61', + ]; + $pageRepo = $this->createMock( PageRepository::class ); + $pageRepo->method( 'countLinksAndRedirects' )->willReturn( $data ); + $page = new Page( $this->pageRepo, new Project( 'exampleWiki' ), 'Page' ); + $page->setRepository( $pageRepo ); + + static::assertEquals( $data, $page->countLinksAndRedirects() ); + } + + private function getRealPageRepository(): PageRepository { + static::createClient(); + return new PageRepository( + static::getContainer()->get( 'doctrine' ), + static::getContainer()->get( 'cache.app' ), + static::getContainer()->get( 'eight_points_guzzle.client.xtools' ), + $this->createMock( LoggerInterface::class ), + static::getContainer()->get( 'parameter_bag' ), + true, + 30 + ); + } } diff --git a/tests/Model/PagesTest.php b/tests/Model/PagesTest.php index d1727cf73..deb39c5a6 100644 --- a/tests/Model/PagesTest.php +++ b/tests/Model/PagesTest.php @@ -1,6 +1,6 @@ project = $this->createMock(Project::class); - $paRepo = $this->createMock(PageAssessmentsRepository::class); - $paRepo->method('getConfig') - ->willReturn($this->getAssessmentsConfig()); - $pa = new PageAssessments($paRepo, $this->project); - $this->project->method('getPageAssessments') - ->willReturn($pa); - $this->project->method('hasPageAssessments') - ->willReturn(true); - $this->project->method('getNamespaces') - ->willReturn([0 => 'Main', 1 => 'Talk', 3 => 'User_talk']); - $this->userRepo = $this->createMock(UserRepository::class); - $this->user = new User($this->userRepo, 'Test user'); - $this->pagesRepo = $this->createMock(PagesRepository::class); - } + /** + * Set up class instances and mocks. + */ + public function setUp(): void { + $this->project = $this->createMock( Project::class ); + $paRepo = $this->createMock( PageAssessmentsRepository::class ); + $paRepo->method( 'getConfig' ) + ->willReturn( $this->getAssessmentsConfig() ); + $pa = new PageAssessments( $paRepo, $this->project ); + $this->project->method( 'getPageAssessments' ) + ->willReturn( $pa ); + $this->project->method( 'hasPageAssessments' ) + ->willReturn( true ); + $this->project->method( 'getNamespaces' ) + ->willReturn( [ 0 => 'Main', 1 => 'Talk', 3 => 'User_talk' ] ); + $this->userRepo = $this->createMock( UserRepository::class ); + $this->user = new User( $this->userRepo, 'Test user' ); + $this->pagesRepo = $this->createMock( PagesRepository::class ); + } - /** - * Test the basic getters. - */ - public function testConstructor(): void - { - $pages = new Pages($this->pagesRepo, $this->project, $this->user); - static::assertEquals(0, $pages->getNamespace()); - static::assertEquals($this->project, $pages->getProject()); - static::assertEquals($this->user, $pages->getUser()); - static::assertEquals(Pages::REDIR_NONE, $pages->getRedirects()); - static::assertEquals(0, $pages->getOffset()); - } + /** + * Test the basic getters. + */ + public function testConstructor(): void { + $pages = new Pages( $this->pagesRepo, $this->project, $this->user ); + static::assertSame( 0, $pages->getNamespace() ); + static::assertEquals( $this->project, $pages->getProject() ); + static::assertEquals( $this->user, $pages->getUser() ); + static::assertEquals( Pages::REDIR_NONE, $pages->getRedirects() ); + static::assertFalse( $pages->getOffset() ); + } - /** - * @dataProvider provideSummaryColumnsData - */ - public function testSummaryColumns(string $redirects, string $deleted, array $expected): void - { - $pages = new Pages($this->pagesRepo, $this->project, $this->user, 0, $redirects, $deleted); - static::assertEquals(array_merge($expected, [ - 'total-page-size', - 'average-page-size', - ]), $pages->getSummaryColumns()); - } + /** + * @dataProvider provideSummaryColumnsData + */ + public function testSummaryColumns( string $redirects, string $deleted, array $expected ): void { + $pages = new Pages( $this->pagesRepo, $this->project, $this->user, 0, $redirects, $deleted ); + static::assertEquals( array_merge( $expected, [ + 'total-page-size', + 'average-page-size', + ] ), $pages->getSummaryColumns() ); + } - /** - * @return array - */ - public function provideSummaryColumnsData(): array - { - return [ - [Pages::REDIR_ALL, Pages::DEL_ALL, ['namespace', 'pages', 'redirects', 'deleted', 'live']], - [Pages::REDIR_ONLY, Pages::DEL_ALL, ['namespace', 'redirects', 'deleted', 'live']], - [Pages::REDIR_NONE, Pages::DEL_ALL, ['namespace', 'pages', 'deleted', 'live']], - [Pages::REDIR_ALL, Pages::DEL_ONLY, ['namespace', 'redirects', 'deleted']], - [Pages::REDIR_ONLY, Pages::DEL_ONLY, ['namespace', 'redirects', 'deleted']], - [Pages::REDIR_NONE, Pages::DEL_ONLY, ['namespace', 'deleted']], - [Pages::REDIR_ALL, Pages::DEL_NONE, ['namespace', 'pages', 'redirects']], - [Pages::REDIR_ONLY, Pages::DEL_NONE, ['namespace', 'redirects']], - [Pages::REDIR_NONE, Pages::DEL_NONE, ['namespace', 'pages']], - ]; - } + /** + * @return array + */ + public function provideSummaryColumnsData(): array { + return [ + [ Pages::REDIR_ALL, Pages::DEL_ALL, [ 'namespace', 'pages', 'redirects', 'deleted', 'live' ] ], + [ Pages::REDIR_ONLY, Pages::DEL_ALL, [ 'namespace', 'redirects', 'deleted', 'live' ] ], + [ Pages::REDIR_NONE, Pages::DEL_ALL, [ 'namespace', 'pages', 'deleted', 'live' ] ], + [ Pages::REDIR_ALL, Pages::DEL_ONLY, [ 'namespace', 'redirects', 'deleted' ] ], + [ Pages::REDIR_ONLY, Pages::DEL_ONLY, [ 'namespace', 'redirects', 'deleted' ] ], + [ Pages::REDIR_NONE, Pages::DEL_ONLY, [ 'namespace', 'deleted' ] ], + [ Pages::REDIR_ALL, Pages::DEL_NONE, [ 'namespace', 'pages', 'redirects' ] ], + [ Pages::REDIR_ONLY, Pages::DEL_NONE, [ 'namespace', 'redirects' ] ], + [ Pages::REDIR_NONE, Pages::DEL_NONE, [ 'namespace', 'pages' ] ], + ]; + } - public function testResults(): void - { - $this->setPagesResults(); - $pages = new Pages($this->pagesRepo, $this->project, $this->user, 0, 'all'); - $pages->setRepository($this->pagesRepo); - $pages->prepareData(); - static::assertEquals(3, $pages->getNumResults()); - static::assertEquals(1, $pages->getNumDeleted()); - static::assertEquals(1, $pages->getNumRedirects()); + public function testResults(): void { + $this->setPagesResults(); + $pages = new Pages( $this->pagesRepo, $this->project, $this->user, 0, 'all' ); + $pages->setRepository( $this->pagesRepo ); + $pages->prepareData(); + static::assertEquals( 3, $pages->getNumResults() ); + static::assertSame( 1, $pages->getNumDeleted() ); + static::assertSame( 1, $pages->getNumRedirects() ); - static::assertEquals([ - 0 => [ - 'count' => 2, - 'redirects' => 0, - 'deleted' => 1, - 'total_length' => 17, - 'avg_length' => 8.5, - ], - 1 => [ - 'count' => 1, - 'redirects' => 1, - 'deleted' => 0, - 'total_length' => 10, - 'avg_length' => 10, - ], - ], $pages->getCounts()); + static::assertEquals( [ + 0 => [ + 'count' => 2, + 'redirects' => 0, + 'deleted' => 1, + 'total_length' => 17, + 'avg_length' => 8.5, + ], + 1 => [ + 'count' => 1, + 'redirects' => 1, + 'deleted' => 0, + 'total_length' => 10, + 'avg_length' => 10, + ], + ], $pages->getCounts() ); - $results = $pages->getResults(); + $results = $pages->getResults(); - static::assertEquals([0, 1], array_keys($results)); - static::assertEquals([ - 'deleted' => true, - 'namespace' => 0, - 'page_title' => 'My_fun_page', - 'full_page_title' => 'My_fun_page', - 'redirect' => true, - 'timestamp' => '20160519000000', - 'rev_id' => 16, - 'rev_length' => 5, - 'length' => null, - 'recreated' => true, - 'assessment' => [ - 'class' => 'Unknown', - 'badge' => 'https://upload.wikimedia.org/wikipedia/commons/e/e0/Symbol_question.svg', - 'color' => '', - 'category' => 'Category:Unassessed articles', - 'projects' => ['Random'], - ], - ], $results[0][0]); - static::assertEquals([ - 'deleted' => false, - 'namespace' => 1, - 'page_title' => 'Google', - 'full_page_title' => 'Talk:Google', - 'redirect' => true, - 'timestamp' => '20160719000000', - 'rev_id' => 15, - 'rev_length' => 10, - 'length' => 50, - 'assessment' => [ - 'class' => 'A', - 'badge' => 'https://upload.wikimedia.org/wikipedia/commons/2/25/Symbol_a_class.svg', - 'color' => '#66FFFF', - 'category' => 'Category:A-Class articles', - 'projects' => ['Technology', 'Websites', 'Internet'], - ], - ], $results[1][0]); - static::assertTrue($pages->isMultiNamespace()); - } + static::assertEquals( [ 0, 1 ], array_keys( $results ) ); + static::assertEquals( [ + 'deleted' => true, + 'namespace' => 0, + 'page_title' => 'My_fun_page', + 'full_page_title' => 'My_fun_page', + 'redirect' => true, + 'timestamp' => '20160519000000', + 'rev_id' => 16, + 'rev_length' => 5, + 'length' => null, + 'recreated' => true, + 'assessment' => [ + 'class' => 'Unknown', + 'badge' => 'https://upload.wikimedia.org/wikipedia/commons/e/e0/Symbol_question.svg', + 'color' => '', + 'category' => 'Category:Unassessed articles', + 'projects' => [ 'Random' ], + ], + ], $results[0][0] ); + static::assertEquals( [ + 'deleted' => false, + 'namespace' => 1, + 'page_title' => 'Google', + 'full_page_title' => 'Talk:Google', + 'redirect' => true, + 'timestamp' => '20160719000000', + 'rev_id' => 15, + 'rev_length' => 10, + 'length' => 50, + 'assessment' => [ + 'class' => 'A', + 'badge' => 'https://upload.wikimedia.org/wikipedia/commons/2/25/Symbol_a_class.svg', + 'color' => '#66FFFF', + 'category' => 'Category:A-Class articles', + 'projects' => [ 'Technology', 'Websites', 'Internet' ], + ], + ], $results[1][0] ); + static::assertTrue( $pages->isMultiNamespace() ); + } - public function setPagesResults(): void - { - $this->pagesRepo->expects($this->exactly(2)) - ->method('getPagesCreated') - ->willReturn([ - [ - 'namespace' => 1, - 'type' => 'rev', - 'page_title' => 'Google', - 'redirect' => '1', - 'rev_length' => 10, - 'length' => 50, - 'timestamp' => '20160719000000', - 'rev_id' => 15, - 'recreated' => null, - 'pa_class' => 'A', - 'was_redirect' => null, - 'pap_project_title' => '["Technology","Websites","Internet"]', - ], [ - 'namespace' => 0, - 'type' => 'arc', - 'page_title' => 'My_fun_page', - 'redirect' => '0', - 'rev_length' => 5, - 'length' => null, - 'timestamp' => '20160519000000', - 'rev_id' => 16, - 'recreated' => 1, - 'pa_class' => null, - 'was_redirect' => '1', - 'pap_project_title' => '["Random"]', - ], [ - 'namespace' => 0, - 'type' => 'rev', - 'page_title' => 'Foo_bar', - 'redirect' => '0', - 'rev_length' => 12, - 'length' => 50, - 'timestamp' => '20160101000000', - 'rev_id' => 17, - 'recreated' => null, - 'pa_class' => 'FA', - 'was_redirect' => null, - 'pap_project_title' => '["Computing","Technology","Linguistics"]', - ], - ]); - $this->pagesRepo->expects($this->once()) - ->method('countPagesCreated') - ->willReturn([ - [ - 'namespace' => 0, - 'count' => 2, - 'deleted' => 1, - 'redirects' => 0, - 'total_length' => 17, - ], [ - 'namespace' => 1, - 'count' => 1, - 'deleted' => 0, - 'redirects' => 1, - 'total_length' => 10, - ], - ]); - } + public function setPagesResults(): void { + $this->pagesRepo->expects( $this->exactly( 2 ) ) + ->method( 'getPagesCreated' ) + ->willReturn( [ + [ + 'namespace' => 1, + 'type' => 'rev', + 'page_title' => 'Google', + 'redirect' => '1', + 'rev_length' => 10, + 'length' => 50, + 'timestamp' => '20160719000000', + 'rev_id' => 15, + 'recreated' => null, + 'pa_class' => 'A', + 'was_redirect' => null, + 'pap_project_title' => '["Technology","Websites","Internet"]', + ], [ + 'namespace' => 0, + 'type' => 'arc', + 'page_title' => 'My_fun_page', + 'redirect' => '0', + 'rev_length' => 5, + 'length' => null, + 'timestamp' => '20160519000000', + 'rev_id' => 16, + 'recreated' => 1, + 'pa_class' => null, + 'was_redirect' => '1', + 'pap_project_title' => '["Random"]', + ], [ + 'namespace' => 0, + 'type' => 'rev', + 'page_title' => 'Foo_bar', + 'redirect' => '0', + 'rev_length' => 12, + 'length' => 50, + 'timestamp' => '20160101000000', + 'rev_id' => 17, + 'recreated' => null, + 'pa_class' => 'FA', + 'was_redirect' => null, + 'pap_project_title' => '["Computing","Technology","Linguistics"]', + ], + ] ); + $this->pagesRepo->expects( $this->once() ) + ->method( 'countPagesCreated' ) + ->willReturn( [ + [ + 'namespace' => 0, + 'count' => 2, + 'deleted' => 1, + 'redirects' => 0, + 'total_length' => 17, + ], [ + 'namespace' => 1, + 'count' => 1, + 'deleted' => 0, + 'redirects' => 1, + 'total_length' => 10, + ], + ] ); + } - public function testDeletionSummary(): void - { - $project = new Project('testWiki'); - $project->setRepository($this->getProjectRepo()); - $this->pagesRepo->expects(static::once()) - ->method('getDeletionSummary') - ->willReturn([ - 'actor_name' => 'MusikAnimal', - 'comment_text' => '[[WP:AfD|Articles for deletion]]', - 'log_timestamp' => '20210108224022', - ]); - $pages = new Pages($this->pagesRepo, $project, $this->user); - $pages->setRepository($this->pagesRepo); - static::assertEquals( - "2021-01-08 22:40 (" . - "MusikAnimal): " . - "Articles for deletion", - $pages->getDeletionSummary(0, 'Foobar', '20210108224000') - ); - } + public function testDeletionSummary(): void { + $project = new Project( 'testWiki' ); + $project->setRepository( $this->getProjectRepo() ); + $this->pagesRepo->expects( static::once() ) + ->method( 'getDeletionSummary' ) + ->willReturn( [ + 'actor_name' => 'MusikAnimal', + 'comment_text' => '[[WP:AfD|Articles for deletion]]', + 'log_timestamp' => '20210108224022', + ] ); + $pages = new Pages( $this->pagesRepo, $project, $this->user ); + $pages->setRepository( $this->pagesRepo ); + static::assertEquals( + "2021-01-08 22:40 (" . + "MusikAnimal): " . + "Articles for deletion", + $pages->getDeletionSummary( 0, 'Foobar', '20210108224000' ) + ); + } - /** - * Mock assessments configuration. - * @return array - */ - private function getAssessmentsConfig(): array - { - return [ - 'class' => [ - 'FA' => [ - 'badge' => 'b/bc/Featured_article_star.svg', - 'color' => '#9CBDFF', - 'category' => 'Category:FA-Class articles', - ], - 'A' => [ - 'badge' => '2/25/Symbol_a_class.svg', - 'color' => '#66FFFF', - 'category' => 'Category:A-Class articles', - ], - 'Unknown' => [ - 'badge' => 'e/e0/Symbol_question.svg', - 'color' => '', - 'category' => 'Category:Unassessed articles', - ], - ], - ]; - } + /** + * Mock assessments configuration. + * @return array + */ + private function getAssessmentsConfig(): array { + return [ + 'class' => [ + 'FA' => [ + 'badge' => 'b/bc/Featured_article_star.svg', + 'color' => '#9CBDFF', + 'category' => 'Category:FA-Class articles', + ], + 'A' => [ + 'badge' => '2/25/Symbol_a_class.svg', + 'color' => '#66FFFF', + 'category' => 'Category:A-Class articles', + ], + 'Unknown' => [ + 'badge' => 'e/e0/Symbol_question.svg', + 'color' => '', + 'category' => 'Category:Unassessed articles', + ], + ], + ]; + } } diff --git a/tests/Model/ProjectTest.php b/tests/Model/ProjectTest.php index e96b0a4ec..afe1d3743 100644 --- a/tests/Model/ProjectTest.php +++ b/tests/Model/ProjectTest.php @@ -1,6 +1,6 @@ projectRepo = $this->getProjectRepo(); - $this->userRepo = $this->createMock(UserRepository::class); - } - - /** - * A project has its own domain name, database name, URL, script path, and article path. - */ - public function testBasicMetadata(): void - { - $this->projectRepo->expects(static::once()) - ->method('getMetadata') - ->willReturn([ - 'general' => [ - 'articlePath' => '/test_wiki/$1', - 'scriptPath' => '/test_w', - ], - ]); - - $project = new Project('testWiki'); - $project->setRepository($this->projectRepo); - static::assertEquals('test.example.org', $project->getDomain()); - static::assertEquals('test_wiki', $project->getDatabaseName()); - static::assertEquals('https://test.example.org/', $project->getUrl()); - static::assertEquals('en', $project->getLang()); - static::assertEquals('/test_w', $project->getScriptPath()); - static::assertEquals('/test_wiki/$1', $project->getArticlePath()); - static::assertTrue($project->exists()); - } - - /** - * A project has a set of namespaces, comprising integer IDs and string titles. - */ - public function testNamespaces(): void - { - $projectRepo = $this->getProjectRepo(); - $projectRepo->expects(static::once()) - ->method('getMetadata') - ->willReturn([ - 'namespaces' => [0 => 'Main', 1 => 'Talk'], - ]); - - $project = new Project('testWiki'); - $project->setRepository($projectRepo); - static::assertCount(2, $project->getNamespaces()); - - // Tests that getMetadata was in fact called only once and cached afterwards - static::assertEquals('Main', $project->getNamespaces()[0]); - } - - /** - * XTools can be run in single-wiki mode, where there is only one project. - */ - public function testSingleWiki(): void - { - $this->markTestSkipped('No single-wiki support, currently.'); - - $this->projectRepo->setSingleBasicInfo([ - 'url' => 'https://example.org/a-wiki/', - 'dbName' => 'example_wiki', - 'lang' => 'en', - ]); - $project = new Project('disregarded_wiki_name'); - $project->setRepository($this->projectRepo); - static::assertEquals('example_wiki', $project->getDatabaseName()); - static::assertEquals('https://example.org/a-wiki/', $project->getUrl()); - static::assertEquals('en', $project->getLang()); - } - - /** - * A project is considered to exist if it has at least a domain name. - */ - public function testExists(): void - { - /** @var ProjectRepository|MockObject $projectRepo */ - $projectRepo = $this->createMock(ProjectRepository::class); - $projectRepo->expects(static::once()) - ->method('getOne') - ->willReturn([]); - - $project = new Project('testWiki'); - $project->setRepository($projectRepo); - static::assertFalse($project->exists()); - } - - /** - * Get the relative URL to the index.php script. - */ - public function testGetScript(): void - { - $projectRepo = $this->getProjectRepo(); - $projectRepo->expects(static::once()) - ->method('getMetadata') - ->willReturn([ - 'general' => [ - 'script' => '/w/index.php', - ], - ]); - $project = new Project('testWiki'); - $project->setRepository($projectRepo); - static::assertEquals('/w/index.php', $project->getScript()); - - // No script from API. - $projectRepo2 = $this->getProjectRepo(); - $projectRepo2->expects(static::once()) - ->method('getMetadata') - ->willReturn([ - 'general' => [ - 'scriptPath' => '/w', - ], - ]); - $project2 = new Project('testWiki'); - $project2->setRepository($projectRepo2); - static::assertEquals('/w/index.php', $project2->getScript()); - } - - /** - * A user or a whole project can opt in to displaying restricted statistics. - * @dataProvider optedInProvider - * @param string[] $optedInProjects List of projects. - * @param string $dbName The database name. - * @param string $domain The domain name. - * @param bool $hasOptedIn The result to check against. - */ - public function testOptedIn(array $optedInProjects, string $dbName, string $domain, bool $hasOptedIn): void - { - $project = new Project($dbName); - $globalProject = new Project('metawiki'); - - /** @var ProjectRepository|MockObject $globalProjectRepo */ - $globalProjectRepo = $this->createMock(ProjectRepository::class); - - $this->projectRepo->expects(static::once()) - ->method('optedIn') - ->willReturn($optedInProjects); - $this->projectRepo->expects(static::once()) - ->method('getOne') - ->willReturn([ - 'dbName' => $dbName, - 'domain' => "https://$domain.org", - ]); - $this->projectRepo->method('getGlobalProject') - ->willReturn($globalProject); - $this->projectRepo->method('pageHasContent') - ->with($project, 2, 'TestUser/EditCounterOptIn.js') - ->willReturn($hasOptedIn); - $project->setRepository($this->projectRepo); - $globalProject->setRepository($globalProjectRepo); - - // Check that the user has opted in or not. - $user = new User($this->userRepo, 'TestUser'); - static::assertEquals($hasOptedIn, $project->userHasOptedIn($user)); - } - - /** - * Data for self::testOptedIn(). - * @return array - */ - public function optedInProvider(): array - { - $optedInProjects = ['project1']; - return [ - [$optedInProjects, 'project1', 'test.example.org', true], - [$optedInProjects, 'project2', 'test2.example.org', false], - [$optedInProjects, 'project3', 'test3.example.org', false], - ]; - } - - /** - * Normalized, quoted table name. - */ - public function testTableName(): void - { - $projectRepo = $this->getProjectRepo(); - $projectRepo->expects(static::once()) - ->method('getTableName') - ->willReturn('testwiki_p.revision_userindex'); - $project = new Project('testWiki'); - $project->setRepository($projectRepo); - static::assertEquals( - 'testwiki_p.revision_userindex', - $project->getTableName('testwiki', 'revision') - ); - } - - /** - * Getting a list of the users within specific user groups. - */ - public function testUsersInGroups(): void - { - $projectRepo = $this->getProjectRepo(); - $projectRepo->expects(static::once()) - ->method('getUsersInGroups') - ->willReturn([ - ['user_name' => 'Bob', 'user_group' => 'sysop'], - ['user_name' => 'Bob', 'user_group' => 'checkuser'], - ['user_name' => 'Julie', 'user_group' => 'sysop'], - ['user_name' => 'Herald', 'user_group' => 'suppress'], - ['user_name' => 'Isosceles', 'user_group' => 'suppress'], - ['user_name' => 'Isosceles', 'user_group' => 'sysop'], - ]); - $project = new Project('testWiki'); - $project->setRepository($projectRepo); - static::assertEquals( - [ - 'Bob' => ['sysop', 'checkuser'], - 'Julie' => ['sysop'], - 'Herald' => ['suppress'], - 'Isosceles' => ['suppress', 'sysop'], - ], - $project->getUsersInGroups(['sysop', 'checkuser'], []) - ); - } - - public function testGetUrlForPage(): void - { - $projectRepo = $this->getProjectRepo(); - $projectRepo->expects(static::once())->method('getMetadata'); - $project = new Project('testWiki'); - $project->setRepository($projectRepo); - static::assertEquals( - "https://test.example.org/wiki/Foobar", - $project->getUrlForPage('Foobar') - ); - } +class ProjectTest extends TestAdapter { + protected ProjectRepository $projectRepo; + protected UserRepository $userRepo; + + public function setUp(): void { + parent::setUp(); + $this->projectRepo = $this->getProjectRepo(); + $this->userRepo = $this->createMock( UserRepository::class ); + } + + /** + * A project has its own domain name, database name, URL, script path, and article path. + */ + public function testBasicMetadata(): void { + $this->projectRepo->expects( static::once() ) + ->method( 'getMetadata' ) + ->willReturn( [ + 'general' => [ + 'articlePath' => '/test_wiki/$1', + 'scriptPath' => '/test_w', + ], + ] ); + + $project = new Project( 'testWiki' ); + $project->setRepository( $this->projectRepo ); + static::assertEquals( 'test.example.org', $project->getDomain() ); + static::assertEquals( 'test_wiki', $project->getDatabaseName() ); + static::assertEquals( 'https://test.example.org/', $project->getUrl() ); + static::assertEquals( 'en', $project->getLang() ); + static::assertEquals( '/test_w', $project->getScriptPath() ); + static::assertEquals( '/test_wiki/$1', $project->getArticlePath() ); + static::assertTrue( $project->exists() ); + } + + /** + * A project has a set of namespaces, comprising integer IDs and string titles. + */ + public function testNamespaces(): void { + $projectRepo = $this->getProjectRepo(); + $projectRepo->expects( static::once() ) + ->method( 'getMetadata' ) + ->willReturn( [ + 'namespaces' => [ 0 => 'Main', 1 => 'Talk' ], + ] ); + + $project = new Project( 'testWiki' ); + $project->setRepository( $projectRepo ); + static::assertCount( 2, $project->getNamespaces() ); + + // Tests that getMetadata was in fact called only once and cached afterwards + static::assertEquals( 'Main', $project->getNamespaces()[0] ); + } + + /** + * XTools can be run in single-wiki mode, where there is only one project. + */ + public function testSingleWiki(): void { + $this->markTestSkipped( 'No single-wiki support, currently.' ); + + $this->projectRepo->setSingleBasicInfo( [ + 'url' => 'https://example.org/a-wiki/', + 'dbName' => 'example_wiki', + 'lang' => 'en', + ] ); + $project = new Project( 'disregarded_wiki_name' ); + $project->setRepository( $this->projectRepo ); + static::assertEquals( 'example_wiki', $project->getDatabaseName() ); + static::assertEquals( 'https://example.org/a-wiki/', $project->getUrl() ); + static::assertEquals( 'en', $project->getLang() ); + } + + /** + * A project is considered to exist if it has at least a domain name. + */ + public function testExists(): void { + /** @var ProjectRepository|MockObject $projectRepo */ + $projectRepo = $this->createMock( ProjectRepository::class ); + $projectRepo->expects( static::once() ) + ->method( 'getOne' ) + ->willReturn( [] ); + + $project = new Project( 'testWiki' ); + $project->setRepository( $projectRepo ); + static::assertFalse( $project->exists() ); + } + + /** + * Get the relative URL to the index.php script. + */ + public function testGetScript(): void { + $projectRepo = $this->getProjectRepo(); + $projectRepo->expects( static::once() ) + ->method( 'getMetadata' ) + ->willReturn( [ + 'general' => [ + 'script' => '/w/index.php', + ], + ] ); + $project = new Project( 'testWiki' ); + $project->setRepository( $projectRepo ); + static::assertEquals( '/w/index.php', $project->getScript() ); + + // No script from API. + $projectRepo2 = $this->getProjectRepo(); + $projectRepo2->expects( static::once() ) + ->method( 'getMetadata' ) + ->willReturn( [ + 'general' => [ + 'scriptPath' => '/w', + ], + ] ); + $project2 = new Project( 'testWiki' ); + $project2->setRepository( $projectRepo2 ); + static::assertEquals( '/w/index.php', $project2->getScript() ); + } + + /** + * A user or a whole project can opt in to displaying restricted statistics. + * @dataProvider optedInProvider + * @param string[] $optedInProjects List of projects. + * @param string $dbName The database name. + * @param string $domain The domain name. + * @param bool $hasOptedIn The result to check against. + */ + public function testOptedIn( array $optedInProjects, string $dbName, string $domain, bool $hasOptedIn ): void { + $project = new Project( $dbName ); + $globalProject = new Project( 'metawiki' ); + + /** @var ProjectRepository|MockObject $globalProjectRepo */ + $globalProjectRepo = $this->createMock( ProjectRepository::class ); + + $this->projectRepo->expects( static::once() ) + ->method( 'optedIn' ) + ->willReturn( $optedInProjects ); + $this->projectRepo->expects( static::once() ) + ->method( 'getOne' ) + ->willReturn( [ + 'dbName' => $dbName, + 'domain' => "https://$domain.org", + ] ); + $this->projectRepo->method( 'getGlobalProject' ) + ->willReturn( $globalProject ); + $this->projectRepo->method( 'pageHasContent' ) + ->with( $project, 2, 'TestUser/EditCounterOptIn.js' ) + ->willReturn( $hasOptedIn ); + $project->setRepository( $this->projectRepo ); + $globalProject->setRepository( $globalProjectRepo ); + + // Check that the user has opted in or not. + $user = new User( $this->userRepo, 'TestUser' ); + static::assertEquals( $hasOptedIn, $project->userHasOptedIn( $user ) ); + } + + /** + * Data for self::testOptedIn(). + * @return array + */ + public function optedInProvider(): array { + $optedInProjects = [ 'project1' ]; + return [ + [ $optedInProjects, 'project1', 'test.example.org', true ], + [ $optedInProjects, 'project2', 'test2.example.org', false ], + [ $optedInProjects, 'project3', 'test3.example.org', false ], + ]; + } + + /** + * Normalized, quoted table name. + */ + public function testTableName(): void { + $projectRepo = $this->getProjectRepo(); + $projectRepo->expects( static::once() ) + ->method( 'getTableName' ) + ->willReturn( 'testwiki_p.revision_userindex' ); + $project = new Project( 'testWiki' ); + $project->setRepository( $projectRepo ); + static::assertEquals( + 'testwiki_p.revision_userindex', + $project->getTableName( 'testwiki', 'revision' ) + ); + } + + /** + * Getting a list of the users within specific user groups. + */ + public function testUsersInGroups(): void { + $projectRepo = $this->getProjectRepo(); + $projectRepo->expects( static::once() ) + ->method( 'getUsersInGroups' ) + ->willReturn( [ + [ 'user_name' => 'Bob', 'user_group' => 'sysop' ], + [ 'user_name' => 'Bob', 'user_group' => 'checkuser' ], + [ 'user_name' => 'Julie', 'user_group' => 'sysop' ], + [ 'user_name' => 'Herald', 'user_group' => 'suppress' ], + [ 'user_name' => 'Isosceles', 'user_group' => 'suppress' ], + [ 'user_name' => 'Isosceles', 'user_group' => 'sysop' ], + ] ); + $project = new Project( 'testWiki' ); + $project->setRepository( $projectRepo ); + static::assertEquals( + [ + 'Bob' => [ 'sysop', 'checkuser' ], + 'Julie' => [ 'sysop' ], + 'Herald' => [ 'suppress' ], + 'Isosceles' => [ 'suppress', 'sysop' ], + ], + $project->getUsersInGroups( [ 'sysop', 'checkuser' ], [] ) + ); + } + + public function testGetUrlForPage(): void { + $projectRepo = $this->getProjectRepo(); + $projectRepo->expects( static::once() )->method( 'getMetadata' ); + $project = new Project( 'testWiki' ); + $project->setRepository( $projectRepo ); + static::assertEquals( + "https://test.example.org/wiki/Foobar", + $project->getUrlForPage( 'Foobar' ) + ); + } } diff --git a/tests/Model/TopEditsTest.php b/tests/Model/TopEditsTest.php index a9b1920fd..f93239025 100644 --- a/tests/Model/TopEditsTest.php +++ b/tests/Model/TopEditsTest.php @@ -1,6 +1,6 @@ project = new Project('en.wikipedia.org'); - $this->project->setPageAssessments($this->createMock(PageAssessments::class)); - $this->projectRepo = $this->createMock(ProjectRepository::class); - $this->projectRepo->method('getMetadata') - ->willReturn(['namespaces' => [0 => 'Main', 3 => 'User_talk']]); - $this->projectRepo->method('getOne') - ->willReturn(['url' => 'https://en.wikipedia.org']); - $this->projectRepo->method('pageHasContent') - ->with($this->project, 2, 'Test user/EditCounterOptIn.js') - ->willReturn(true); - $this->project->setRepository($this->projectRepo); - $this->userRepo = $this->createMock(UserRepository::class); - $this->user = new User($this->userRepo, 'Test user'); - $this->autoEditsHelper = $this->getAutomatedEditsHelper(); - $this->teRepo = $this->createMock(TopEditsRepository::class); - $this->editRepo = $this->createMock(EditRepository::class); - $this->editRepo->method('getAutoEditsHelper') - ->willReturn($this->autoEditsHelper); - $this->pageRepo = $this->createMock(PageRepository::class); - } + /** + * Set up class instances and mocks. + */ + public function setUp(): void { + $this->project = new Project( 'en.wikipedia.org' ); + $this->project->setPageAssessments( $this->createMock( PageAssessments::class ) ); + $this->projectRepo = $this->createMock( ProjectRepository::class ); + $this->projectRepo->method( 'getMetadata' ) + ->willReturn( [ 'namespaces' => [ 0 => 'Main', 3 => 'User_talk' ] ] ); + $this->projectRepo->method( 'getOne' ) + ->willReturn( [ 'url' => 'https://en.wikipedia.org' ] ); + $this->projectRepo->method( 'pageHasContent' ) + ->with( $this->project, 2, 'Test user/EditCounterOptIn.js' ) + ->willReturn( true ); + $this->project->setRepository( $this->projectRepo ); + $this->userRepo = $this->createMock( UserRepository::class ); + $this->user = new User( $this->userRepo, 'Test user' ); + $this->autoEditsHelper = $this->getAutomatedEditsHelper(); + $this->teRepo = $this->createMock( TopEditsRepository::class ); + $this->editRepo = $this->createMock( EditRepository::class ); + $this->editRepo->method( 'getAutoEditsHelper' ) + ->willReturn( $this->autoEditsHelper ); + $this->pageRepo = $this->createMock( PageRepository::class ); + } - /** - * Test the basic functionality of TopEdits. - */ - public function testBasic(): void - { - // Single namespace, with defaults. - $te = $this->getTopEdits(); - static::assertEquals(0, $te->getNamespace()); - static::assertEquals(1000, $te->getLimit()); + /** + * Test the basic functionality of TopEdits. + */ + public function testBasic(): void { + // Single namespace, with defaults. + $te = $this->getTopEdits(); + static::assertSame( 0, $te->getNamespace() ); + static::assertEquals( 1000, $te->getLimit() ); - // Single namespace, explicit configuration. - $te = $this->getTopEdits(null, 5, false, false, 50); - static::assertEquals(5, $te->getNamespace()); - static::assertEquals(50, $te->getLimit()); + // Single namespace, explicit configuration. + $te = $this->getTopEdits( null, 5, false, false, 50 ); + static::assertEquals( 5, $te->getNamespace() ); + static::assertEquals( 50, $te->getLimit() ); - // All namespaces, so limit set. - $te = $this->getTopEdits(null, 'all'); - static::assertEquals('all', $te->getNamespace()); - static::assertEquals(20, $te->getLimit()); + // All namespaces, so limit set. + $te = $this->getTopEdits( null, 'all' ); + static::assertEquals( 'all', $te->getNamespace() ); + static::assertEquals( 20, $te->getLimit() ); - // All namespaces, explicit limit. - $te = $this->getTopEdits(null, 'all', false, false, 3); - static::assertEquals('all', $te->getNamespace()); - static::assertEquals(3, $te->getLimit()); + // All namespaces, explicit limit. + $te = $this->getTopEdits( null, 'all', false, false, 3 ); + static::assertEquals( 'all', $te->getNamespace() ); + static::assertEquals( 3, $te->getLimit() ); - $page = new Page($this->pageRepo, $this->project, 'Test page'); - $te->setPage($page); - static::assertEquals($page, $te->getPage()); - } + $page = new Page( $this->pageRepo, $this->project, 'Test page' ); + $te->setPage( $page ); + static::assertEquals( $page, $te->getPage() ); + } - /** - * Getting top edited pages across all namespaces. - */ - public function testTopEditsAllNamespaces(): void - { - $te = $this->getTopEdits(null, 'all', false, false, 2); - $this->teRepo->expects($this->once()) - ->method('getTopEditsAllNamespaces') - ->with($this->project, $this->user, '', '', 2) - ->willReturn(array_merge( - $this->topEditsNamespaceFactory()[0], - $this->topEditsNamespaceFactory()[3] - )); - $te->setRepository($this->teRepo); - $te->prepareData(); + /** + * Getting top edited pages across all namespaces. + */ + public function testTopEditsAllNamespaces(): void { + $te = $this->getTopEdits( null, 'all', false, false, 2 ); + $this->teRepo->expects( $this->once() ) + ->method( 'getTopEditsAllNamespaces' ) + ->with( $this->project, $this->user, '', '', 2 ) + ->willReturn( array_merge( + $this->topEditsNamespaceFactory()[0], + $this->topEditsNamespaceFactory()[3] + ) ); + $te->setRepository( $this->teRepo ); + $te->prepareData(); - $result = $te->getTopEdits(); - static::assertEquals([0, 3], array_keys($result)); - static::assertEquals(2, count($result)); - static::assertEquals(2, count($result[0])); - static::assertEquals(2, count($result[3])); - static::assertEquals([ - 'namespace' => '0', - 'page_title' => 'Foo bar', - 'redirect' => '1', - 'count' => '24', - 'full_page_title' => 'Foo bar', - 'assessment' => [ - 'class' => 'List', - ], - ], $result[0][0]); + $result = $te->getTopEdits(); + static::assertEquals( [ 0, 3 ], array_keys( $result ) ); + static::assertCount( 2, $result ); + static::assertCount( 2, $result[0] ); + static::assertCount( 2, $result[3] ); + static::assertEquals( [ + 'namespace' => '0', + 'page_title' => 'Foo bar', + 'redirect' => '1', + 'count' => '24', + 'full_page_title' => 'Foo bar', + 'assessment' => [ + 'class' => 'List', + ], + ], $result[0][0] ); - // Fetching again should use value of class property. - // The $this->once() above will validate this. - $result2 = $te->getTopEdits(); - static::assertEquals($result, $result2); - } + // Fetching again should use value of class property. + // The $this->once() above will validate this. + $result2 = $te->getTopEdits(); + static::assertEquals( $result, $result2 ); + } - /** - * Getting top edited pages within a single namespace. - */ - public function testTopEditsNamespace(): void - { - $te = $this->getTopEdits(null, 3, false, false, 2); - $this->teRepo->expects($this->once()) - ->method('getTopEditsNamespace') - ->with($this->project, $this->user, 3, false, false, 2) - ->willReturn($this->topEditsNamespaceFactory()[3]); - $te->setRepository($this->teRepo); - $te->prepareData(); + /** + * Getting top edited pages within a single namespace. + */ + public function testTopEditsNamespace(): void { + $te = $this->getTopEdits( null, 3, false, false, 2 ); + $this->teRepo->expects( $this->once() ) + ->method( 'getTopEditsNamespace' ) + ->with( $this->project, $this->user, 3, false, false, 2 ) + ->willReturn( $this->topEditsNamespaceFactory()[3] ); + $te->setRepository( $this->teRepo ); + $te->prepareData(); - $result = $te->getTopEdits(); - static::assertEquals([3], array_keys($result)); - static::assertEquals(1, count($result)); - static::assertEquals(2, count($result[3])); - static::assertEquals([ - 'namespace' => '3', - 'page_title' => 'Jimbo Wales', - 'redirect' => '0', - 'count' => '1', - 'full_page_title' => 'User talk:Jimbo Wales', - ], $result[3][1]); - } + $result = $te->getTopEdits(); + static::assertEquals( [ 3 ], array_keys( $result ) ); + static::assertCount( 1, $result ); + static::assertCount( 2, $result[3] ); + static::assertEquals( [ + 'namespace' => '3', + 'page_title' => 'Jimbo Wales', + 'redirect' => '0', + 'count' => '1', + 'full_page_title' => 'User talk:Jimbo Wales', + ], $result[3][1] ); + } - /** - * Data for self::testTopEditsAllNamespaces() and self::testTopEditsNamespace(). - * @return array - */ - private function topEditsNamespaceFactory(): array - { - return [ - 0 => [ - [ - 'namespace' => '0', - 'page_title' => 'Foo_bar', - 'redirect' => '1', - 'count' => '24', - 'pa_class' => 'List', - 'full_page_title' => 'Foo_bar', - ], [ - 'namespace' => '0', - 'page_title' => '101st_Airborne_Division', - 'redirect' => '0', - 'count' => '18', - 'pa_class' => 'C', - 'full_page_title' => '101st_Airborne_Division', - ], - ], - 3 => [ - [ - 'namespace' => '3', - 'page_title' => 'Test_user', - 'redirect' => '0', - 'count' => '3', - 'full_page_title' => 'User_talk:Test_user', - ], [ - 'namespace' => '3', - 'page_title' => 'Jimbo_Wales', - 'redirect' => '0', - 'count' => '1', - 'full_page_title' => 'User_talk:Jimbo_Wales', - ], - ], - ]; - } + /** + * Data for self::testTopEditsAllNamespaces() and self::testTopEditsNamespace(). + * @return array + */ + private function topEditsNamespaceFactory(): array { + return [ + 0 => [ + [ + 'namespace' => '0', + 'page_title' => 'Foo_bar', + 'redirect' => '1', + 'count' => '24', + 'pa_class' => 'List', + 'full_page_title' => 'Foo_bar', + ], [ + 'namespace' => '0', + 'page_title' => '101st_Airborne_Division', + 'redirect' => '0', + 'count' => '18', + 'pa_class' => 'C', + 'full_page_title' => '101st_Airborne_Division', + ], + ], + 3 => [ + [ + 'namespace' => '3', + 'page_title' => 'Test_user', + 'redirect' => '0', + 'count' => '3', + 'full_page_title' => 'User_talk:Test_user', + ], [ + 'namespace' => '3', + 'page_title' => 'Jimbo_Wales', + 'redirect' => '0', + 'count' => '1', + 'full_page_title' => 'User_talk:Jimbo_Wales', + ], + ], + ]; + } - /** - * Top edits to a single page. - */ - public function testTopEditsPage(): void - { - $te = $this->getTopEdits(new Page($this->pageRepo, $this->project, 'Test page')); - $this->teRepo->expects($this->once()) - ->method('getTopEditsPage') - ->willReturn($this->topEditsPageFactory()); - // The Edit instantiation happens in the repo, so we need to mock it for each - // revision so that the processing in TopEdits::prepareData() is done correctly. - $this->teRepo->method('getEdit') - ->willReturnCallback(function ($page, $rev) { - return new Edit($this->editRepo, $this->userRepo, $page, $rev); - }); + /** + * Top edits to a single page. + */ + public function testTopEditsPage(): void { + $te = $this->getTopEdits( new Page( $this->pageRepo, $this->project, 'Test page' ) ); + $this->teRepo->expects( $this->once() ) + ->method( 'getTopEditsPage' ) + ->willReturn( $this->topEditsPageFactory() ); + // The Edit instantiation happens in the repo, so we need to mock it for each + // revision so that the processing in TopEdits::prepareData() is done correctly. + $this->teRepo->method( 'getEdit' ) + ->willReturnCallback( function ( $page, $rev ) { + return new Edit( $this->editRepo, $this->userRepo, $page, $rev ); + } ); - $te->prepareData(); + $te->prepareData(); - static::assertEquals(4, $te->getNumTopEdits(), 'getNumTopEdits'); - static::assertEquals(100, $te->getTotalAdded(), 'getTotalAdded'); - static::assertEquals(-50, $te->getTotalRemoved(), 'getTotalRemoved'); - static::assertEquals(1, $te->getTotalMinor(), 'getTotalMinor'); - static::assertEquals(1, $te->getTotalAutomated(), 'getTotalAutomated'); - static::assertEquals(2, $te->getTotalReverted(), 'getTotalReverted'); - static::assertEquals(10, $te->getTopEdits()[1]->getId(), 'ID of second mock TopEdit'); - static::assertEquals(22.5, $te->getAtbe(), 'getAtBe'); - } + static::assertEquals( 4, $te->getNumTopEdits(), 'getNumTopEdits' ); + static::assertEquals( 100, $te->getTotalAdded(), 'getTotalAdded' ); + static::assertEquals( -50, $te->getTotalRemoved(), 'getTotalRemoved' ); + static::assertSame( 1, $te->getTotalMinor(), 'getTotalMinor' ); + static::assertSame( 1, $te->getTotalAutomated(), 'getTotalAutomated' ); + static::assertEquals( 2, $te->getTotalReverted(), 'getTotalReverted' ); + static::assertEquals( 10, $te->getTopEdits()[1]->getId(), 'ID of second mock TopEdit' ); + static::assertEquals( 22.5, $te->getAtbe(), 'getAtBe' ); + } - /** - * Test data for self::TopEditsPage(). - * @return array - */ - private function topEditsPageFactory(): array - { - return [ - [ - 'id' => 0, - 'timestamp' => '20170423000000', - 'minor' => 0, - 'length' => 100, - 'length_change' => 100, - 'reverted' => 0, - 'user_id' => 5, - 'username' => 'Test user', - 'comment' => 'Foo bar', - 'parent_comment' => null, - ], [ - 'id' => 10, - 'timestamp' => '20170313000000', - 'minor' => '1', - 'length' => 200, - 'length_change' => 50, - 'reverted' => 0, - 'user_id' => 5, - 'username' => 'Test user', - 'comment' => 'Weeee (using [[WP:AWB]])', - 'parent_comment' => 'Reverted edits by Test user ([[WP:HG]])', - ], [ - 'id' => 20, - 'timestamp' => '20170223000000', - 'minor' => 0, - 'length' => 500, - 'length_change' => -50, - 'reverted' => 0, - 'user_id' => 5, - 'username' => 'Test user', - 'comment' => 'Boomshakalaka', - 'parent_comment' => 'Just another innocent edit', - ], [ - 'id' => 30, - 'timestamp' => '20170123000000', - 'minor' => 0, - 'length' => 500, - 'length_change' => 100, - 'reverted' => 1, - 'user_id' => 5, - 'username' => 'Test user', - 'comment' => 'Best edit ever', - 'parent_comment' => 'I plead the Fifth', - ], - ]; - } + /** + * Test data for self::TopEditsPage(). + * @return array + */ + private function topEditsPageFactory(): array { + return [ + [ + 'id' => 0, + 'timestamp' => '20170423000000', + 'minor' => 0, + 'length' => 100, + 'length_change' => 100, + 'reverted' => 0, + 'user_id' => 5, + 'username' => 'Test user', + 'comment' => 'Foo bar', + 'parent_comment' => null, + ], [ + 'id' => 10, + 'timestamp' => '20170313000000', + 'minor' => '1', + 'length' => 200, + 'length_change' => 50, + 'reverted' => 0, + 'user_id' => 5, + 'username' => 'Test user', + 'comment' => 'Weeee (using [[WP:AWB]])', + 'parent_comment' => 'Reverted edits by Test user ([[WP:HG]])', + ], [ + 'id' => 20, + 'timestamp' => '20170223000000', + 'minor' => 0, + 'length' => 500, + 'length_change' => -50, + 'reverted' => 0, + 'user_id' => 5, + 'username' => 'Test user', + 'comment' => 'Boomshakalaka', + 'parent_comment' => 'Just another innocent edit', + ], [ + 'id' => 30, + 'timestamp' => '20170123000000', + 'minor' => 0, + 'length' => 500, + 'length_change' => 100, + 'reverted' => 1, + 'user_id' => 5, + 'username' => 'Test user', + 'comment' => 'Best edit ever', + 'parent_comment' => 'I plead the Fifth', + ], + ]; + } - /** - * @param Page|null $page - * @param string|int $namespace Namespace ID or 'all'. - * @param int|false $start Start date as Unix timestamp. - * @param int|false $end End date as Unix timestamp. - * @param int|null $limit Number of rows to fetch. - * @return TopEdits - */ - private function getTopEdits( - ?Page $page = null, - $namespace = 0, - $start = false, - $end = false, - ?int $limit = null - ): TopEdits { - return new TopEdits( - $this->teRepo, - $this->autoEditsHelper, - $this->project, - $this->user, - $page, - $namespace, - $start, - $end, - $limit - ); - } + /** + * @param Page|null $page + * @param string|int $namespace Namespace ID or 'all'. + * @param int|false $start Start date as Unix timestamp. + * @param int|false $end End date as Unix timestamp. + * @param int|null $limit Number of rows to fetch. + * @return TopEdits + */ + private function getTopEdits( + ?Page $page = null, + $namespace = 0, + $start = false, + $end = false, + ?int $limit = null + ): TopEdits { + return new TopEdits( + $this->teRepo, + $this->autoEditsHelper, + $this->project, + $this->user, + $page, + $namespace, + $start, + $end, + $limit + ); + } } diff --git a/tests/Model/UserRightsTest.php b/tests/Model/UserRightsTest.php index f1dc7a919..068b57128 100644 --- a/tests/Model/UserRightsTest.php +++ b/tests/Model/UserRightsTest.php @@ -1,6 +1,6 @@ i18n = static::createClient()->getContainer()->get('app.i18n_helper'); - $project = new Project('test.example.org'); - $projectRepo = $this->getProjectRepo(); - $projectRepo->method('getMetadata') - ->willReturn([ - 'tempAccountPatterns' => ['~2$1'], - ]); - $project->setRepository($projectRepo); - $this->userRepo = $this->createMock(UserRepository::class); - $this->user = new User($this->userRepo, 'Testuser'); - $this->userRightsRepo = $this->createMock(UserRightsRepository::class); - $this->userRights = new UserRights($this->userRightsRepo, $project, $this->user, $this->i18n); - } + public function setUp(): void { + $this->i18n = static::createClient()->getContainer()->get( 'app.i18n_helper' ); + $project = new Project( 'test.example.org' ); + $projectRepo = $this->getProjectRepo(); + $projectRepo->method( 'getMetadata' ) + ->willReturn( [ + 'tempAccountPatterns' => [ '~2$1' ], + ] ); + $project->setRepository( $projectRepo ); + $this->userRepo = $this->createMock( UserRepository::class ); + $this->user = new User( $this->userRepo, 'Testuser' ); + $this->userRightsRepo = $this->createMock( UserRightsRepository::class ); + $this->userRights = new UserRights( $this->userRightsRepo, $project, $this->user, $this->i18n ); + } - /** - * User rights changes. - */ - public function testUserRightsChanges(): void - { - $this->userRightsRepo->expects(static::once()) - ->method('getRightsChanges') - ->willReturn([[ - // Added: interface-admin, temporary. - 'log_id' => '92769185', - 'log_timestamp' => '20180826173045', - 'log_params' => 'a:4:{s:12:"4::oldgroups";a:3:{i:0;s:11:"abusefilter";i:1;s:9:"checkuser";i:2;s:5:'. - '"sysop";}s:12:"5::newgroups";a:4:{i:0;s:11:"abusefilter";i:1;s:9:"checkuser";i:2;s:5:"sysop";'. - 'i:3;s:15:"interface-admin";}s:11:"oldmetadata";a:3:{i:0;a:1:{s:6:"expiry";N;}i:1;a:1:{s:6:"'. - 'expiry";N;}i:2;a:1:{s:6:"expiry";N;}}s:11:"newmetadata";a:4:{i:0;a:1:{s:6:"expiry";N;}i:1;a:1'. - ':{s:6:"expiry";N;}i:2;a:1:{s:6:"expiry";N;}i:3;a:1:{s:6:"expiry";s:14:"20181025000000";}}}', - 'log_action' => 'rights', - 'performer' => 'Worm That Turned', - 'log_comment' => 'per [[Special:Diff/856641107]]', - 'type' => 'local', - 'log_deleted' => '0', - ], [ - // Removed: ipblock-exempt, filemover. - 'log_id' => '210221', - 'log_timestamp' => '20180108132810', - 'log_comment' => '', - 'log_params' => 'a:4:{s:12:"4::oldgroups";a:6:{i:0;s:10:"bureaucrat";i:1;s:9:' . - '"filemover";i:2;s:6:"import";i:3;s:14:"ipblock-exempt";i:4;s:5:"sysop";i:5;' . - 's:14:"templateeditor";}s:12:"5::newgroups";a:5:{i:0;s:10:"bureaucrat";i:1;s:9:' . - '"filemover";i:2;s:6:"import";i:3;s:14:"ipblock-exempt";i:4;s:5:"sysop";}s:11:' . - '"oldmetadata";a:6:{i:0;a:1:{s:6:"expiry";N;}i:1;a:1:{s:6:"expiry";s:14:"' . - '20180108132858";}i:2;a:1:{s:6:"expiry";N;}i:3;a:1:{s:6:"expiry";s:14:"20180108132858"' . - ';}i:4;a:1:{s:6:"expiry";N;}i:5;a:1:{s:6:"expiry";N;}}s:11:"newmetadata";a:5:{i:0;' . - 'a:1:{s:6:"expiry";N;}i:1;a:1:{s:6:"expiry";s:14:"20180108132858";}i:2;a:1:{s:6:' . - '"expiry";N;}i:3;a:1:{s:6:"expiry";s:14:"20180108132858";}i:4;a:1:{s:6:"expiry";N;}}}', - 'log_action' => 'rights', - 'performer' => 'MusikAnimal', - 'type' => 'local', - 'log_deleted' => '0', - ], [ - // Added: ipblock-exempt, filemover, templateeditor. - 'log_id' => '210220', - 'log_timestamp' => '20180108132758', - 'log_comment' => '', - 'log_params' => 'a:4:{s:12:"4::oldgroups";a:3:{i:0;s:10:"bureaucrat";i:1;s:6:"import";' . - 'i:2;s:5:"sysop";}s:12:"5::newgroups";a:6:{i:0;s:10:"bureaucrat";i:1;s:6:"import";' . - 'i:2;s:5:"sysop";i:3;s:14:"ipblock-exempt";i:4;s:9:"filemover";i:5;s:14:"templateeditor";}' . - 's:11:"oldmetadata";a:3:{i:0;a:1:{s:6:"expiry";N;}i:1;a:1:{s:6:"expiry";N;}i:2;a:1:' . - '{s:6:"expiry";N;}}s:11:"newmetadata";a:6:{i:0;a:1:{s:6:"expiry";N;}i:1;a:1:{s:6:' . - '"expiry";N;}i:2;a:1:{s:6:"expiry";N;}i:3;a:1:{s:6:"expiry";s:14:"20180108132858";}' . - 'i:4;a:1:{s:6:"expiry";s:14:"20180108132858";}i:5;a:1:{s:6:"expiry";N;}}}', - 'log_action' => 'rights', - 'performer' => 'MusikAnimal', - 'type' => 'local', - 'log_deleted' => '0', - ], [ - // Added: bureaucrat; Removed: rollbacker. - 'log_id' => '155321', - 'log_timestamp' => '20150716002614', - 'log_comment' => 'Per user request.', - 'log_params' => 'a:2:{s:12:"4::oldgroups";a:3:{i:0;s:8:"reviewer";i:1;s:10:"rollbacker"' . - ';i:2;s:5:"sysop";}s:12:"5::newgroups";a:3:{i:0;s:8:"reviewer";i:1;s:5:"sysop";i:2;' . - 's:10:"bureaucrat";}}', - 'log_action' => 'rights', - 'performer' => 'Cyberpower678', - 'type' => 'meta', - 'log_deleted' => '0', - ], [ - // Old-school log entry, adds sysop. - 'log_id' => '140643', - 'log_timestamp' => '20141222034127', - 'log_comment' => 'per request', - 'log_params' => "\nsysop", - 'log_action' => 'rights', - 'performer' => 'Snowolf', - 'type' => 'meta', - 'log_deleted' => '0', - ], [ - // Comment deleted - 'log_id' => '168397975', - 'log_timestamp' => '20250310044508', - 'log_comment' => null, - 'log_params' => null, - 'log_action' => 'rights', - 'performer' => 'Queen of Hearts', - 'type' => 'local', - 'log_deleted' => '2', - ], - ]); + /** + * User rights changes. + */ + public function testUserRightsChanges(): void { + $this->userRightsRepo->expects( static::once() ) + ->method( 'getRightsChanges' ) + ->willReturn( [ [ + // Added: interface-admin, temporary. + 'log_id' => '92769185', + 'log_timestamp' => '20180826173045', + 'log_params' => 'a:4:{s:12:"4::oldgroups";a:3:{i:0;s:11:"abusefilter";i:1;s:9:"checkuser";i:2;s:5:' . + '"sysop";}s:12:"5::newgroups";a:4:{i:0;s:11:"abusefilter";i:1;s:9:"checkuser";i:2;s:5:"sysop";' . + 'i:3;s:15:"interface-admin";}s:11:"oldmetadata";a:3:{i:0;a:1:{s:6:"expiry";N;}i:1;a:1:{s:6:"' . + 'expiry";N;}i:2;a:1:{s:6:"expiry";N;}}s:11:"newmetadata";a:4:{i:0;a:1:{s:6:"expiry";N;}i:1;a:1' . + ':{s:6:"expiry";N;}i:2;a:1:{s:6:"expiry";N;}i:3;a:1:{s:6:"expiry";s:14:"20181025000000";}}}', + 'log_action' => 'rights', + 'performer' => 'Worm That Turned', + 'log_comment' => 'per [[Special:Diff/856641107]]', + 'type' => 'local', + 'log_deleted' => '0', + ], [ + // Removed: ipblock-exempt, filemover. + 'log_id' => '210221', + 'log_timestamp' => '20180108132810', + 'log_comment' => '', + 'log_params' => 'a:4:{s:12:"4::oldgroups";a:6:{i:0;s:10:"bureaucrat";i:1;s:9:' . + '"filemover";i:2;s:6:"import";i:3;s:14:"ipblock-exempt";i:4;s:5:"sysop";i:5;' . + 's:14:"templateeditor";}s:12:"5::newgroups";a:5:{i:0;s:10:"bureaucrat";i:1;s:9:' . + '"filemover";i:2;s:6:"import";i:3;s:14:"ipblock-exempt";i:4;s:5:"sysop";}s:11:' . + '"oldmetadata";a:6:{i:0;a:1:{s:6:"expiry";N;}i:1;a:1:{s:6:"expiry";s:14:"' . + '20180108132858";}i:2;a:1:{s:6:"expiry";N;}i:3;a:1:{s:6:"expiry";s:14:"20180108132858"' . + ';}i:4;a:1:{s:6:"expiry";N;}i:5;a:1:{s:6:"expiry";N;}}s:11:"newmetadata";a:5:{i:0;' . + 'a:1:{s:6:"expiry";N;}i:1;a:1:{s:6:"expiry";s:14:"20180108132858";}i:2;a:1:{s:6:' . + '"expiry";N;}i:3;a:1:{s:6:"expiry";s:14:"20180108132858";}i:4;a:1:{s:6:"expiry";N;}}}', + 'log_action' => 'rights', + 'performer' => 'MusikAnimal', + 'type' => 'local', + 'log_deleted' => '0', + ], [ + // Added: ipblock-exempt, filemover, templateeditor. + 'log_id' => '210220', + 'log_timestamp' => '20180108132758', + 'log_comment' => '', + 'log_params' => 'a:4:{s:12:"4::oldgroups";a:3:{i:0;s:10:"bureaucrat";i:1;s:6:"import";' . + 'i:2;s:5:"sysop";}s:12:"5::newgroups";a:6:{i:0;s:10:"bureaucrat";i:1;s:6:"import";' . + 'i:2;s:5:"sysop";i:3;s:14:"ipblock-exempt";i:4;s:9:"filemover";i:5;s:14:"templateeditor";}' . + 's:11:"oldmetadata";a:3:{i:0;a:1:{s:6:"expiry";N;}i:1;a:1:{s:6:"expiry";N;}i:2;a:1:' . + '{s:6:"expiry";N;}}s:11:"newmetadata";a:6:{i:0;a:1:{s:6:"expiry";N;}i:1;a:1:{s:6:' . + '"expiry";N;}i:2;a:1:{s:6:"expiry";N;}i:3;a:1:{s:6:"expiry";s:14:"20180108132858";}' . + 'i:4;a:1:{s:6:"expiry";s:14:"20180108132858";}i:5;a:1:{s:6:"expiry";N;}}}', + 'log_action' => 'rights', + 'performer' => 'MusikAnimal', + 'type' => 'local', + 'log_deleted' => '0', + ], [ + // Added: bureaucrat; Removed: rollbacker. + 'log_id' => '155321', + 'log_timestamp' => '20150716002614', + 'log_comment' => 'Per user request.', + 'log_params' => 'a:2:{s:12:"4::oldgroups";a:3:{i:0;s:8:"reviewer";i:1;s:10:"rollbacker"' . + ';i:2;s:5:"sysop";}s:12:"5::newgroups";a:3:{i:0;s:8:"reviewer";i:1;s:5:"sysop";i:2;' . + 's:10:"bureaucrat";}}', + 'log_action' => 'rights', + 'performer' => 'Cyberpower678', + 'type' => 'meta', + 'log_deleted' => '0', + ], [ + // Old-school log entry, adds sysop. + 'log_id' => '140643', + 'log_timestamp' => '20141222034127', + 'log_comment' => 'per request', + 'log_params' => "\nsysop", + 'log_action' => 'rights', + 'performer' => 'Snowolf', + 'type' => 'meta', + 'log_deleted' => '0', + ], [ + // Comment deleted + 'log_id' => '168397975', + 'log_timestamp' => '20250310044508', + 'log_comment' => null, + 'log_params' => null, + 'log_action' => 'rights', + 'performer' => 'Queen of Hearts', + 'type' => 'local', + 'log_deleted' => '2', + ], + ] ); - /** @var MockObject|UserRepository $userRepo */ - $userRepo = $this->createMock(UserRepository::class); - $userRepo->method('getIdAndRegistration') - ->willReturn([ - 'userId' => 5, - 'regDate' => '20180101000000', - ]); - $this->user->setRepository($userRepo); + /** @var MockObject|UserRepository $userRepo */ + $userRepo = $this->createMock( UserRepository::class ); + $userRepo->method( 'getIdAndRegistration' ) + ->willReturn( [ + 'userId' => 5, + 'regDate' => '20180101000000', + ] ); + $this->user->setRepository( $userRepo ); - static::assertEquals([ - 20181025000000 => [ - 'logId' => '92769185', - 'performer' => 'Worm That Turned', - 'comment' => null, - 'added' => [], - 'removed' => ['interface-admin'], - 'grantType' => 'automatic', - 'type' => 'local', - 'paramsDeleted' => false, - 'commentDeleted' => false, - 'performerDeleted' => false, - ], - 20180826173045 => [ - 'logId' => '92769185', - 'performer' => 'Worm That Turned', - 'comment' => 'per [[Special:Diff/856641107]]', - 'added' => ['interface-admin'], - 'removed' => [], - 'grantType' => 'manual', - 'type' => 'local', - 'paramsDeleted' => false, - 'commentDeleted' => false, - 'performerDeleted' => false, - ], - 20180108132858 => [ - 'logId' => '210220', - 'performer' => 'MusikAnimal', - 'comment' => null, - 'added' => [], - 'removed' => ['ipblock-exempt', 'filemover'], - 'grantType' => 'automatic', - 'type' => 'local', - 'paramsDeleted' => false, - 'commentDeleted' => false, - 'performerDeleted' => false, - ], - 20180108132810 => [ - 'logId' => '210221', - 'performer' => 'MusikAnimal', - 'comment' => '', - 'added' => [], - 'removed' => ['templateeditor'], - 'grantType' => 'manual', - 'type' => 'local', - 'paramsDeleted' => false, - 'commentDeleted' => false, - 'performerDeleted' => false, - ], - 20180108132758 => [ - 'logId' => '210220', - 'performer' => 'MusikAnimal', - 'comment' => '', - 'added' => ['ipblock-exempt', 'filemover', 'templateeditor'], - 'removed' => [], - 'grantType' => 'manual', - 'type' => 'local', - 'paramsDeleted' => false, - 'commentDeleted' => false, - 'performerDeleted' => false, - ], - 20150716002614 => [ - 'logId' => '155321', - 'performer' => 'Cyberpower678', - 'comment' => 'Per user request.', - 'added' => ['bureaucrat'], - 'removed' => ['rollbacker'], - 'grantType' => 'manual', - 'type' => 'meta', - 'paramsDeleted' => false, - 'commentDeleted' => false, - 'performerDeleted' => false, - ], - 20141222034127 => [ - 'logId' => '140643', - 'performer' => 'Snowolf', - 'comment' => 'per request', - 'added' => ['sysop'], - 'removed' => [], - 'grantType' => 'manual', - 'type' => 'meta', - 'paramsDeleted' => false, - 'commentDeleted' => false, - 'performerDeleted' => false, - ], - 20250310044508 => [ - 'logId' => '168397975', - 'performer' => 'Queen of Hearts', - 'comment' => null, - 'added' => [], - 'removed' => [], - 'grantType' => 'manual', - 'type' => 'local', - 'paramsDeleted' => true, - 'commentDeleted' => true, - 'performerDeleted' => false, - ], - ], $this->userRights->getRightsChanges()); + static::assertEquals( [ + 20181025000000 => [ + 'logId' => '92769185', + 'performer' => 'Worm That Turned', + 'comment' => null, + 'added' => [], + 'removed' => [ 'interface-admin' ], + 'grantType' => 'automatic', + 'type' => 'local', + 'paramsDeleted' => false, + 'commentDeleted' => false, + 'performerDeleted' => false, + ], + 20180826173045 => [ + 'logId' => '92769185', + 'performer' => 'Worm That Turned', + 'comment' => 'per [[Special:Diff/856641107]]', + 'added' => [ 'interface-admin' ], + 'removed' => [], + 'grantType' => 'manual', + 'type' => 'local', + 'paramsDeleted' => false, + 'commentDeleted' => false, + 'performerDeleted' => false, + ], + 20180108132858 => [ + 'logId' => '210220', + 'performer' => 'MusikAnimal', + 'comment' => null, + 'added' => [], + 'removed' => [ 'ipblock-exempt', 'filemover' ], + 'grantType' => 'automatic', + 'type' => 'local', + 'paramsDeleted' => false, + 'commentDeleted' => false, + 'performerDeleted' => false, + ], + 20180108132810 => [ + 'logId' => '210221', + 'performer' => 'MusikAnimal', + 'comment' => '', + 'added' => [], + 'removed' => [ 'templateeditor' ], + 'grantType' => 'manual', + 'type' => 'local', + 'paramsDeleted' => false, + 'commentDeleted' => false, + 'performerDeleted' => false, + ], + 20180108132758 => [ + 'logId' => '210220', + 'performer' => 'MusikAnimal', + 'comment' => '', + 'added' => [ 'ipblock-exempt', 'filemover', 'templateeditor' ], + 'removed' => [], + 'grantType' => 'manual', + 'type' => 'local', + 'paramsDeleted' => false, + 'commentDeleted' => false, + 'performerDeleted' => false, + ], + 20150716002614 => [ + 'logId' => '155321', + 'performer' => 'Cyberpower678', + 'comment' => 'Per user request.', + 'added' => [ 'bureaucrat' ], + 'removed' => [ 'rollbacker' ], + 'grantType' => 'manual', + 'type' => 'meta', + 'paramsDeleted' => false, + 'commentDeleted' => false, + 'performerDeleted' => false, + ], + 20141222034127 => [ + 'logId' => '140643', + 'performer' => 'Snowolf', + 'comment' => 'per request', + 'added' => [ 'sysop' ], + 'removed' => [], + 'grantType' => 'manual', + 'type' => 'meta', + 'paramsDeleted' => false, + 'commentDeleted' => false, + 'performerDeleted' => false, + ], + 20250310044508 => [ + 'logId' => '168397975', + 'performer' => 'Queen of Hearts', + 'comment' => null, + 'added' => [], + 'removed' => [], + 'grantType' => 'manual', + 'type' => 'local', + 'paramsDeleted' => true, + 'commentDeleted' => true, + 'performerDeleted' => false, + ], + ], $this->userRights->getRightsChanges() ); - $this->userRightsRepo->expects(static::once()) - ->method('getGlobalRightsChanges') - ->willReturn([[ - 'log_id' => '140643', - 'log_timestamp' => '20141222034127', - 'log_comment' => 'per request', - 'log_params' => "\nsysop", - 'log_action' => 'gblrights', - 'performer' => 'Snowolf', - 'type' => 'global', - 'log_deleted' => '0', - ]]); + $this->userRightsRepo->expects( static::once() ) + ->method( 'getGlobalRightsChanges' ) + ->willReturn( [ [ + 'log_id' => '140643', + 'log_timestamp' => '20141222034127', + 'log_comment' => 'per request', + 'log_params' => "\nsysop", + 'log_action' => 'gblrights', + 'performer' => 'Snowolf', + 'type' => 'global', + 'log_deleted' => '0', + ] ] ); - static::assertEquals([ - 20141222034127 => [ - 'logId' => '140643', - 'performer' => 'Snowolf', - 'comment' => 'per request', - 'added' => ['sysop'], - 'removed' => [], - 'grantType' => 'manual', - 'type' => 'global', - 'paramsDeleted' => false, - 'commentDeleted' => false, - 'performerDeleted' => false, - ], - ], $this->userRights->getGlobalRightsChanges()); + static::assertEquals( [ + 20141222034127 => [ + 'logId' => '140643', + 'performer' => 'Snowolf', + 'comment' => 'per request', + 'added' => [ 'sysop' ], + 'removed' => [], + 'grantType' => 'manual', + 'type' => 'global', + 'paramsDeleted' => false, + 'commentDeleted' => false, + 'performerDeleted' => false, + ], + ], $this->userRights->getGlobalRightsChanges() ); - /** @var MockObject|UserRepository $userRepo */ - $userRepo = $this->createMock(UserRepository::class); - $userRepo->expects(static::once()) - ->method('getUserRights') - ->willReturn(['sysop', 'bureaucrat']); - $userRepo->expects(static::once()) - ->method('getGlobalUserRights') - ->willReturn(['sysop']); - $this->user->setRepository($userRepo); + /** @var MockObject|UserRepository $userRepo */ + $userRepo = $this->createMock( UserRepository::class ); + $userRepo->expects( static::once() ) + ->method( 'getUserRights' ) + ->willReturn( [ 'sysop', 'bureaucrat' ] ); + $userRepo->expects( static::once() ) + ->method( 'getGlobalUserRights' ) + ->willReturn( [ 'sysop' ] ); + $this->user->setRepository( $userRepo ); - // Current rights. - static::assertEquals( - ['sysop', 'bureaucrat'], - $this->userRights->getRightsStates()['local']['current'] - ); + // Current rights. + static::assertEquals( + [ 'sysop', 'bureaucrat' ], + $this->userRights->getRightsStates()['local']['current'] + ); - // Former rights. - static::assertEquals( - ['interface-admin', 'ipblock-exempt', 'filemover', 'templateeditor', 'rollbacker'], - $this->userRights->getRightsStates()['local']['former'] - ); + // Former rights. + static::assertEquals( + [ 'interface-admin', 'ipblock-exempt', 'filemover', 'templateeditor', 'rollbacker' ], + $this->userRights->getRightsStates()['local']['former'] + ); - // Admin status. - static::assertEquals('current', $this->userRights->getAdminStatus()); - } + // Admin status. + static::assertEquals( 'current', $this->userRights->getAdminStatus() ); + } } diff --git a/tests/Model/UserTest.php b/tests/Model/UserTest.php index b405f0c81..f05c4cf83 100644 --- a/tests/Model/UserTest.php +++ b/tests/Model/UserTest.php @@ -1,6 +1,6 @@ userRepo = $this->createMock(UserRepository::class); - } - - /** - * A username should be given an initial capital letter in all cases. - */ - public function testUsernameHasInitialCapital(): void - { - $user = new User($this->userRepo, 'lowercasename'); - static::assertEquals('Lowercasename', $user->getUsername()); - $user2 = new User($this->userRepo, 'UPPERCASENAME'); - static::assertEquals('UPPERCASENAME', $user2->getUsername()); - } - - /** - * A user has an integer identifier on a project (and this can differ from project - * to project). - */ - public function testUserHasIdOnProject(): void - { - // Set up stub user and project repositories. - $this->userRepo->expects($this->once()) - ->method('getIdAndRegistration') - ->willReturn([ - 'userId' => 12, - 'regDate' => '20170101000000', - ]); - $projectRepo = $this->createMock(ProjectRepository::class); - $projectRepo->expects($this->once()) - ->method('getOne') - ->willReturn(['dbname' => 'testWiki']); - - // Make sure the user has the correct ID. - $user = new User($this->userRepo, 'TestUser'); - $project = new Project('wiki.example.org'); - $project->setRepository($projectRepo); - static::assertEquals(12, $user->getId($project)); - } - - /** - * Is a user an admin on a given project? - * @dataProvider isAdminProvider - * @param string $username The username. - * @param string[] $groups The groups to test. - * @param bool $isAdmin The desired result. - */ - public function testIsAdmin(string $username, array $groups, bool $isAdmin): void - { - $this->userRepo->expects($this->once()) - ->method('getUserRights') - ->willReturn($groups); - $user = new User($this->userRepo, $username); - static::assertEquals($isAdmin, $user->isAdmin(new Project('testWiki'))); - } - - /** - * Data for self::testIsAdmin(). - * @return string[] - */ - public function isAdminProvider(): array - { - return [ - ['AdminUser', ['sysop', 'autopatrolled'], true], - ['NormalUser', ['autopatrolled'], false], - ]; - } - - /** - * Get the expiry of the current block of a user on a given project - */ - public function testCountActiveBlocks(): void - { - $this->userRepo->expects($this->once()) - ->method('countActiveBlocks') - ->willReturn(5); - $user = new User($this->userRepo, 'TestUser'); - - $projectRepo = $this->createMock(ProjectRepository::class); - $project = new Project('wiki.example.org'); - $project->setRepository($projectRepo); - - static::assertEquals(5, $user->countActiveBlocks($project)); - } - - /** - * Is the user currently blocked on a given project? - */ - public function testIsBlocked(): void - { - $this->userRepo->expects($this->once()) - ->method('countActiveBlocks') - ->willReturn(1); - $user = new User($this->userRepo, 'TestUser'); - - $projectRepo = $this->createMock(ProjectRepository::class); - $project = new Project('wiki.example.org'); - $project->setRepository($projectRepo); - - static::assertEquals(true, $user->isBlocked($project)); - } - - /** - * Registration date of the user - */ - public function testRegistrationDate(): void - { - $this->userRepo->expects($this->once()) - ->method('getIdAndRegistration') - ->willReturn([ - 'userId' => 12, - 'regDate' => '20170101000000', - ]); - $user = new User($this->userRepo, 'TestUser'); - - $projectRepo = $this->createMock(ProjectRepository::class); - $project = new Project('wiki.example.org'); - $project->setRepository($projectRepo); - - $regDateTime = new DateTime('2017-01-01 00:00:00'); - static::assertEquals($regDateTime, $user->getRegistrationDate($project)); - } - - /** - * System edit count. - */ - public function testEditCount(): void - { - $this->userRepo->expects($this->once()) - ->method('getEditCount') - ->willReturn(12345); - $user = new User($this->userRepo, 'TestUser'); - - $projectRepo = $this->createMock(ProjectRepository::class); - $projectRepo->expects($this->once()) - ->method('getOne') - ->willReturn(['url' => 'https://wiki.example.org']); - $project = new Project('wiki.example.org'); - $project->setRepository($projectRepo); - - static::assertEquals(12345, $user->getEditCount($project)); - - // Should not call UserRepository::getEditCount() again. - static::assertEquals(12345, $user->getEditCount($project)); - } - - /** - * Too many edits to process? - */ - public function testHasTooManyEdits(): void - { - $this->userRepo->expects($this->once()) - ->method('getEditCount') - ->willReturn(123456789); - $this->userRepo->expects($this->exactly(3)) - ->method('maxEdits') - ->willReturn(250000); - $user = new User($this->userRepo, 'TestUser'); - - $projectRepo = $this->createMock(ProjectRepository::class); - $projectRepo->expects($this->once()) - ->method('getOne') - ->willReturn(['url' => 'https://wiki.example.org']); - $project = new Project('wiki.example.org'); - $project->setRepository($projectRepo); - - // User::maxEdits() - static::assertEquals(250000, $user->maxEdits()); - - // User::tooManyEdits() - static::assertTrue($user->hasTooManyEdits($project)); - } - - /** - * IP-related functionality and methods. - */ - public function testIpMethods(): void - { - $user = new User($this->userRepo, '192.168.0.0'); - static::assertTrue($user->isIP()); - static::assertFalse($user->isIpRange()); - static::assertFalse($user->isIPv6()); - static::assertEquals('192.168.0.0', $user->getUsernameIdent()); - - $user = new User($this->userRepo, '74.24.52.13/20'); - static::assertTrue($user->isIP()); - static::assertTrue($user->isQueryableRange()); - static::assertEquals('ipr-74.24.52.13/20', $user->getUsernameIdent()); - - $user = new User($this->userRepo, '2600:387:0:80d::b0'); - static::assertTrue($user->isIP()); - static::assertTrue($user->isIPv6()); - static::assertFalse($user->isIpRange()); - static::assertEquals('2600:387:0:80D:0:0:0:B0', $user->getUsername()); - static::assertEquals('2600:387:0:80D:0:0:0:B0', $user->getUsernameIdent()); - - // Using 'ipr-' prefix, which should only apply in routing. - $user = new User($this->userRepo, 'ipr-2001:DB8::/32'); - static::assertTrue($user->isIP()); - static::assertTrue($user->isIPv6()); - static::assertTrue($user->isIpRange()); - static::assertTrue($user->isQueryableRange()); - static::assertEquals('2001:DB8:0:0:0:0:0:0/32', $user->getUsername()); - static::assertEquals('2001:db8::/32', $user->getPrettyUsername()); - static::assertEquals('ipr-2001:DB8:0:0:0:0:0:0/32', $user->getUsernameIdent()); - - $user = new User($this->userRepo, '2001:db8::/31'); - static::assertTrue($user->isIpRange()); - static::assertFalse($user->isQueryableRange()); - - $user = new User($this->userRepo, 'Test'); - static::assertFalse($user->isIP()); - static::assertFalse($user->isIpRange()); - static::assertEquals('Test', $user->getPrettyUsername()); - } - - public function testGetIpSubstringFromCidr(): void - { - $user = new User($this->userRepo, '2001:db8:abc:1400::/54'); - static::assertEquals('2001:DB8:ABC:1', $user->getIpSubstringFromCidr()); - - $user = new User($this->userRepo, '174.197.128.0/18'); - static::assertEquals('174.197.1', $user->getIpSubstringFromCidr()); - - $user = new User($this->userRepo, '174.197.128.0'); - static::assertEquals(null, $user->getIpSubstringFromCidr()); - } - - public function testIsQueryableRange(): void - { - $user = new User($this->userRepo, '2001:db8:abc:1400::/54'); - static::assertTrue($user->isQueryableRange()); - - $user = new User($this->userRepo, '2001:db8:abc:1400::/5'); - static::assertFalse($user->isQueryableRange()); - - $user = new User($this->userRepo, '2001:db8:abc:1400'); - static::assertTrue($user->isQueryableRange()); - } - - /** - * From Core's PatternTest https://w.wiki/BZQH (GPL-2.0-or-later) - * @dataProvider provideIsTempUsername - * @param string $stringPattern - * @param string $name - * @param bool $expected - * @return void - */ - public function testIsTemp(string $stringPattern, string $name, bool $expected): void - { - $project = $this->createMock(Project::class); - $project->method('hasTempAccounts')->willReturn(true); - $project->method('getTempAccountPatterns')->willReturn([$stringPattern]); - static::assertSame($expected, User::isTempUsername($project, $name)); - } - - /** - * From Core's PatternTest https://w.wiki/BZQH (GPL-2.0-or-later) - */ - public static function provideIsTempUsername(): array - { - return [ - 'prefix mismatch' => [ - 'pattern' => '*$1', - 'name' => 'Test', - 'expected' => false, - ], - 'prefix match' => [ - 'pattern' => '*$1', - 'name' => '*Some user', - 'expected' => true, - ], - 'suffix only match' => [ - 'pattern' => '$1*', - 'name' => 'Some user*', - 'expected' => true, - ], - 'suffix only mismatch' => [ - 'pattern' => '$1*', - 'name' => 'Some user', - 'expected' => false, - ], - 'prefix and suffix match' => [ - 'pattern' => '*$1*', - 'name' => '*Unregistered 123*', - 'expected' => true, - ], - 'prefix and suffix mismatch' => [ - 'pattern' => '*$1*', - 'name' => 'Unregistered 123*', - 'expected' => false, - ], - 'prefix and suffix zero length match' => [ - 'pattern' => '*$1*', - 'name' => '**', - 'expected' => true, - ], - 'prefix and suffix overlapping' => [ - 'pattern' => '*$1*', - 'name' => '*', - 'expected' => false, - ], - ]; - } +class UserTest extends TestAdapter { + protected UserRepository $userRepo; + + public function setUp(): void { + $this->userRepo = $this->createMock( UserRepository::class ); + } + + /** + * A username should be given an initial capital letter in all cases. + */ + public function testUsernameHasInitialCapital(): void { + $user = new User( $this->userRepo, 'lowercasename' ); + static::assertEquals( 'Lowercasename', $user->getUsername() ); + $user2 = new User( $this->userRepo, 'UPPERCASENAME' ); + static::assertEquals( 'UPPERCASENAME', $user2->getUsername() ); + } + + /** + * A user has an integer identifier on a project (and this can differ from project + * to project). + */ + public function testUserHasIdOnProject(): void { + // Set up stub user and project repositories. + $this->userRepo->expects( $this->once() ) + ->method( 'getIdAndRegistration' ) + ->willReturn( [ + 'userId' => 12, + 'regDate' => '20170101000000', + ] ); + $projectRepo = $this->createMock( ProjectRepository::class ); + $projectRepo->expects( $this->once() ) + ->method( 'getOne' ) + ->willReturn( [ 'dbname' => 'testWiki' ] ); + + // Make sure the user has the correct ID. + $user = new User( $this->userRepo, 'TestUser' ); + $project = new Project( 'wiki.example.org' ); + $project->setRepository( $projectRepo ); + static::assertEquals( 12, $user->getId( $project ) ); + } + + /** + * Is a user an admin on a given project? + * @dataProvider isAdminProvider + * @param string $username The username. + * @param string[] $groups The groups to test. + * @param bool $isAdmin The desired result. + */ + public function testIsAdmin( string $username, array $groups, bool $isAdmin ): void { + $this->userRepo->expects( $this->once() ) + ->method( 'getUserRights' ) + ->willReturn( $groups ); + $user = new User( $this->userRepo, $username ); + static::assertEquals( $isAdmin, $user->isAdmin( new Project( 'testWiki' ) ) ); + } + + /** + * Data for self::testIsAdmin(). + * @return string[] + */ + public function isAdminProvider(): array { + return [ + [ 'AdminUser', [ 'sysop', 'autopatrolled' ], true ], + [ 'NormalUser', [ 'autopatrolled' ], false ], + ]; + } + + /** + * Get the expiry of the current block of a user on a given project + */ + public function testCountActiveBlocks(): void { + $this->userRepo->expects( $this->once() ) + ->method( 'countActiveBlocks' ) + ->willReturn( 5 ); + $user = new User( $this->userRepo, 'TestUser' ); + + $projectRepo = $this->createMock( ProjectRepository::class ); + $project = new Project( 'wiki.example.org' ); + $project->setRepository( $projectRepo ); + + static::assertEquals( 5, $user->countActiveBlocks( $project ) ); + } + + /** + * Is the user currently blocked on a given project? + */ + public function testIsBlocked(): void { + $this->userRepo->expects( $this->once() ) + ->method( 'countActiveBlocks' ) + ->willReturn( 1 ); + $user = new User( $this->userRepo, 'TestUser' ); + + $projectRepo = $this->createMock( ProjectRepository::class ); + $project = new Project( 'wiki.example.org' ); + $project->setRepository( $projectRepo ); + + static::assertTrue( $user->isBlocked( $project ) ); + } + + /** + * Registration date of the user + */ + public function testRegistrationDate(): void { + $this->userRepo->expects( $this->once() ) + ->method( 'getIdAndRegistration' ) + ->willReturn( [ + 'userId' => 12, + 'regDate' => '20170101000000', + ] ); + $user = new User( $this->userRepo, 'TestUser' ); + + $projectRepo = $this->createMock( ProjectRepository::class ); + $project = new Project( 'wiki.example.org' ); + $project->setRepository( $projectRepo ); + + $regDateTime = new DateTime( '2017-01-01 00:00:00' ); + static::assertEquals( $regDateTime, $user->getRegistrationDate( $project ) ); + } + + /** + * System edit count. + */ + public function testEditCount(): void { + $this->userRepo->expects( $this->once() ) + ->method( 'getEditCount' ) + ->willReturn( 12345 ); + $user = new User( $this->userRepo, 'TestUser' ); + + $projectRepo = $this->createMock( ProjectRepository::class ); + $projectRepo->expects( $this->once() ) + ->method( 'getOne' ) + ->willReturn( [ 'url' => 'https://wiki.example.org' ] ); + $project = new Project( 'wiki.example.org' ); + $project->setRepository( $projectRepo ); + + static::assertEquals( 12345, $user->getEditCount( $project ) ); + + // Should not call UserRepository::getEditCount() again. + static::assertEquals( 12345, $user->getEditCount( $project ) ); + } + + /** + * Too many edits to process? + */ + public function testHasTooManyEdits(): void { + $this->userRepo->expects( $this->once() ) + ->method( 'getEditCount' ) + ->willReturn( 123456789 ); + $this->userRepo->expects( $this->exactly( 3 ) ) + ->method( 'maxEdits' ) + ->willReturn( 250000 ); + $user = new User( $this->userRepo, 'TestUser' ); + + $projectRepo = $this->createMock( ProjectRepository::class ); + $projectRepo->expects( $this->once() ) + ->method( 'getOne' ) + ->willReturn( [ 'url' => 'https://wiki.example.org' ] ); + $project = new Project( 'wiki.example.org' ); + $project->setRepository( $projectRepo ); + + // User::maxEdits() + static::assertEquals( 250000, $user->maxEdits() ); + + // User::tooManyEdits() + static::assertTrue( $user->hasTooManyEdits( $project ) ); + } + + /** + * IP-related functionality and methods. + */ + public function testIpMethods(): void { + $user = new User( $this->userRepo, '192.168.0.0' ); + static::assertTrue( $user->isIP() ); + static::assertFalse( $user->isIpRange() ); + static::assertFalse( $user->isIPv6() ); + static::assertEquals( '192.168.0.0', $user->getUsernameIdent() ); + + $user = new User( $this->userRepo, '74.24.52.13/20' ); + static::assertTrue( $user->isIP() ); + static::assertTrue( $user->isQueryableRange() ); + static::assertEquals( 'ipr-74.24.52.13/20', $user->getUsernameIdent() ); + + $user = new User( $this->userRepo, '2600:387:0:80d::b0' ); + static::assertTrue( $user->isIP() ); + static::assertTrue( $user->isIPv6() ); + static::assertFalse( $user->isIpRange() ); + static::assertEquals( '2600:387:0:80D:0:0:0:B0', $user->getUsername() ); + static::assertEquals( '2600:387:0:80D:0:0:0:B0', $user->getUsernameIdent() ); + + // Using 'ipr-' prefix, which should only apply in routing. + $user = new User( $this->userRepo, 'ipr-2001:DB8::/32' ); + static::assertTrue( $user->isIP() ); + static::assertTrue( $user->isIPv6() ); + static::assertTrue( $user->isIpRange() ); + static::assertTrue( $user->isQueryableRange() ); + static::assertEquals( '2001:DB8:0:0:0:0:0:0/32', $user->getUsername() ); + static::assertEquals( '2001:db8::/32', $user->getPrettyUsername() ); + static::assertEquals( 'ipr-2001:DB8:0:0:0:0:0:0/32', $user->getUsernameIdent() ); + + $user = new User( $this->userRepo, '2001:db8::/31' ); + static::assertTrue( $user->isIpRange() ); + static::assertFalse( $user->isQueryableRange() ); + + $user = new User( $this->userRepo, 'Test' ); + static::assertFalse( $user->isIP() ); + static::assertFalse( $user->isIpRange() ); + static::assertEquals( 'Test', $user->getPrettyUsername() ); + } + + public function testGetIpSubstringFromCidr(): void { + $user = new User( $this->userRepo, '2001:db8:abc:1400::/54' ); + static::assertEquals( '2001:DB8:ABC:1', $user->getIpSubstringFromCidr() ); + + $user = new User( $this->userRepo, '174.197.128.0/18' ); + static::assertEquals( '174.197.1', $user->getIpSubstringFromCidr() ); + + $user = new User( $this->userRepo, '174.197.128.0' ); + static::assertNull( $user->getIpSubstringFromCidr() ); + } + + public function testIsQueryableRange(): void { + $user = new User( $this->userRepo, '2001:db8:abc:1400::/54' ); + static::assertTrue( $user->isQueryableRange() ); + + $user = new User( $this->userRepo, '2001:db8:abc:1400::/5' ); + static::assertFalse( $user->isQueryableRange() ); + + $user = new User( $this->userRepo, '2001:db8:abc:1400' ); + static::assertTrue( $user->isQueryableRange() ); + } + + /** + * From Core's PatternTest https://w.wiki/BZQH (GPL-2.0-or-later) + * @dataProvider provideIsTempUsername + * @param string $stringPattern + * @param string $name + * @param bool $expected + * @return void + */ + public function testIsTemp( string $stringPattern, string $name, bool $expected ): void { + $project = $this->createMock( Project::class ); + $project->method( 'hasTempAccounts' )->willReturn( true ); + $project->method( 'getTempAccountPatterns' )->willReturn( [ $stringPattern ] ); + static::assertSame( $expected, User::isTempUsername( $project, $name ) ); + } + + /** + * From Core's PatternTest https://w.wiki/BZQH (GPL-2.0-or-later) + */ + public static function provideIsTempUsername(): array { + return [ + 'prefix mismatch' => [ + 'pattern' => '*$1', + 'name' => 'Test', + 'expected' => false, + ], + 'prefix match' => [ + 'pattern' => '*$1', + 'name' => '*Some user', + 'expected' => true, + ], + 'suffix only match' => [ + 'pattern' => '$1*', + 'name' => 'Some user*', + 'expected' => true, + ], + 'suffix only mismatch' => [ + 'pattern' => '$1*', + 'name' => 'Some user', + 'expected' => false, + ], + 'prefix and suffix match' => [ + 'pattern' => '*$1*', + 'name' => '*Unregistered 123*', + 'expected' => true, + ], + 'prefix and suffix mismatch' => [ + 'pattern' => '*$1*', + 'name' => 'Unregistered 123*', + 'expected' => false, + ], + 'prefix and suffix zero length match' => [ + 'pattern' => '*$1*', + 'name' => '**', + 'expected' => true, + ], + 'prefix and suffix overlapping' => [ + 'pattern' => '*$1*', + 'name' => '*', + 'expected' => false, + ], + ]; + } } diff --git a/tests/Repository/RepositoryTest.php b/tests/Repository/RepositoryTest.php index 7f8876957..3059f3d48 100644 --- a/tests/Repository/RepositoryTest.php +++ b/tests/Repository/RepositoryTest.php @@ -1,6 +1,6 @@ repository = static::getContainer()->get(SimpleEditCounterRepository::class); - $this->userRepo = static::getContainer()->get(UserRepository::class); - } + protected function setUp(): void { + static::bootKernel(); + $this->repository = static::getContainer()->get( SimpleEditCounterRepository::class ); + $this->userRepo = static::getContainer()->get( UserRepository::class ); + } - /** - * Test that the table-name transformations are correct. - */ - public function testGetTableName(): void - { - if (static::getContainer()->getParameter('app.is_wmf')) { - // When using Labs. - static::assertEquals('`testwiki_p`.`page`', $this->repository->getTableName('testwiki', 'page')); - static::assertEquals( - '`testwiki_p`.`logging_userindex`', - $this->repository->getTableName('testwiki', 'logging') - ); - } else { - // When using wiki databases directly. - static::assertEquals('`testwiki`.`page`', $this->repository->getTableName('testwiki', 'page')); - static::assertEquals('`testwiki`.`logging`', $this->repository->getTableName('testwiki', 'logging')); - } - } + /** + * Test that the table-name transformations are correct. + */ + public function testGetTableName(): void { + if ( static::getContainer()->getParameter( 'app.is_wmf' ) ) { + // When using Labs. + static::assertEquals( '`testwiki_p`.`page`', $this->repository->getTableName( 'testwiki', 'page' ) ); + static::assertEquals( + '`testwiki_p`.`logging_userindex`', + $this->repository->getTableName( 'testwiki', 'logging' ) + ); + } else { + // When using wiki databases directly. + static::assertEquals( '`testwiki`.`page`', $this->repository->getTableName( 'testwiki', 'page' ) ); + static::assertEquals( '`testwiki`.`logging`', $this->repository->getTableName( 'testwiki', 'logging' ) ); + } + } - /** - * Test getting a unique cache key for a given set of arguments. - */ - public function testCacheKey(): void - { - // Set up example Models that we'll pass to Repository::getCacheKey(). - $project = $this->createMock(Project::class); - $project->method('getCacheKey')->willReturn('enwiki'); - $user = new User($this->userRepo, 'Test user (WMF)'); + /** + * Test getting a unique cache key for a given set of arguments. + */ + public function testCacheKey(): void { + // Set up example Models that we'll pass to Repository::getCacheKey(). + $project = $this->createMock( Project::class ); + $project->method( 'getCacheKey' )->willReturn( 'enwiki' ); + $user = new User( $this->userRepo, 'Test user (WMF)' ); - // Given explicit cache prefix. - static::assertEquals( - 'cachePrefix.enwiki.f475a8ac7f25e162bba0eb1b4b245027.'. - 'a84e19e5268bf01623c8a130883df668.202cb962ac59075b964b07152d234b70', - $this->repository->getCacheKey( - [$project, $user, '20170101', '', null, [1, 2, 3]], - 'cachePrefix' - ) - ); + // Given explicit cache prefix. + static::assertEquals( + 'cachePrefix.enwiki.f475a8ac7f25e162bba0eb1b4b245027.' . + 'a84e19e5268bf01623c8a130883df668.202cb962ac59075b964b07152d234b70', + $this->repository->getCacheKey( + [ $project, $user, '20170101', '', null, [ 1, 2, 3 ] ], + 'cachePrefix' + ) + ); - // It will use the name of the caller, in this case testCacheKey. - static::assertEquals( - // The `false` argument generates the trailing `.` - 'testCacheKey.enwiki.f475a8ac7f25e162bba0eb1b4b245027.' . - 'a84e19e5268bf01623c8a130883df668.d41d8cd98f00b204e9800998ecf8427e', - $this->repository->getCacheKey([$project, $user, '20170101', '', false, null]) - ); + // It will use the name of the caller, in this case testCacheKey. + static::assertEquals( + // The `false` argument generates the trailing `.` + 'testCacheKey.enwiki.f475a8ac7f25e162bba0eb1b4b245027.' . + 'a84e19e5268bf01623c8a130883df668.d41d8cd98f00b204e9800998ecf8427e', + $this->repository->getCacheKey( [ $project, $user, '20170101', '', false, null ] ) + ); - // Single argument, no prefix. - static::assertEquals( - 'testCacheKey.838763cbdc764f1740370a8ee1000c65', - $this->repository->getCacheKey('mycache') - ); - } + // Single argument, no prefix. + static::assertEquals( + 'testCacheKey.838763cbdc764f1740370a8ee1000c65', + $this->repository->getCacheKey( 'mycache' ) + ); + } - /** - * SQL date conditions helper. - */ - public function testDateConditions(): void - { - $start = strtotime('20170101'); - $end = strtotime('20190201'); - $offset = strtotime('20180201235959'); + /** + * SQL date conditions helper. + */ + public function testDateConditions(): void { + $start = strtotime( '20170101' ); + $end = strtotime( '20190201' ); + $offset = strtotime( '20180201235959' ); - static::assertEquals( - " AND alias.rev_timestamp >= '20170101000000' AND alias.rev_timestamp <= '20190201235959'", - $this->repository->getDateConditions($start, $end, false, 'alias.') - ); + static::assertEquals( + " AND alias.rev_timestamp >= '20170101000000' AND alias.rev_timestamp <= '20190201235959'", + $this->repository->getDateConditions( $start, $end, false, 'alias.' ) + ); - static::assertEquals( - " AND rev_timestamp >= '20170101000000' AND rev_timestamp <= '20180201235959'", - $this->repository->getDateConditions($start, $end, $offset) - ); - } + static::assertEquals( + " AND rev_timestamp >= '20170101000000' AND rev_timestamp <= '20180201235959'", + $this->repository->getDateConditions( $start, $end, $offset ) + ); + } } diff --git a/tests/SessionHelper.php b/tests/SessionHelper.php index 2cf509294..8192aa4d0 100644 --- a/tests/SessionHelper.php +++ b/tests/SessionHelper.php @@ -1,6 +1,6 @@ getRequestStack($session); */ -trait SessionHelper -{ - /** - * Create and get a new session object. - * Code courtesy of marien-probesys on GitHub. Unlicensed but used with permission. - * @see https://github.com/symfony/symfony/discussions/45662 - * @param KernelBrowser $client - * @return Session - */ - public function createSession(KernelBrowser $client): Session - { - $container = $client->getContainer(); - $sessionSavePath = $container->getParameter('session.save_path'); - $sessionStorage = new MockFileSessionStorage($sessionSavePath); +trait SessionHelper { + /** + * Create and get a new session object. + * Code courtesy of marien-probesys on GitHub. Unlicensed but used with permission. + * @see https://github.com/symfony/symfony/discussions/45662 + * @param KernelBrowser $client + * @return Session + */ + public function createSession( KernelBrowser $client ): Session { + $container = $client->getContainer(); + $sessionSavePath = $container->getParameter( 'session.save_path' ); + $sessionStorage = new MockFileSessionStorage( $sessionSavePath ); - $session = new Session($sessionStorage); - $session->start(); - $session->save(); + $session = new Session( $sessionStorage ); + $session->start(); + $session->save(); - $sessionCookie = new Cookie( - $session->getName(), - $session->getId(), - null, - null, - 'localhost', - ); - $client->getCookieJar()->set($sessionCookie); + $sessionCookie = new Cookie( + $session->getName(), + $session->getId(), + null, + null, + 'localhost', + ); + $client->getCookieJar()->set( $sessionCookie ); - return $session; - } + return $session; + } - /** - * Get a RequestStack with the Session object set. - * @param Session $session - * @param array $requestParams - * @return RequestStack - */ - public function getRequestStack(Session $session, array $requestParams = []): RequestStack - { - /** @var RequestStack $requestStack */ - $requestStack = static::getContainer()->get('request_stack'); - $request = new Request($requestParams); - $request->setSession($session); - $requestStack->push($request); - return $requestStack; - } + /** + * Get a RequestStack with the Session object set. + * @param Session $session + * @param array $requestParams + * @return RequestStack + */ + public function getRequestStack( Session $session, array $requestParams = [] ): RequestStack { + /** @var RequestStack $requestStack */ + $requestStack = static::getContainer()->get( 'request_stack' ); + $request = new Request( $requestParams ); + $request->setSession( $session ); + $requestStack->push( $request ); + return $requestStack; + } } diff --git a/tests/TestAdapter.php b/tests/TestAdapter.php index 0331f7aa7..3c2486e1c 100644 --- a/tests/TestAdapter.php +++ b/tests/TestAdapter.php @@ -1,6 +1,6 @@ createMock(ProjectRepository::class); - $repo->method('getOne') - ->willReturn([ - 'url' => 'https://test.example.org', - 'dbName' => 'test_wiki', - 'lang' => 'en', - ]); - return $repo; - } + /** + * Get a mocked ProjectRepository with some dummy data. + * @return MockObject|ProjectRepository + */ + public function getProjectRepo(): MockObject { + /** @var MockObject|ProjectRepository $repo */ + $repo = $this->createMock( ProjectRepository::class ); + $repo->method( 'getOne' ) + ->willReturn( [ + 'url' => 'https://test.example.org', + 'dbName' => 'test_wiki', + 'lang' => 'en', + ] ); + return $repo; + } - /** - * Get a Project object for en.wikipedia.org - * @return Project - */ - protected function getMockEnwikiProject(): Project - { - $projectRepo = $this->createMock(ProjectRepository::class); - $projectRepo->method('getOne') - ->willReturn([ - 'url' => 'https://en.wikipedia.org/w/api.php', - ]); - $projectRepo->method('getMetadata') - ->willReturn([ - 'general' => [ - 'mainpage' => 'Main Page', - 'scriptPath' => '/w', - ], - 'tempAccountPatterns' => ['~2$1'], - ]); - $project = new Project('en.wikipedia.org'); - $project->setRepository($projectRepo); - return $project; - } + /** + * Get a Project object for en.wikipedia.org + * @return Project + */ + protected function getMockEnwikiProject(): Project { + $projectRepo = $this->createMock( ProjectRepository::class ); + $projectRepo->method( 'getOne' ) + ->willReturn( [ + 'url' => 'https://en.wikipedia.org/w/api.php', + ] ); + $projectRepo->method( 'getMetadata' ) + ->willReturn( [ + 'general' => [ + 'mainpage' => 'Main Page', + 'scriptPath' => '/w', + ], + 'tempAccountPatterns' => [ '~2$1' ], + ] ); + $project = new Project( 'en.wikipedia.org' ); + $project->setRepository( $projectRepo ); + return $project; + } - /** - * Get an AutomatedEditsHelper with the session properly set. - * @param KernelBrowser|null $client - * @return AutomatedEditsHelper - */ - protected function getAutomatedEditsHelper(?KernelBrowser $client = null): AutomatedEditsHelper - { - $client = $client ?? static::createClient(); - $session = $this->createSession($client); - return new AutomatedEditsHelper( - $this->getRequestStack($session), - static::getContainer()->get('cache.app'), - static::getContainer()->get('eight_points_guzzle.client.xtools') - ); - } + /** + * Get an AutomatedEditsHelper with the session properly set. + * @param KernelBrowser|null $client + * @return AutomatedEditsHelper + */ + protected function getAutomatedEditsHelper( ?KernelBrowser $client = null ): AutomatedEditsHelper { + $client = $client ?? static::createClient(); + $session = $this->createSession( $client ); + return new AutomatedEditsHelper( + $this->getRequestStack( $session ), + static::getContainer()->get( 'cache.app' ), + static::getContainer()->get( 'eight_points_guzzle.client.xtools' ) + ); + } } diff --git a/tests/Twig/AppExtensionTest.php b/tests/Twig/AppExtensionTest.php index 3e6f38dc9..f6ec40d94 100644 --- a/tests/Twig/AppExtensionTest.php +++ b/tests/Twig/AppExtensionTest.php @@ -1,6 +1,6 @@ createSession(static::createClient()); - $requestStack = $this->getRequestStack($session); - $i18nHelper = new I18nHelper($requestStack, static::getContainer()->getParameter('kernel.project_dir')); - $urlGenerator = $this->createMock(UrlGenerator::class); - $this->appExtension = new AppExtension( - $requestStack, - $i18nHelper, - $urlGenerator, - $this->createMock(ProjectRepository::class), - static::getContainer()->get('parameter_bag'), - static::getContainer()->getParameter('app.is_wmf'), - static::getContainer()->getParameter('app.single_wiki'), - 30 - ); - } + /** + * Set class instance. + */ + public function setUp(): void { + $session = $this->createSession( static::createClient() ); + $requestStack = $this->getRequestStack( $session ); + $i18nHelper = new I18nHelper( $requestStack, static::getContainer()->getParameter( 'kernel.project_dir' ) ); + $urlGenerator = $this->createMock( UrlGenerator::class ); + $this->appExtension = new AppExtension( + $requestStack, + $i18nHelper, + $urlGenerator, + $this->createMock( ProjectRepository::class ), + static::getContainer()->get( 'parameter_bag' ), + static::getContainer()->getParameter( 'app.is_wmf' ), + static::getContainer()->getParameter( 'app.single_wiki' ), + 30 + ); + } - /** - * Format number as a diff size. - */ - public function testDiffFormat(): void - { - static::assertEquals( - "3,000", - $this->appExtension->diffFormat(3000) - ); - static::assertEquals( - "-20,000", - $this->appExtension->diffFormat(-20000) - ); - static::assertEquals( - "0", - $this->appExtension->diffFormat(0) - ); - static::assertEquals('', $this->appExtension->diffFormat(null)); - } + /** + * Format number as a diff size. + */ + public function testDiffFormat(): void { + static::assertEquals( + "3,000", + $this->appExtension->diffFormat( 3000 ) + ); + static::assertEquals( + "-20,000", + $this->appExtension->diffFormat( -20000 ) + ); + static::assertEquals( + "0", + $this->appExtension->diffFormat( 0 ) + ); + static::assertSame( '', $this->appExtension->diffFormat( null ) ); + } - /** - * Format number as a percentage. - */ - public function testPercentFormat(): void - { - static::assertEquals('45%', $this->appExtension->percentFormat(45)); - static::assertEquals('30%', $this->appExtension->percentFormat(30, null, 3)); - static::assertEquals('33.33%', $this->appExtension->percentFormat(2, 6, 2)); - static::assertEquals('25%', $this->appExtension->percentFormat(2, 8)); - } + /** + * Format number as a percentage. + */ + public function testPercentFormat(): void { + static::assertEquals( '45%', $this->appExtension->percentFormat( 45 ) ); + static::assertEquals( '30%', $this->appExtension->percentFormat( 30, null, 3 ) ); + static::assertEquals( '33.33%', $this->appExtension->percentFormat( 2, 6, 2 ) ); + static::assertEquals( '25%', $this->appExtension->percentFormat( 2, 8 ) ); + } - /** - * Format a time duration as humanized string. - */ - public function testFormatDuration(): void - { - static::assertEquals( - [30, 'num-seconds'], - $this->appExtension->formatDuration(30, false) - ); - static::assertEquals( - [1, 'num-minutes'], - $this->appExtension->formatDuration(70, false) - ); - static::assertEquals( - [50, 'num-minutes'], - $this->appExtension->formatDuration(3000, false) - ); - static::assertEquals( - [2, 'num-hours'], - $this->appExtension->formatDuration(7500, false) - ); - static::assertEquals( - [10, 'num-days'], - $this->appExtension->formatDuration(864000, false) - ); - } + /** + * Format a time duration as humanized string. + */ + public function testFormatDuration(): void { + static::assertEquals( + [ 30, 'num-seconds' ], + $this->appExtension->formatDuration( 30, false ) + ); + static::assertEquals( + [ 1, 'num-minutes' ], + $this->appExtension->formatDuration( 70, false ) + ); + static::assertEquals( + [ 50, 'num-minutes' ], + $this->appExtension->formatDuration( 3000, false ) + ); + static::assertEquals( + [ 2, 'num-hours' ], + $this->appExtension->formatDuration( 7500, false ) + ); + static::assertEquals( + [ 10, 'num-days' ], + $this->appExtension->formatDuration( 864000, false ) + ); + } - /** - * Format a number. - */ - public function testNumberFormat(): void - { - static::assertEquals('1,234', $this->appExtension->numberFormat(1234)); - static::assertEquals('1,234.32', $this->appExtension->numberFormat(1234.316, 2)); - static::assertEquals('50', $this->appExtension->numberFormat(50.0000, 4)); - } + /** + * Format a number. + */ + public function testNumberFormat(): void { + static::assertEquals( '1,234', $this->appExtension->numberFormat( 1234 ) ); + static::assertEquals( '1,234.32', $this->appExtension->numberFormat( 1234.316, 2 ) ); + static::assertSame( '50', $this->appExtension->numberFormat( 50.0000, 4 ) ); + } - /** - * Format a size. - */ - public function testSizeFormat(): void - { - static::assertEquals('12.01 KB', $this->appExtension->sizeFormat(12300)); - static::assertEquals('100', $this->appExtension->sizeFormat(100)); - static::assertEquals('0', $this->appExtension->sizeFormat(0)); - static::assertEquals('1.12 GB', $this->appExtension->sizeFormat(1200300400)); - static::assertEquals('1.09 TB', $this->appExtension->sizeFormat(1200300400500)); - } + /** + * Format a size. + */ + public function testSizeFormat(): void { + static::assertEquals( '12.01 KB', $this->appExtension->sizeFormat( 12300 ) ); + static::assertSame( '100', $this->appExtension->sizeFormat( 100 ) ); + static::assertSame( '0', $this->appExtension->sizeFormat( 0 ) ); + static::assertEquals( '1.12 GB', $this->appExtension->sizeFormat( 1200300400 ) ); + static::assertEquals( '1.09 TB', $this->appExtension->sizeFormat( 1200300400500 ) ); + } - /** - * Intuition methods. - */ - public function testIntution(): void - { - static::assertEquals('en', $this->appExtension->getLang()); - static::assertEquals('English', $this->appExtension->getLangName()); + /** + * Intuition methods. + */ + public function testIntution(): void { + static::assertEquals( 'en', $this->appExtension->getLang() ); + static::assertEquals( 'English', $this->appExtension->getLangName() ); - $allLangs = $this->appExtension->getAllLangs(); + $allLangs = $this->appExtension->getAllLangs(); - // There should be a bunch. - static::assertGreaterThan(20, count($allLangs)); + // There should be a bunch. + static::assertGreaterThan( 20, count( $allLangs ) ); - // Keys should be the language codes, with name as the values. - static::assertArraySubset(['en' => 'English'], $allLangs); - static::assertArraySubset(['de' => 'Deutsch'], $allLangs); - static::assertArraySubset(['es' => 'Español'], $allLangs); + // Keys should be the language codes, with name as the values. + static::assertArraySubset( [ 'en' => 'English' ], $allLangs ); + static::assertArraySubset( [ 'de' => 'Deutsch' ], $allLangs ); + static::assertArraySubset( [ 'es' => 'Español' ], $allLangs ); - // Testing if the language is RTL. - static::assertFalse($this->appExtension->isRTL('en')); - static::assertTrue($this->appExtension->isRTL('ar')); - } + // Testing if the language is RTL. + static::assertFalse( $this->appExtension->isRTL( 'en' ) ); + static::assertTrue( $this->appExtension->isRTL( 'ar' ) ); + } - /** - * Methods that fetch data about the git repository. - */ - public function testGitMethods(): void - { - // This test is mysteriously failing on Scrutinizer, but not on Travis. - // Commenting out for now. - // static::assertEquals(7, strlen($this->appExtension->gitShortHash())); + /** + * Methods that fetch data about the git repository. + */ + public function testGitMethods(): void { + // This test is mysteriously failing on Scrutinizer, but not on Travis. + // Commenting out for now. + // static::assertEquals(7, strlen($this->appExtension->gitShortHash())); - static::assertEquals(40, strlen($this->appExtension->gitHash())); - static::assertMatchesRegularExpression('/\d{4}-\d{2}-\d{2}/', $this->appExtension->gitDate()); - } + static::assertEquals( 40, strlen( $this->appExtension->gitHash() ) ); + static::assertMatchesRegularExpression( '/\d{4}-\d{2}-\d{2}/', $this->appExtension->gitDate() ); + } - /** - * Capitalizing first letter. - */ - public function testCapitalizeFirst(): void - { - static::assertEquals('Foo', $this->appExtension->capitalizeFirst('foo')); - static::assertEquals('Bar', $this->appExtension->capitalizeFirst('Bar')); - } + /** + * Capitalizing first letter. + */ + public function testCapitalizeFirst(): void { + static::assertEquals( 'Foo', $this->appExtension->capitalizeFirst( 'foo' ) ); + static::assertEquals( 'Bar', $this->appExtension->capitalizeFirst( 'Bar' ) ); + } - /** - * Getting amount of time it took to complete the request. - */ - public function testRequestTime(): void - { - static::assertTrue(is_double($this->appExtension->requestMemory())); - } + /** + * Getting amount of time it took to complete the request. + */ + public function testRequestTime(): void { + static::assertTrue( is_float( $this->appExtension->requestMemory() ) ); + } - /** - * Is the given user logged out? - */ - public function testUserIsAnon(): void - { - $userRepo = $this->createMock(UserRepository::class); - $user = new User($userRepo, '68.229.186.65'); - $user2 = new User($userRepo, 'Test user'); - $project = $this->createMock(Project::class); - $project->method('hasTempAccounts') - ->willReturn(true); - $project->method('getTempAccountPatterns') - ->willReturn(['~2$1']); - static::assertTrue($this->appExtension->isUserAnon($project, $user)); - static::assertFalse($this->appExtension->isUserAnon($project, $user2)); + /** + * Is the given user logged out? + */ + public function testUserIsAnon(): void { + $userRepo = $this->createMock( UserRepository::class ); + $user = new User( $userRepo, '68.229.186.65' ); + $user2 = new User( $userRepo, 'Test user' ); + $project = $this->createMock( Project::class ); + $project->method( 'hasTempAccounts' ) + ->willReturn( true ); + $project->method( 'getTempAccountPatterns' ) + ->willReturn( [ '~2$1' ] ); + static::assertTrue( $this->appExtension->isUserAnon( $project, $user ) ); + static::assertFalse( $this->appExtension->isUserAnon( $project, $user2 ) ); - static::assertTrue($this->appExtension->isUserAnon($project, '2605:E000:855A:4B00:3035:523D:F7E9:8F82')); - static::assertFalse($this->appExtension->isUserAnon($project, '192.0.blah.1')); - static::assertTrue($this->appExtension->isUserAnon($project, '~2024-1234')); - } + static::assertTrue( $this->appExtension->isUserAnon( $project, '2605:E000:855A:4B00:3035:523D:F7E9:8F82' ) ); + static::assertFalse( $this->appExtension->isUserAnon( $project, '192.0.blah.1' ) ); + static::assertTrue( $this->appExtension->isUserAnon( $project, '~2024-1234' ) ); + } - /** - * Formatting dates. - */ - public function testDateFormat(): void - { - static::assertEquals( - '2017-01-23 00:00', - $this->appExtension->dateFormat('2017-01-23') - ); - static::assertEquals( - '2017-01-23 00:00', - $this->appExtension->dateFormat(new DateTime('2017-01-23')) - ); - } + /** + * Formatting dates. + */ + public function testDateFormat(): void { + static::assertEquals( + '2017-01-23 00:00', + $this->appExtension->dateFormat( '2017-01-23' ) + ); + static::assertEquals( + '2017-01-23 00:00', + $this->appExtension->dateFormat( new DateTime( '2017-01-23' ) ) + ); + } - /** - * Building URL query string from array. - */ - public function testBuildQuery(): void - { - static::assertEquals( - 'foo=1&bar=2', - $this->appExtension->buildQuery([ - 'foo' => 1, - 'bar' => 2, - ]) - ); - } + /** + * Building URL query string from array. + */ + public function testBuildQuery(): void { + static::assertEquals( + 'foo=1&bar=2', + $this->appExtension->buildQuery( [ + 'foo' => 1, + 'bar' => 2, + ] ) + ); + } - /** - * Getting a normalized page title with the namespace. - */ - public function testTitleWithNs(): void - { - static::assertSame( - 'User talk:Foo bar', - $this->appExtension->titleWithNs('Foo_bar', 3, [ - 3 => 'User talk', - ]) - ); - } + /** + * Getting a normalized page title with the namespace. + */ + public function testTitleWithNs(): void { + static::assertSame( + 'User talk:Foo bar', + $this->appExtension->titleWithNs( 'Foo_bar', 3, [ + 3 => 'User talk', + ] ) + ); + } - /** - * Wikifying a string. - */ - public function testWikify(): void - { - $project = new Project('TestProject'); - $projectRepo = $this->createMock(ProjectRepository::class); - $projectRepo->method('getOne') - ->willReturn([ - 'url' => 'https://test.example.org', - 'dbName' => 'test_wiki', - 'lang' => 'en', - ]); - $projectRepo->method('getMetadata') - ->willReturn([ - 'general' => [ - 'articlePath' => '/wiki/$1', - ], - ]); - $project->setRepository($projectRepo); - $summary = ' [[test page]]'; - static::assertEquals( - "<script>alert(\"XSS baby\")</script> " . - "test page", - $this->appExtension->wikify($summary, $project) - ); - } + /** + * Wikifying a string. + */ + public function testWikify(): void { + $project = new Project( 'TestProject' ); + $projectRepo = $this->createMock( ProjectRepository::class ); + $projectRepo->method( 'getOne' ) + ->willReturn( [ + 'url' => 'https://test.example.org', + 'dbName' => 'test_wiki', + 'lang' => 'en', + ] ); + $projectRepo->method( 'getMetadata' ) + ->willReturn( [ + 'general' => [ + 'articlePath' => '/wiki/$1', + ], + ] ); + $project->setRepository( $projectRepo ); + $summary = ' [[test page]]'; + static::assertEquals( + "<script>alert(\"XSS baby\")</script> " . + "test page", + $this->appExtension->wikify( $summary, $project ) + ); + } } diff --git a/tests/Twig/TopNavExtensionTest.php b/tests/Twig/TopNavExtensionTest.php index 11523286c..76ca8199f 100644 --- a/tests/Twig/TopNavExtensionTest.php +++ b/tests/Twig/TopNavExtensionTest.php @@ -1,6 +1,6 @@ createSession(static::createClient()); - $requestStack = $this->getRequestStack($session); - $i18nHelper = new I18nHelper($requestStack, static::getContainer()->getParameter('kernel.project_dir')); - $this->topNavExtension = new TopNavExtension( - $requestStack, - $i18nHelper, - $this->createMock(UrlGenerator::class), - $this->createMock(ProjectRepository::class), - static::getContainer()->get('parameter_bag'), - static::getContainer()->getParameter('app.is_wmf'), - static::getContainer()->getParameter('app.single_wiki'), - static::getContainer()->getParameter('app.replag_threshold') - ); - } + /** + * Set class instance. + */ + public function setUp(): void { + $session = $this->createSession( static::createClient() ); + $requestStack = $this->getRequestStack( $session ); + $i18nHelper = new I18nHelper( $requestStack, static::getContainer()->getParameter( 'kernel.project_dir' ) ); + $this->topNavExtension = new TopNavExtension( + $requestStack, + $i18nHelper, + $this->createMock( UrlGenerator::class ), + $this->createMock( ProjectRepository::class ), + static::getContainer()->get( 'parameter_bag' ), + static::getContainer()->getParameter( 'app.is_wmf' ), + static::getContainer()->getParameter( 'app.single_wiki' ), + static::getContainer()->getParameter( 'app.replag_threshold' ) + ); + } - /** - * @covers \App\Twig\TopNavExtension::topNavEditCounter() - */ - public function testTopNavEditCounter(): void - { - static::assertEquals([ - 'General statistics', - 'Month counts', - 'Namespace Totals', - 'Rights changes', - 'Time card', - 'Top edited pages', - 'Year counts', - ], array_values($this->topNavExtension->topNavEditCounter())); - } + /** + * @covers \App\Twig\TopNavExtension::topNavEditCounter() + */ + public function testTopNavEditCounter(): void { + static::assertEquals( [ + 'General statistics', + 'Month counts', + 'Namespace Totals', + 'Rights changes', + 'Time card', + 'Top edited pages', + 'Year counts', + ], array_values( $this->topNavExtension->topNavEditCounter() ) ); + } - /** - * @covers \App\Twig\TopNavExtension::topNavUser() - */ - public function testTopNavUser(): void - { - static::assertEquals([ - 'Admin Score', - 'Automated Edits', - 'Category Edits', - 'Edit Counter', - 'Edit Summaries', - 'Global Contributions', - 'Pages Created', - 'Simple Counter', - 'Top Edits', - ], array_values($this->topNavExtension->topNavUser())); - } + /** + * @covers \App\Twig\TopNavExtension::topNavUser() + */ + public function testTopNavUser(): void { + static::assertEquals( [ + 'Admin Score', + 'Automated Edits', + 'Category Edits', + 'Edit Counter', + 'Edit Summaries', + 'Global Contributions', + 'Pages Created', + 'Simple Counter', + 'Top Edits', + ], array_values( $this->topNavExtension->topNavUser() ) ); + } - /** - * @covers \App\Twig\TopNavExtension::topNavPage() - */ - public function testTopNavPage(): void - { - static::assertEquals([ - 'Authorship', - 'Blame', - 'Page History', - ], array_values($this->topNavExtension->topNavPage())); - } + /** + * @covers \App\Twig\TopNavExtension::topNavPage() + */ + public function testTopNavPage(): void { + static::assertEquals( [ + 'Authorship', + 'Blame', + 'Page History', + ], array_values( $this->topNavExtension->topNavPage() ) ); + } - /** - * @covers \App\Twig\TopNavExtension::topNavProject() - */ - public function testTopNavProject(): void - { - static::assertEquals([ - 'Admin Stats', - 'Patroller Stats', - 'Steward Stats', - 'Largest Pages', - ], array_values($this->topNavExtension->topNavProject())); - } + /** + * @covers \App\Twig\TopNavExtension::topNavProject() + */ + public function testTopNavProject(): void { + static::assertEquals( [ + 'Admin Stats', + 'Patroller Stats', + 'Steward Stats', + 'Largest Pages', + ], array_values( $this->topNavExtension->topNavProject() ) ); + } } diff --git a/webpack.config.js b/webpack.config.js index cb7467493..e6bd79d21 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -3,85 +3,84 @@ const Encore = require('@symfony/webpack-encore'); // Manually configure the runtime environment if not already configured yet by the "encore" command. // It's useful when you use tools that rely on webpack.config.js file. if (!Encore.isRuntimeEnvironmentConfigured()) { - Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev'); + Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev'); } Encore - // Directory where compiled assets will be stored. - .setOutputPath('public/build/') + // Directory where compiled assets will be stored. + .setOutputPath('public/build/') - // Public URL path used by the web server to access the output path. - .setPublicPath('/build') + // Public URL path used by the web server to access the output path. + .setPublicPath('/build') - // this is now needed so that your manifest.json keys are still `build/foo.js` - // (which is a file that's used by Symfony's `asset()` function) - .setManifestKeyPrefix('build') + // this is now needed so that your manifest.json keys are still `build/foo.js` + // (which is a file that's used by Symfony's `asset()` function) + .setManifestKeyPrefix('build') - .copyFiles({ - from: './assets/images', - to: 'images/[path][name].[ext]' - }) + .copyFiles({ + from: './assets/images', + to: 'images/[path][name].[ext]' + }) - /* - * ENTRY CONFIG - * - * Add 1 entry for each "page" of your app - * (including one that's included on every page - e.g. "app") - * - * Each entry will result in one JavaScript file (e.g. app.js) - * and one CSS file (e.g. app.css) if you JavaScript imports CSS. - */ - .addEntry('app', [ - // Scripts - './assets/vendor/jquery.i18n/jquery.i18n.dist.js', - './assets/vendor/Chart.min.js', - './assets/vendor/bootstrap-typeahead.js', - './assets/js/common/application.js', - './assets/js/common/contributions-lists.js', - './assets/js/adminstats.js', - './assets/js/pageinfo.js', - './assets/js/authorship.js', - './assets/js/autoedits.js', - './assets/js/blame.js', - './assets/js/categoryedits.js', - './assets/js/editcounter.js', - './assets/js/globalcontribs.js', - './assets/js/pages.js', - './assets/js/topedits.js', + /* + * ENTRY CONFIG + * + * Add 1 entry for each "page" of your app + * (including one that's included on every page - e.g. "app") + * + * Each entry will result in one JavaScript file (e.g. app.js) + * and one CSS file (e.g. app.css) if you JavaScript imports CSS. + */ + .addEntry('app', [ + // Scripts + './assets/vendor/jquery.i18n/jquery.i18n.dist.js', + './assets/vendor/Chart.min.js', + './assets/vendor/bootstrap-typeahead.js', + './assets/js/common/application.js', + './assets/js/common/contributions-lists.js', + './assets/js/adminstats.js', + './assets/js/pageinfo.js', + './assets/js/authorship.js', + './assets/js/autoedits.js', + './assets/js/blame.js', + './assets/js/categoryedits.js', + './assets/js/editcounter.js', + './assets/js/globalcontribs.js', + './assets/js/pages.js', + './assets/js/topedits.js', - // Stylesheets - './assets/css/application.scss', - './assets/css/pageinfo.scss', - './assets/css/autoedits.scss', - './assets/css/blame.scss', - './assets/css/categoryedits.scss', - './assets/css/editcounter.scss', - './assets/css/home.scss', - './assets/css/meta.scss', - './assets/css/pages.scss', - './assets/css/topedits.scss', - './assets/css/responsive.scss' - ]) + // Stylesheets + './assets/css/application.scss', + './assets/css/pageinfo.scss', + './assets/css/autoedits.scss', + './assets/css/blame.scss', + './assets/css/categoryedits.scss', + './assets/css/editcounter.scss', + './assets/css/home.scss', + './assets/css/meta.scss', + './assets/css/pages.scss', + './assets/css/topedits.scss', + './assets/css/responsive.scss' + ]) - // When enabled, Webpack "splits" your files into smaller pieces for greater optimization. - .splitEntryChunks() + // When enabled, Webpack "splits" your files into smaller pieces for greater optimization. + .splitEntryChunks() - // will require an extra script tag for runtime.js - // but, you probably want this, unless you're building a single-page app - .enableSingleRuntimeChunk() + // will require an extra script tag for runtime.js + // but, you probably want this, unless you're building a single-page app + .enableSingleRuntimeChunk() - // Other options. - .enableSassLoader() - .cleanupOutputBeforeBuild() - .enableBuildNotifications() - .enableSourceMaps(!Encore.isProduction()) - .enableVersioning(Encore.isProduction()) + // Other options. + .enableSassLoader() + .cleanupOutputBeforeBuild() + .enableBuildNotifications() + .enableSourceMaps(!Encore.isProduction()) + .enableVersioning(Encore.isProduction()) - // enables @babel/preset-env polyfills - .configureBabelPresetEnv((config) => { - config.useBuiltIns = 'usage'; - config.corejs = 3; - }) -; + // enables @babel/preset-env polyfills + .configureBabelPresetEnv((config) => { + config.useBuiltIns = 'usage'; + config.corejs = 3; + }); module.exports = Encore.getWebpackConfig();