',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