From 2ac7206a1bee97f69458e3ef14ee1ee750d1a218 Mon Sep 17 00:00:00 2001 From: MusikAnimal Date: Fri, 19 Jan 2018 12:28:17 -0500 Subject: [PATCH] TopEdits: overhaul, better revert detection, more stats for single page --- app/Resources/views/base.html.twig | 1 + .../views/topedits/result_article.html.twig | 212 ++++++++++++------ app/config/semi_automated.yml | 3 +- i18n/en.json | 5 + i18n/qqq.json | 7 +- .../Controller/TopEditsController.php | 31 +-- src/AppBundle/Helper/AutomatedEditsHelper.php | 2 +- src/Xtools/Edit.php | 18 ++ src/Xtools/PageRepository.php | 6 +- src/Xtools/TopEdits.php | 200 ++++++++++++++++- src/Xtools/TopEditsRepository.php | 100 ++++++++- tests/Xtools/TopEditsTest.php | 113 ++++++++-- web/static/css/topedits.scss | 21 ++ 13 files changed, 604 insertions(+), 115 deletions(-) create mode 100644 web/static/css/topedits.scss diff --git a/app/Resources/views/base.html.twig b/app/Resources/views/base.html.twig index 27aafb61e..a3464a7ed 100644 --- a/app/Resources/views/base.html.twig +++ b/app/Resources/views/base.html.twig @@ -41,6 +41,7 @@ 'static/css/editcounter.scss' 'static/css/pages.scss' 'static/css/meta.scss' + 'static/css/topedits.scss' output='static/production/application.css' %} {% endstylesheets %} diff --git a/app/Resources/views/topedits/result_article.html.twig b/app/Resources/views/topedits/result_article.html.twig index 05529b00c..3f9b29b13 100644 --- a/app/Resources/views/topedits/result_article.html.twig +++ b/app/Resources/views/topedits/result_article.html.twig @@ -1,6 +1,7 @@ {% extends 'base.html.twig' %} {% import 'macros/layout.html.twig' as layout %} {% import 'macros/wiki.html.twig' as wiki %} +{% import 'macros/pieChart.html.twig' as chart %} {% block body %}
@@ -22,72 +23,155 @@
{% set content %} -
- - - - - - - - - - - - - - - - - - - - - -
{{ msg('article') }} - - {{ wiki.pageLink(page, page.displaytitle|raw) }} - - ({{ wiki.pageLogLink(page) }} - · {{ msg('tool-articleinfo') }}) -
{{ msg('user') }} - {{ wiki.userLink(user, project) }} - ({{ msg('tool-ec') }}) -
{{ msg('count') }}{{ revision_count|num_format }}
{{ msg('added') }}{{ total_added|diff_format }}
{{ msg('deleted') }}{{ total_removed|diff_format }}
-
+ {% if te.numTopEdits == 0 %} +
+ {{ msg('no-contribs') }} +
+ {% else %} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ msg('article') }} + + {{ wiki.pageLink(page, page.displaytitle|raw) }} + + ({{ wiki.pageLogLink(page) }} + · {{ msg('tool-articleinfo') }}) +
{{ msg('user') }} + {{ wiki.userLink(user, project) }} + ({{ msg('tool-ec') }}) +
{{ msg('total-edits') }}{{ te.numTopEdits|num_format }}
{{ msg('minor-edits') }} + {{ te.totalMinor }} + ({{ te.totalMinor|percent_format(te.numTopEdits) }}) +
{{ msg('auto-edits') }} + {{ te.totalAutomated }} + ({{ te.totalAutomated|percent_format(te.numTopEdits) }}) +
{{ msg('reverted-edits') }}1 + {{ te.totalReverted }} + ({{ te.totalReverted|percent_format(te.numTopEdits) }}) +
atbe2{{ te.atbe|round(1) }}
{{ msg('added-bytes') }}3{{ te.totalAdded|diff_format }}
{{ msg('deleted-bytes') }}{{ te.totalRemoved|diff_format }}
+
+ +
+ {{ + chart.pie_chart('minor_edis', + [{ + label: msg('minor-edits'), + value: te.totalMinor, + percentage: (te.totalMinor / te.numTopEdits) * 100 + }, + { + label: msg('major-edits'), + value: te.numTopEdits - te.totalMinor, + percentage: 100 - ((te.totalMinor / te.numTopEdits) * 100) + }] + ) + }} + {{ + chart.pie_chart('auto_edis', + [{ + label: msg('auto-edits'), + value: te.totalAutomated, + percentage: (te.totalAutomated / te.numTopEdits) * 100 + }, + { + label: msg('manual-edits'), + value: te.numTopEdits - te.totalAutomated, + percentage: 100 - ((te.totalAutomated / te.numTopEdits) * 100) + }] + ) + }} + {{ + chart.pie_chart('reverted_edis', + [{ + label: msg('reverted-edits'), + value: te.totalReverted, + percentage: (te.totalReverted / te.numTopEdits) * 100 + }, + { + label: msg('unreverted-edits'), + value: te.numTopEdits - te.totalReverted, + percentage: 100 - ((te.totalReverted / te.numTopEdits) * 100) + }] + ) + }} +
+ +
+ 1 {{ msg('reverted-desc') }} +
2 {{ msg('average-time-bw-edits') }} +
3 {{ msg('text-added-description') }} +
- - - {% for key in ['date', 'links', 'size', 'edit-summary'] %} - - {% endfor %} - +
- - {{ msg(key)|capitalize_first }} - {% if key != "links" %}{% endif %} - -
+ + {% for key in ['date', 'links', 'size', 'edit-summary'] %} + + {% endfor %} + - {% for rev in revisions|reverse %} - - - - - - - {% endfor %} -
+ + {{ msg(key)|capitalize_first }} + {% if key != "links" %}{% endif %} + +
- {{ wiki.diffLink(rev) }} - · - {% set offset = date(rev.year ~ '-' ~ rev.month ~ '-01')|date('Ymt') ~ '235959' %} - {{ wiki.pageHistLink(page, msg('history'), offset) }} - - {{ rev.size|diff_format }} - - {{ rev.wikifiedSummary|raw }} -
+ {% for rev in te.topEdits %} + + + {{ wiki.permaLink(rev) }} + + + {{ wiki.diffLink(rev) }} + · + {% set offset = date(rev.year ~ '-' ~ rev.month ~ '-01')|date('Ymt') ~ '235959' %} + {{ wiki.pageHistLink(page, msg('history'), offset) }} + + + {{ rev.size|diff_format }} + + + {% if rev.reverted %}({{ msg('reverted')|lower }}) {% endif %} + {{ rev.wikifiedSummary|raw }} + + + {% endfor %} + + {% endif %} {% endset %} {{ layout.content_block('topedits-article', content, msg('topedits-article-desc'), null, false, false) }} diff --git a/app/config/semi_automated.yml b/app/config/semi_automated.yml index a8c743e9e..20035bc90 100644 --- a/app/config/semi_automated.yml +++ b/app/config/semi_automated.yml @@ -32,7 +32,8 @@ parameters: Undo: regex: '^(Undid|Undo) revision \d+ by \[\[Special:(Contribs|Contributions)\/.*?\|.*?\]\]' link: Special:MyLanguage/Project:Undo - revert: true + # This is not considered a revert because you can undo + # any arbitrary revision, not just the previous one. Huggle: regex: '\(\[\[WP:HG' tag: huggle diff --git a/i18n/en.json b/i18n/en.json index b3e227722..1abaa8241 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -78,6 +78,7 @@ "date-range-outside-revisions": "No revisions found for the given date range", "delete": "Delete", "deleted": "Deleted", + "deleted-bytes": "Deleted (bytes)", "deleted-edits": "Deleted edits", "deleted-pages": "Deleted pages", "delpagesfilter-both": "Include live and deleted pages", @@ -170,6 +171,7 @@ "major-contributors": "Major contributors", "major-edits": "Major edits", "major-edits-with-summaries": "Major edits with summaries", + "manual-edits": "Manual edits", "max-text-added": "Max. text added", "max-text-deleted": "Max. text deleted", "memory-usage": "Taken $1 {{PLURAL:$2|megabyte|megabytes}} of memory to execute.", @@ -279,6 +281,8 @@ "result": "Result", "result-by-name": "Result by name / IP", "result-by-time": "Result by time", + "reverted": "Reverted", + "reverted-desc": "Reverted edits are edits that were reverted with the immediately following edit", "reverted-edits": "Reverted edits", "revision-delete": "Revision delete", "rfx-bureaucrat": "This tool can also analyze Requests for bureaucratship pages", @@ -372,6 +376,7 @@ "unique-references": "Unique references", "unknown": "Unknown", "unprotect": "Unprotect", + "unreverted-edits": "Unreverted edits", "user": "User", "user-groups": "User groups", "user-id": "User ID", diff --git a/i18n/qqq.json b/i18n/qqq.json index 94bffd2a3..9d9830d10 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -79,6 +79,7 @@ "date-range-outside-revisions": "Text shown in info flash message saying that choosen date range does not contain any revisions", "delete": "Name of a MediaWiki log action, used as header of a row in the table of log action counts for a user.\n{{Identical|Delete}}", "deleted": "Deleted in the context of deleted text or deleted edits.\n{{Identical|Deleted}}", + "deleted-bytes": "Label for the number of bytes that were removed from an article.", "deleted-edits": "Deleted edits of a user", "deleted-pages": "Filter for page's deletion state", "delpagesfilter-both": "Filter to show both deleted and live pages", @@ -169,6 +170,7 @@ "major-contributors": "Heading for list of authors who made major contributions to XTools.", "major-edits": "Label for number of non-minor edits.", "major-edits-with-summaries": "Value for the number of major edits that have edit summaries.", + "manual-edits": "Label for number of manual edits made to an article, e.g. without the help of semi-automated tools like Huggle.", "max-text-added": "Maximun text added by a user, short word or abbreviation for Max. (if possible)", "max-text-deleted": "Maximum text deleted by a user, short word or abbreviation for Max. (if possible)", "memory-usage": "Text showing how much memory the tool took to execute. $1 is the number of megabytes.", @@ -278,7 +280,9 @@ "result": "Label/header for the query result\n{{Identical|Result}}", "result-by-name": "Label for results by name or IP", "result-by-time": "Label for Results by time", - "reverted-edits": "Reverted edits of a user", + "reverted": "Text indicating an edit was reverted.\n{{Identical|Reverted}}", + "reverted-desc": "Description of what it means that a edit was reverted in the TopEdits tool. It only detects surrounding edits, so the wording 'immediately following edit' is important.", + "reverted-edits": "Reverted edits of a user.", "revision-delete": "Name of a MediaWiki log action, used as header of a row in the table of log action counts for a user.", "rfx-bureaucrat": "Further intro message for RfX Analysis tool.", "rfx-duplicates": "Label for number of duplicate votes that were detected in a RfX.\n{{Identical|Duplicate}}", @@ -371,6 +375,7 @@ "unique-references": "Label for the number of unique references that are in an article.", "unknown": "General term.\n{{Identical|Unknown}}", "unprotect": "Name of a MediaWiki log action, used as header of a row in the table of log action counts for a user\n{{Identical|Unprotect}}", + "unreverted-edits": "Label for number of edits that were not reverted in an article.", "user": "Label for the user that the statistics are about.\n{{Identical|User}}", "user-groups": "Label for the list of 'groups' that a user belongs to.", "user-id": "Registration id of a user (short term)", diff --git a/src/AppBundle/Controller/TopEditsController.php b/src/AppBundle/Controller/TopEditsController.php index 32a4c943c..e691fa7c6 100644 --- a/src/AppBundle/Controller/TopEditsController.php +++ b/src/AppBundle/Controller/TopEditsController.php @@ -132,11 +132,13 @@ public function namespaceTopEdits(Request $request, User $user, Project $project */ $limit = $isSubRequest ? 10 : null; - $topEdits = new TopEdits($project, $user, $namespace, $limit); + $topEdits = new TopEdits($project, $user, null, $namespace, $limit); $topEditsRepo = new TopEditsRepository(); $topEditsRepo->setContainer($this->container); $topEdits->setRepository($topEditsRepo); + $topEdits->prepareData(); + return $this->render('topedits/result_namespace.html.twig', [ 'xtPage' => 'topedits', 'xtTitle' => $user->getUsername(), @@ -168,21 +170,13 @@ protected function singlePageTopEdits(User $user, Project $project, $namespaceId return $page; } - // Get all revisions of this page by this user. - $revisionsData = $page->getRevisions($user); + // FIXME: add pagination. + $topEdits = new TopEdits($project, $user, $page); + $topEditsRepo = new TopEditsRepository(); + $topEditsRepo->setContainer($this->container); + $topEdits->setRepository($topEditsRepo); - // Loop through all revisions and format dates, find totals, etc. - $totalAdded = 0; - $totalRemoved = 0; - $revisions = []; - foreach ($revisionsData as $revision) { - if ($revision['length_change'] > 0) { - $totalAdded += $revision['length_change']; - } else { - $totalRemoved += $revision['length_change']; - } - $revisions[] = new Edit($page, $revision); - } + $topEdits->prepareData(); // Send all to the template. return $this->render('topedits/result_article.html.twig', [ @@ -191,10 +185,7 @@ protected function singlePageTopEdits(User $user, Project $project, $namespaceId 'project' => $project, 'user' => $user, 'page' => $page, - 'total_added' => $totalAdded, - 'total_removed' => $totalRemoved, - 'revisions' => $revisions, - 'revision_count' => count($revisions), + 'te' => $topEdits, ]); } @@ -245,7 +236,7 @@ public function topEditsUserApiAction(Request $request, $namespace = 0, $article $response->setEncodingOptions(JSON_NUMERIC_CHECK); if ($article === '') { - $data = $topEdits->getTopEdits(); + $data = $topEdits->getTopEditsNamespace(); if (is_numeric($namespace)) { $data = $data[$namespace]; diff --git a/src/AppBundle/Helper/AutomatedEditsHelper.php b/src/AppBundle/Helper/AutomatedEditsHelper.php index a6a958333..2106589df 100644 --- a/src/AppBundle/Helper/AutomatedEditsHelper.php +++ b/src/AppBundle/Helper/AutomatedEditsHelper.php @@ -150,7 +150,7 @@ function ($tool) { } ); - // If 'revert' is set to `true`, the use 'regex' as the regular expression, + // 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 [ diff --git a/src/Xtools/Edit.php b/src/Xtools/Edit.php index 0c955d19a..8c171c08d 100644 --- a/src/Xtools/Edit.php +++ b/src/Xtools/Edit.php @@ -42,6 +42,9 @@ class Edit extends Model /** @var string The SHA-1 of the wikitext as of the revision. */ protected $sha; + /** @var bool Whether this edit was later reverted. */ + protected $reverted; + /** * Edit constructor. * @param Page $page @@ -76,6 +79,11 @@ public function __construct(Page $page, $attrs) ? $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 reverted. + $this->reverted = isset($attrs['reverted']) ? (bool)$attrs['reverted'] : null; } /** @@ -215,6 +223,16 @@ public function getSha() return $this->sha; } + /** + * Was this edit reported as having been reverted? + * The value for this is merely passed in from precomputed data. + * @return bool + */ + public function isReverted() + { + return $this->reverted; + } + /** * Get edit summary as 'wikified' HTML markup * @param bool $useUnnormalizedPageTitle Use the unnormalized page title to avoid diff --git a/src/Xtools/PageRepository.php b/src/Xtools/PageRepository.php index 9d160974d..b25ae1066 100644 --- a/src/Xtools/PageRepository.php +++ b/src/Xtools/PageRepository.php @@ -104,7 +104,7 @@ public function getPagesWikitext(Project $project, $pageTitles) * @param User|null $user Specify to get only revisions by the given user. * @param false|int $start * @param false|int $end - * @return string[] Each member with keys: id, timestamp, length- + * @return string[] Each member with keys: id, timestamp, length. */ public function getRevisions(Page $page, User $user = null, $start = false, $end = false) { @@ -149,7 +149,7 @@ public function getRevisionsStmt( $end = false ) { $revTable = $this->getTableName($page->getProject()->getDatabaseName(), 'revision'); - $userClause = $user ? "revs.rev_user_text in (:username) AND " : ""; + $userClause = $user ? "revs.rev_user_text = :username AND " : ""; // This sorts ascending by rev_timestamp because ArticleInfo must start with the oldest // revision and work its way forward for proper processing. Consequently, if we want to do @@ -204,7 +204,7 @@ public function getNumRevisions(Page $page, User $user = null, $start = false, $ } $revTable = $page->getProject()->getTableName('revision'); - $userClause = $user ? "rev_user_text in (:username) AND " : ""; + $userClause = $user ? "rev_user_text = :username AND " : ""; $datesConditions = $this->getDateConditions($start, $end); diff --git a/src/Xtools/TopEdits.php b/src/Xtools/TopEdits.php index b443951de..e6f5faa42 100644 --- a/src/Xtools/TopEdits.php +++ b/src/Xtools/TopEdits.php @@ -18,7 +18,10 @@ class TopEdits extends Model /** @var User The user. */ protected $user; - /** @var string[] Top edits object for quick caching, keyed by namespace ID. */ + /** @var Page The page (if applicable). */ + protected $page; + + /** @var string[]|Edit[] Top edits, either to a page or across namespaces. */ protected $topEdits = []; /** @var int Number of rows to fetch. */ @@ -27,6 +30,21 @@ class TopEdits extends Model /** @var int Which namespace we are querying for. */ protected $namespace; + /** @var int Number of bytes added across all top edits. */ + protected $totalAdded = 0; + + /** @var int Number of bytes removed across all top edits. */ + protected $totalRemoved = 0; + + /** @var int Number of top edits marked as minor. */ + protected $totalMinor = 0; + + /** @var int Number of automated top edits. */ + protected $totalAutomated = 0; + + /** @var int Number of reverted top edits. */ + protected $totalReverted = 0; + const DEFAULT_LIMIT_SINGLE_NAMESPACE = 100; const DEFAULT_LIMIT_ALL_NAMESPACES = 20; @@ -34,15 +52,17 @@ class TopEdits extends Model * TopEdits constructor. * @param Project $project * @param User $user + * @param Page $page * @param string|int Namespace ID or 'all'. * @param int $limit Number of rows to fetch. This defaults to * DEFAULT_LIMIT_SINGLE_NAMESPACE if $this->namespace is a single namespace (int), * and DEFAULT_LIMIT_ALL_NAMESPACES if $this->namespace is 'all'. */ - public function __construct(Project $project, User $user, $namespace = 0, $limit = null) + public function __construct(Project $project, User $user, Page $page = null, $namespace = 0, $limit = null) { $this->project = $project; $this->user = $user; + $this->page = $page; $this->namespace = $namespace === 'all' ? 'all' : (int)$namespace; if ($limit) { @@ -73,16 +93,101 @@ public function getNamespace() } /** - * Get the top edits by a user in the given namespace, or 'all' namespaces. - * This is the public function that should be used. - * @return string[] Results of self::getTopEditsByNamespace(), keyed by namespace. + * Get total number of bytes added. + * @return int + */ + public function getTotalAdded() + { + return $this->totalAdded; + } + + /** + * Get total number of bytes removed. + * @return int + */ + public function getTotalRemoved() + { + return $this->totalRemoved; + } + + /** + * Get total number of edits marked as minor. + * @return int + */ + public function getTotalMinor() + { + return $this->totalMinor; + } + + /** + * Get total number of automated edits. + * @return int + */ + public function getTotalAutomated() + { + return $this->totalAutomated; + } + + /** + * Get total number of edits that were reverted. + * @return int + */ + public function getTotalReverted() + { + return $this->totalReverted; + } + + /** + * Get the top edits data. + * @return string[]|Edit[] */ public function getTopEdits() { - if (count($this->topEdits) > 0) { - return $this->topEdits; + return $this->topEdits; + } + + /** + * Get the total number of top edits. + * @return int + */ + public function getNumTopEdits() + { + return count($this->topEdits); + } + + /** + * Get the averate time between edits (in days). + * @return double + */ + public function getAtbe() + { + $firstDateTime = $this->topEdits[0]->getTimestamp(); + $lastDateTime = end($this->topEdits)->getTimestamp(); + $secs = $firstDateTime->getTimestamp() - $lastDateTime->getTimestamp(); + $days = $secs / (60 * 60 * 24); + return $days / count($this->topEdits); + } + + /** + * Fetch and store all the data we need to show the TopEdits view. + * This is the public method that should be called before using + * the getter methods. + */ + public function prepareData() + { + if (isset($this->page)) { + $this->topEdits = $this->getTopEditsPage(); + } else { + $this->topEdits = $this->getTopEditsNamespace(); } + } + /** + * Get the top edits by a user in the given namespace, or 'all' namespaces. + * @return string[] Results keyed by namespace. + */ + private function getTopEditsNamespace() + { if ($this->namespace === 'all') { $pages = $this->getRepository()->getTopEditsAllNamespaces( $this->project, @@ -98,8 +203,83 @@ public function getTopEdits() ); } - $this->topEdits = $this->formatTopPages($pages); - return $this->topEdits; + return $this->formatTopPagesNamespace($pages); + } + + /** + * Get the top edits to the given page. + * @return Edit[] + */ + private function getTopEditsPage() + { + $revs = $this->getRepository()->getTopEditsPage( + $this->page, + $this->user + ); + + return $this->formatTopEditsPage($revs); + } + + /** + * Format the results for top edits to a single page. This method also computes + * totals for added/removed text, automated and reverted edits. + * @param string[] $revs As returned by TopEditsRepository::getTopEditsPage. + * @return Edit[] + */ + private function formatTopEditsPage($revs) + { + $edits = []; + + $aeh = $this->getRepository() + ->getContainer() + ->get('app.automated_edits_helper'); + + foreach ($revs as $revision) { + // Check if the edit was reverted based on the edit summary of the following edit. + // If so, update $revision so that when an Edit is instantiated, it will + // have the 'reverted' option set. + if ($aeh->isRevert($revision['parent_comment'], $this->project->getDomain())) { + $revision['reverted'] = 1; + } + + $edit = $this->getEditAndIncrementCounts($revision); + + $edits[] = $edit; + } + + return $edits; + } + + /** + * Create an Edit instance for the given revision, and increment running totals. + * This is used by self::formatTopEditsPage(). + * @param string[] $revision Revision row as retrieved from the database. + * @return Edit + */ + private function getEditAndIncrementCounts($revision) + { + $edit = new Edit($this->page, $revision); + + if ($edit->isAutomated($this->getRepository()->getContainer())) { + $this->totalAutomated++; + } + + if ($edit->isMinor()) { + $this->totalMinor++; + } + + if ($edit->isReverted()) { + $this->totalReverted++; + } else { + // Length changes don't count if they were reverted. + if ($revision['length_change'] > 0) { + $this->totalAdded += $revision['length_change']; + } else { + $this->totalRemoved += $revision['length_change']; + } + } + + return $edit; } /** @@ -108,7 +288,7 @@ public function getTopEdits() * or TopEditsRepository::getTopEditsAllNamespaces. * @return string[] Same as input but with 'displaytitle' and 'page_title_ns'. */ - private function formatTopPages($pages) + private function formatTopPagesNamespace($pages) { /** @var string[] The top edited pages, keyed by namespace ID. */ $topEditedPages = []; diff --git a/src/Xtools/TopEditsRepository.php b/src/Xtools/TopEditsRepository.php index 57a71c9f7..71489d177 100644 --- a/src/Xtools/TopEditsRepository.php +++ b/src/Xtools/TopEditsRepository.php @@ -5,6 +5,8 @@ namespace Xtools; +use Symfony\Component\DependencyInjection\Container; + /** * TopEditsRepository is responsible for retrieving data from the database * about the top-edited pages of a user. It doesn't do any post-processing @@ -13,6 +15,15 @@ */ class TopEditsRepository extends Repository { + /** + * Expose the container to the TopEdits class. + * @return Container + */ + public function getContainer() + { + return $this->container; + } + /** * Get the top edits by a user in a single namespace. * @param Project $project @@ -25,7 +36,7 @@ class TopEditsRepository extends Repository public function getTopEditsNamespace(Project $project, User $user, $namespace = 0, $limit = 100) { // Set up cache. - $cacheKey = $this->getCacheKey(func_get_args(), 'topedits'); + $cacheKey = $this->getCacheKey(func_get_args(), 'topedits_ns'); if ($this->cache->hasItem($cacheKey)) { return $this->cache->getItem($cacheKey)->get(); } @@ -34,10 +45,11 @@ public function getTopEditsNamespace(Project $project, User $user, $namespace = $revisionTable = $this->getTableName($project->getDatabaseName(), 'revision'); $hasPageAssessments = $this->isLabs() && $project->hasPageAssessments() && $namespace === 0; + $paTable = $this->getTableName($project->getDatabaseName(), 'page_assessments'); $paSelect = $hasPageAssessments ? ", ( SELECT pa_class - FROM page_assessments + FROM $paTable WHERE pa_page_id = page_id AND pa_class != 'Unknown' LIMIT 1 @@ -125,6 +137,90 @@ public function getTopEditsAllNamespaces(Project $project, User $user, $limit = return $results; } + /** + * Get the top edits by a user to a single page. + * @param Page $page + * @param User $user + * @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) + { + // 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, true); + + // Now we need to get the most recent revision, since the childrevs stuff excludes it. + $lastRev = $this->queryTopEditsPage($page, $user, false); + if ($lastRev[0]['id'] !== end($results)['id']) { + $results = array_merge($lastRev, $results); + } + + // Cache for 10 minutes, and return. + $this->setCache($cacheKey, $results); + + return $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 boolean $childRevs Whether to include child revisions. + * @return string[] Each row with keys 'id', 'timestamp', 'minor', 'length', + * 'length_change', 'reverted', 'user_id', 'username', 'comment', 'parent_comment' + */ + private function queryTopEditsPage(Page $page, User $user, $childRevs = false) + { + $revTable = $this->getTableName($page->getProject()->getDatabaseName(), 'revision'); + + if ($childRevs) { + $childSelect = ', (CASE WHEN childrevs.rev_sha1 = parentrevs.rev_sha1 THEN 1 ELSE 0 END) AS reverted, + childrevs.rev_comment AS parent_comment'; + $childJoin = "LEFT JOIN $revTable AS childrevs ON (revs.rev_id = childrevs.rev_parent_id)"; + $childWhere = 'AND childrevs.rev_page = :pageid'; + $childLimit = ''; + } else { + $childSelect = ', "" AS parent_comment, 0 AS reverted'; + $childJoin = ''; + $childWhere = ''; + $childLimit = 'LIMIT 1'; + } + + $sql = "SELECT + revs.rev_id AS id, + revs.rev_timestamp AS timestamp, + revs.rev_minor_edit AS minor, + revs.rev_len AS length, + (CAST(revs.rev_len AS SIGNED) - IFNULL(parentrevs.rev_len, 0)) AS length_change, + revs.rev_user AS user_id, + revs.rev_user_text AS username, + revs.rev_comment AS comment + $childSelect + FROM $revTable AS revs + LEFT JOIN $revTable AS parentrevs ON (revs.rev_parent_id = parentrevs.rev_id) + $childJoin + WHERE revs.rev_user_text = :username + AND revs.rev_page = :pageid + $childWhere + ORDER BY revs.rev_timestamp DESC + $childLimit"; + + $conn = $this->getProjectsConnection(); + $resultQuery = $conn->executeQuery($sql, [ + 'pageid' => $page->getId(), + 'username' => $user->getUsername(), + ]); + + return $resultQuery->fetchAll(); + } + /** * Get the display titles of the given pages. * @param Project $project diff --git a/tests/Xtools/TopEditsTest.php b/tests/Xtools/TopEditsTest.php index 707537b1d..76ba19483 100644 --- a/tests/Xtools/TopEditsTest.php +++ b/tests/Xtools/TopEditsTest.php @@ -5,9 +5,9 @@ namespace Tests\Xtools; -use PHPUnit_Framework_TestCase; use Xtools\TopEdits; use Xtools\TopEditsRepository; +use Xtools\Page; use Xtools\User; use Xtools\Project; use Xtools\ProjectRepository; @@ -16,7 +16,7 @@ /** * Tests of the TopEdits class. */ -class TopEditsTest extends PHPUnit_Framework_TestCase +class TopEditsTest extends WebTestCase { /** @var Project The project instance. */ protected $project; @@ -35,14 +35,19 @@ class TopEditsTest extends PHPUnit_Framework_TestCase */ public function setUp() { - $this->project = new Project('TestProject'); + $this->project = new Project('en.wikipedia.org'); $this->projectRepo = $this->getMock(ProjectRepository::class); $this->projectRepo->method('getMetadata') ->willReturn(['namespaces' => [0 => 'Main', 3 => 'User_talk']]); $this->project->setRepository($this->projectRepo); $this->user = new User('Test user'); + $client = static::createClient(); + $container = $client->getContainer(); + $this->teRepo = $this->getMock(TopEditsRepository::class); + $this->teRepo->method('getContainer') + ->willReturn($container); } /** @@ -56,17 +61,17 @@ public function testBasic() $this->assertEquals(100, $te->getLimit()); // Single namespace, explicit configuration. - $te2 = new TopEdits($this->project, $this->user, 5, 50); + $te2 = new TopEdits($this->project, $this->user, null, 5, 50); $this->assertEquals(5, $te2->getNamespace()); $this->assertEquals(50, $te2->getLimit()); // All namespaces, so limit set. - $te3 = new TopEdits($this->project, $this->user, 'all'); + $te3 = new TopEdits($this->project, $this->user, null, 'all'); $this->assertEquals('all', $te3->getNamespace()); $this->assertEquals(20, $te3->getLimit()); // All namespaces, explicit limit. - $te4 = new TopEdits($this->project, $this->user, 'all', 3); + $te4 = new TopEdits($this->project, $this->user, null, 'all', 3); $this->assertEquals('all', $te4->getNamespace()); $this->assertEquals(3, $te4->getLimit()); } @@ -76,13 +81,13 @@ public function testBasic() */ public function testTopEditsAllNamespaces() { - $te = new TopEdits($this->project, $this->user, 'all', 2); + $te = new TopEdits($this->project, $this->user, null, 'all', 2); $this->teRepo->expects($this->once()) ->method('getTopEditsAllNamespaces') ->with($this->project, $this->user, 2) ->willReturn(array_merge( - $this->topEditsRepoFactory()[0], - $this->topEditsRepoFactory()[3] + $this->topEditsNamespaceFactory()[0], + $this->topEditsNamespaceFactory()[3] )); $this->teRepo->expects($this->once()) ->method('getDisplayTitles') @@ -93,6 +98,7 @@ public function testTopEditsAllNamespaces() 'User_talk:Jimbo_Wales' => 'User talk:Jimbo Wales', ]); $te->setRepository($this->teRepo); + $te->prepareData(); $result = $te->getTopEdits(); $this->assertEquals([0, 3], array_keys($result)); @@ -120,11 +126,11 @@ public function testTopEditsAllNamespaces() */ public function testTopEditsNamespace() { - $te = new TopEdits($this->project, $this->user, 3, 2); + $te = new TopEdits($this->project, $this->user, null, 3, 2); $this->teRepo->expects($this->once()) ->method('getTopEditsNamespace') ->with($this->project, $this->user, 3, 2) - ->willReturn($this->topEditsRepoFactory()[3]); + ->willReturn($this->topEditsNamespaceFactory()[3]); $this->teRepo->expects($this->once()) ->method('getDisplayTitles') ->willReturn([ @@ -132,6 +138,7 @@ public function testTopEditsNamespace() 'User_talk:Jimbo_Wales' => 'User talk:Jimbo Wales', ]); $te->setRepository($this->teRepo); + $te->prepareData(); $result = $te->getTopEdits(); $this->assertEquals([3], array_keys($result)); @@ -148,10 +155,10 @@ public function testTopEditsNamespace() } /** - * Data for self::testTopEdits(). + * Data for self::testTopEditsAllNamespaces() and self::testTopEditsNamespace(). * @return string[] */ - public function topEditsRepoFactory() + private function topEditsNamespaceFactory() { return [ 0 => [ @@ -188,4 +195,84 @@ public function topEditsRepoFactory() ], ]; } + + /** + * Top edits to a single page. + */ + public function testTopEditsPage() + { + $page = new Page($this->project, 'Test page'); + + $te = new TopEdits($this->project, $this->user, $page); + $this->teRepo->expects($this->once()) + ->method('getTopEditsPage') + ->willReturn($this->topEditsPageFactory()); + $te->setRepository($this->teRepo); + + $te->prepareData(); + + $this->assertEquals(4, $te->getNumTopEdits()); + $this->assertEquals(100, $te->getTotalAdded()); + $this->assertEquals(-50, $te->getTotalRemoved()); + $this->assertEquals(1, $te->getTotalMinor()); + $this->assertEquals(1, $te->getTotalAutomated()); + $this->assertEquals(2, $te->getTotalReverted()); + $this->assertEquals(10, $te->getTopEdits()[1]->getId()); + $this->assertEquals(22.5, $te->getAtbe()); + } + + /** + * Test data for self::TopEditsPage(). + * @return string[] + */ + private function topEditsPageFactory() + { + 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', + ], + ]; + } } diff --git a/web/static/css/topedits.scss b/web/static/css/topedits.scss new file mode 100644 index 000000000..c7e056b49 --- /dev/null +++ b/web/static/css/topedits.scss @@ -0,0 +1,21 @@ +@import 'mixins.scss'; + +.topedits { + .color-icon { + margin-right: 3px; + } + + #reverted_edis_legend .legend-body div:first-of-type .legend-label::after { + content: '1'; + font-size: 80%; + vertical-align: 4px; + } + + .footnotes { + margin: 20px 0; + } + + .bg-warning { + background: #fcf8e3 !important; + } +}