{% 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)
+ }]
+ )
+ }}
+
+
+
-
-
- {% for key in ['date', 'links', 'size', 'edit-summary'] %}
-
-
- {{ msg(key)|capitalize_first }}
- {% if key != "links" %}{% endif %}
-
- |
- {% endfor %}
-
+
+
+ {% for key in ['date', 'links', 'size', 'edit-summary'] %}
+
+
+ {{ msg(key)|capitalize_first }}
+ {% if key != "links" %}{% endif %}
+
+ |
+ {% endfor %}
+
- {% for rev in revisions|reverse %}
-
-
- {{ 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 }}
- |
-
- {{ rev.wikifiedSummary|raw }}
- |
-
- {% endfor %}
-
+ {% 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;
+ }
+}