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();